From d6ffdfa63882b82ed586fc73904250b3c4b9a8bf Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 13 Sep 2026 04:18:38 +0300 Subject: [PATCH] perf(render3d): a depth prepass for the near tree foliage Needle-card crowns are many cut-out quads deep and a discarding shader turns early depth rejection off, so every card behind the front one ran the full lighting shader. The near tree LODs now write depth first (depth.frag, the same alpha coverage test), terrain under them is rejected before shading, and the lit pass draws them with no discard and an equal depth test (invariant gl_Position). R3D_NOPREPASS=1 restores the old path. Dense forest on a Windows PC: 61 -> 78 fps at 3840x2160, 116 -> 164 fps at 1920x1080. Co-Authored-By: Claude Opus 5 --- changes/foliage-prepass.md | 9 ++++ packages/ludic.render3d/render.ludic | 20 +++++++ packages/ludic.render3d/scatter.ludic | 63 +++++++++++++++++++++- packages/ludic.render3d/shaders/depth.frag | 23 ++++++++ packages/ludic.render3d/shaders/model.frag | 5 ++ packages/ludic.render3d/shaders/model.vert | 4 ++ 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 changes/foliage-prepass.md create mode 100644 packages/ludic.render3d/shaders/depth.frag diff --git a/changes/foliage-prepass.md b/changes/foliage-prepass.md new file mode 100644 index 00000000..88a9ade1 --- /dev/null +++ b/changes/foliage-prepass.md @@ -0,0 +1,9 @@ +bump: patch +type: perf +**Dense forest renders faster** — a depth prepass for the near tree foliage in `ludic.render3d`. + +A crown of needle cards is many cut-out quads deep, and a shader that can `discard` turns +early depth rejection off, so every card behind the front one ran the full lighting shader. +In a dense stand at 3840x2160 that made the vegetation pass the largest in the frame. The near +tree LODs now write depth first with `depth.frag` (the same alpha coverage test), the terrain +beneath them is rejected before shading, and the lit pass draws them with no `discard` and an equal depth test. `R3D_NOPREPASS=1` restores the old path for comparison. diff --git a/packages/ludic.render3d/render.ludic b/packages/ludic.render3d/render.ludic index 25f1db0f..72428e2b 100644 --- a/packages/ludic.render3d/render.ludic +++ b/packages/ludic.render3d/render.ludic @@ -26,6 +26,12 @@ var r3d_debug_max: bool = false var r3d_cloud_shadow: int = 0x3F000000 # 0.5 var r3d_clip_y: int = 0xCF000000 # -2^31: no clipping +# R3D_NOPREPASS=1: light the foliage the old way, every card behind the front one included +var r3d_prepass_env: int = -1 +function r3d_prepass_off() -> bool { + if r3d_prepass_env < 0 { r3d_prepass_env = 0; if Os.has_env("R3D_NOPREPASS") { r3d_prepass_env = 1 } } + return r3d_prepass_env == 1 +} function fog_bind(prog: int) -> void { u_f(gl_uniform(prog, "u_clip_y"), r3d_clip_y) u_f(gl_uniform(prog, "u_spec_scale"), F_ONE) @@ -158,6 +164,19 @@ function r3d_frame(time: int) -> void { if water_on and not r3d_no_refl { prof_begin("water reflection"); water_reflection_pass(); prof_end() } prof_cpu_mark("reflection") post_begin_scene() + # the near tree foliage lays its depth down before anything is shaded, so the terrain + # under the stands and the cards behind the front ones are rejected before lighting. + # It has to come straight after post_begin_scene: terrain_sun_prepare binds its own + # target, and depth drawn after it lands in the sun buffer, not the scene's. + if not r3d_prepass_off() { + prof_begin("foliage prepass") + gl_color_mask(0, 0, 0, 0) + scatter_draw_depth() + gl_color_mask(1, 1, 1, 1) + prof_end() + sc_prepass = true + } + prof_cpu_mark("foliage prepass") prof_begin("terrain sun") terrain_sun_prepare() prof_end() @@ -168,6 +187,7 @@ function r3d_frame(time: int) -> void { prof_begin("scene (vegetation)") scene_draw() prof_end() + sc_prepass = false prof_cpu_mark("vegetation") prof_begin("grass") grass_draw() diff --git a/packages/ludic.render3d/scatter.ludic b/packages/ludic.render3d/scatter.ludic index 59fae935..5d89877b 100644 --- a/packages/ludic.render3d/scatter.ludic +++ b/packages/ludic.render3d/scatter.ludic @@ -342,6 +342,13 @@ var sc_blade_tint: words = null var sc_prog_shadow: int = 0 var sc_prog_shadow_wind: int = 0 var sc_prog_shadow_fol: int = 0 # foliage meshes: alpha-tested casters +# The foliage depth prepass (render.ludic): the near tree LODs write depth first with a +# shader that only runs the alpha test, then the lit pass shades them with no discard and +# an equal depth test, so a pixel of needles is lit once rather than once for every card +# stacked behind it. In a dense stand at 4K that overdraw was the largest pass in the frame. +var sc_prog_fol_depth: int = 0 +var sc_prog_fol_eq: int = 0 +var sc_prepass: bool = false # the main pass is drawing over what the prepass laid down var sc_imp_prog: int = 0 var sc_imp_prog_shadow: int = 0 var sc_bake_prog: int = 0 @@ -357,6 +364,8 @@ var sc_a2c: bool = true function scatter_init() -> void { sc_prog = r3d_program("model.vert", "model.frag", "") sc_prog_fol = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define ALPHA_TEST\n") + sc_prog_fol_depth = r3d_program("model.vert", "depth.frag", "#define FOLIAGE\n#define WIND\n#define ALPHA_TEST\n") + sc_prog_fol_eq = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define EQ_PASS\n") sc_prog_wind = r3d_program("model.vert", "model.frag", "#define WIND\n") sc_prog_blade = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define BLADE\n") sc_prog_flower = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define FLOWER\n") @@ -770,7 +779,10 @@ function layer_program(l: Layer, shadow: bool, card: bool) -> int { } if l.blade { return sc_prog_blade } if l.flower { return sc_prog_flower } - if l.foliage { return sc_prog_fol } + if l.foliage { + if sc_prepass and sc_prog_fol_eq != 0 { return sc_prog_fol_eq } + return sc_prog_fol + } if l.wind != 0 { return sc_prog_wind } return sc_prog } @@ -802,6 +814,9 @@ function layer_draw_model(l: Layer, model: Model, vb: int, cnt: int, card: bool, if cnt == 0 { return } let p = layer_program(l, shadow, card) gl_use_program(p) + # over the prepass: only the fragment the prepass kept, at exactly its depth (a texel + # it cut would otherwise pass LEQUAL over the terrain behind and draw the quad solid) + if p == sc_prog_fol_eq { gl_depth_func(GL_EQUAL) } var ground = F_ZERO if l.grounded { ground = F_ONE } u_f(gl_uniform(p, "u_ground"), ground) @@ -850,6 +865,7 @@ function layer_draw_model(l: Layer, model: Model, vb: int, cnt: int, card: bool, mesh_draw_instanced(pr.mesh, cnt) } gl_disable(GL_SAMPLE_ALPHA_TO_COVERAGE) + if p == sc_prog_fol_eq { gl_depth_func(GL_LESS) } } function layer_draw_far(l: Layer, shadow: bool, light_vp: words) -> void { @@ -909,6 +925,51 @@ function layer_draw_shadow(l: Layer, light_vp: words) -> void { var sc_skip_blade: bool = false var sc_skip_flower: bool = false var sc_skip_card: bool = false + +# ---- the foliage depth prepass ----------------------------------------------------------- +# Exactly the instances and positions layer_draw_model will light (the same LOD buckets, +# the same vertex shader, the same ground and wind), into depth only. +function layer_draw_depth(l: Layer, model: Model, vb: int, cnt: int) -> void { + if cnt == 0 or model == null or vb == 0 { return } + let p = sc_prog_fol_depth + gl_use_program(p) + var ground = F_ZERO + if l.grounded { ground = F_ONE } + u_f(gl_uniform(p, "u_ground"), ground) + if l.grounded { terrain_bind_height(p) } + u_f(gl_uniform(p, "u_time"), r3d_time) + u_f(gl_uniform(p, "u_wind"), l.wind) + u_f(gl_uniform(p, "u_model_h"), model.height) + u_mat4(gl_uniform(p, "u_view"), cam_view) + u_mat4(gl_uniform(p, "u_proj"), cam_proj) + u_f(gl_uniform(p, "u_clip_y"), r3d_clip_y) + gl_disable(GL_CULL_FACE) + for i in 0 .. len(model.prims) { + let pr = model.prims[i] + scatter_attach(pr.mesh.vao, vb) + r3d_bind_2d(p, "u_diff", 0, pr.diff) + mesh_draw_instanced(pr.mesh, cnt) + } +} + +# every foliage mesh draw the lit pass will make with sc_prog_fol_eq: not blades, not +# flowers, not card levels (those keep their own alpha and draw as before) +function scatter_draw_depth() -> void { + if sc_prog_fol_depth == 0 { return } + for i in 0 .. len(sc_layers) { + let l = sc_layers[i] + if not l.foliage or l.blade or l.flower { continue } + if r3d_no_trees and l.imp != null { continue } + layer_update(l) + if l.n_lods > 1 { + # (a tree layer is flagged `card` for its distant level; its mesh levels still count) + for k in 0 .. l.n_lods { if l.lod_card[k] != 1 { layer_draw_depth(l, l.lods[k], l.lod_buf[k], l.n_lod[k]) } } + } else if not l.card { + layer_draw_depth(l, l.model, l.buf, l.n_near) + } + } + gl_enable(GL_CULL_FACE) +} function scatter_draw() -> void { for i in 0 .. len(sc_layers) { let l = sc_layers[i] diff --git a/packages/ludic.render3d/shaders/depth.frag b/packages/ludic.render3d/shaders/depth.frag new file mode 100644 index 00000000..54f5a76d --- /dev/null +++ b/packages/ludic.render3d/shaders/depth.frag @@ -0,0 +1,23 @@ +// depth.frag — the foliage depth prepass (scatter_draw_depth, render.ludic). +// +// A crown of needle cards is many cut-out quads deep from any angle. Lit directly, every +// card behind the front one runs the whole lighting shader and is thrown away afterwards, +// because a shader that can discard switches early depth rejection off. So the near tree +// LODs first write depth with this: the same alpha coverage test the lit shader applies, +// and nothing else. The lit pass then draws them with no discard and an equal depth test +// (model.frag's EQ_PASS), and each pixel of foliage is lit once. +// +// The test must match model.frag's ALPHA_TEST exactly - a texel this pass keeps and that +// one would have cut is drawn solid - so it is the same formula, taken at coverage 0.5 +// because without MSAA a fragment is either there or not. +in vec3 v_wpos; +in vec2 v_uv; +uniform sampler2D u_diff; +void main() { + if (v_wpos.y < u_clip_y) discard; + vec4 d = texture(u_diff, v_uv); + float lod = textureQueryLod(u_diff, v_uv).x; + float a = min(d.a * (1.0 + 0.45 * max(lod, 0.0)), 1.0); + float cov = (a - 0.4) / max(fwidth(a), 1e-4) + 0.5; + if (cov < 0.5) discard; +} diff --git a/packages/ludic.render3d/shaders/model.frag b/packages/ludic.render3d/shaders/model.frag index 9f40e465..e1925fef 100644 --- a/packages/ludic.render3d/shaders/model.frag +++ b/packages/ludic.render3d/shaders/model.frag @@ -32,7 +32,12 @@ mat3 cotangentFrame(vec3 N, vec3 p, vec2 uv) { } void main() { +#ifndef EQ_PASS + // (the EQ_PASS variant draws over the foliage prepass: any discard at all would switch + // early depth rejection off and bring the overdraw back, and the prepass has already + // cut both the clip plane and the alpha) if (v_wpos.y < u_clip_y) discard; +#endif vec3 N = normalize(v_nrm); if (!gl_FrontFacing && v_hull >= 0.0) N = -N; #ifdef CARD diff --git a/packages/ludic.render3d/shaders/model.vert b/packages/ludic.render3d/shaders/model.vert index 03cd774a..e0bf27b8 100644 --- a/packages/ludic.render3d/shaders/model.vert +++ b/packages/ludic.render3d/shaders/model.vert @@ -40,6 +40,10 @@ float heightSmooth(sampler2D tex, vec2 uv) { + (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y; } out vec3 v_wpos; +// The foliage prepass (depth.frag) and the lit pass after it compile this same source +// into two programs and compare depths for equality: the position must come out +// bit-identical in both, which is what `invariant` promises. +invariant gl_Position; out vec3 v_nrm; out vec2 v_uv; out float v_seed;