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;