From 1db489836a8c22c4ec9d924a5b03a4a036a0529a Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 16 Jul 2026 12:59:50 +0200 Subject: [PATCH] perf(EN-057): gate the occlusion capture on real consumers; close EN-053 as re-measured EN-057: the Hi-Z occlusion reduce + readback ran every frame even when no rasterized scene node existed to consume a verdict - the shooter's only nodes are 267 gi_only proxies, which never draw. The engine now derives has_consumers per frame from the scene graph and the culler skips the capture without it. Going consumer-less also invalidates the grid, so a consumer appearing later reads test_aabb's conservative 'potentially visible' answer instead of a stale capture - the gate cannot wrongly cull a draw by construction. The pyramid itself still builds (SSAO consumes it). Default true, so hosts that never call the setter keep today's behaviour. 127 tests pass; full PERFTEST bisect runs clean on the shooter. EN-053: CLOSED without implementation, and that is the result. The ticket's premise - shadow sampling as the ~5.3 ms top GPU lever - was re-measured tonight before building anything: shadows-off buys 0.4 ms on the current frame (shadow_pass 0.09 ms), while no-ssgi buys 4.9 ms. The 5.3 ms was July 12's frame; house v2, 120k grass and the photoscan terrain landed since. A shadow-mask pass tonight would have been the SH-049 attribution mistake with a week of renderer surgery attached. Registry entry carries the numbers and the re-open criterion. --- docs/tickets.md | 24 ++++++++++++++++++++++-- native/shared/src/engine.rs | 6 ++++++ native/shared/src/renderer/occlusion.rs | 22 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/tickets.md b/docs/tickets.md index 15dcef9a..157b5732 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -1763,7 +1763,20 @@ behave. `rel` is now normalised to '/'. Run it on Windows: 0 failures. --- -## EN-053 — Shadows cost ~5.3 ms in SAMPLING, and four passes each pay it 🔴 *(2026-07-16 audit)* +## EN-053 — Shadows cost ~5.3 ms in SAMPLING, and four passes each pay it ❌ *closed same day — the premise did not survive re-measurement* + +> **Measured before building (2026-07-16 evening, PERFTEST mode-0 bisect on +> the shooter's current main):** toggling shadows off buys **0.4 ms** +> (39.88 → 39.48 ms), with `shadow_pass` at 0.09 ms — while `no-ssgi` buys +> **4.9 ms**. The 5.3 ms figure was measured 2026-07-12 and the frame changed +> under it (house v2, 120k grass, photoscan terrain landed since). Building +> the shadow-mask pass tonight would have repeated the SH-049 attribution +> mistake with a week of renderer surgery attached. The file:line facts below +> stay true (16 taps in a ±1-texel disk, four independent consumers) — they +> are just not where the frame's time is. Re-open only from a fresh bisect +> that shows sampling as a top-3 lever; today's top lever is SSGI cost +> (quality-gated — the direction is making it cheaper, e.g. the HW ray-query +> path, not turning it off). The shadow **pass** is fixed (EN-043/044/045 — 0.18 ms). What remains is the per-pixel PCF: the deferred lighting path runs a fixed **16-tap** Poisson @@ -1841,7 +1854,14 @@ budget for at 4K. All VERIFIED still present: Fix: dirty-flag the lighting UBO, cache bloom + composite bind groups keyed on the views they wrap, build the graph once and rebuild on topology change. -## EN-057 — Hi-Z occlusion runs every frame for zero consumers 🟡 *(2026-07-16 audit)* +## EN-057 — Hi-Z occlusion runs every frame for zero consumers ✅ *(shipped same day)* + +> The occlusion reduce + readback now gates on a per-frame +> `set_has_consumers` flag the engine derives from the scene graph (any +> visible non-gi_only node). Going consumer-less also invalidates the grid, +> so a consumer appearing later reads the conservative "potentially visible" +> answer instead of a stale capture — the gate cannot wrongly cull a draw by +> construction. The Hi-Z pyramid itself still builds: SSAO consumes it. The pyramid build + occlusion reduce + readback gate only on `ssao_enabled` and the culler's own flag (`mod.rs:10707-10731`, `occlusion.rs:221-223`) — diff --git a/native/shared/src/engine.rs b/native/shared/src/engine.rs index 967faf68..a6402302 100644 --- a/native/shared/src/engine.rs +++ b/native/shared/src/engine.rs @@ -175,6 +175,12 @@ impl EngineState { // Collect last frame's occlusion-grid readback (if it // completed) before culling against it. self.renderer.occlusion.poll(&self.renderer.device); + // EN-057 — skip the occlusion reduce + readback entirely when no + // rasterized node could consume a verdict (gi_only proxies never + // draw; the shooter's 267 of them paid this every frame for + // nothing). The Hi-Z pyramid itself stays: SSAO consumes it. + self.renderer.occlusion.set_has_consumers( + self.scene.nodes.iter().any(|(_, n)| n.visible && !n.gi_only)); self.scene.prepare( &self.renderer.device, &self.renderer.queue, diff --git a/native/shared/src/renderer/occlusion.rs b/native/shared/src/renderer/occlusion.rs index 43e588c6..94171771 100644 --- a/native/shared/src/renderer/occlusion.rs +++ b/native/shared/src/renderer/occlusion.rs @@ -75,6 +75,12 @@ pub struct OcclusionCuller { grid_valid: bool, grid_vp: [[f32; 4]; 4], pub enabled: bool, + /// EN-057 — false when no rasterized scene node exists to consume the + /// culling verdicts (e.g. a scene whose only nodes are gi_only proxies: + /// they never draw, so the reduce + readback benefited zero draws every + /// frame). Set per frame by the engine from the scene graph; defaults to + /// true so hosts that never call the setter keep today's behaviour. + has_consumers: bool, /// set when record() ran this frame so after_submit() knows to map recorded_this_frame: bool, } @@ -173,10 +179,24 @@ impl OcclusionCuller { grid_valid: false, grid_vp: [[0.0; 4]; 4], enabled: true, + has_consumers: true, recorded_this_frame: false, } } + /// EN-057 — tell the culler whether any rasterized consumer exists this + /// frame. Going consumer-less also invalidates the grid, so if a + /// consumer appears later the interim frames read the conservative + /// "potentially visible" answer (test_aabb on an invalid grid) instead + /// of a stale capture — the gate cannot cost a wrongly-culled draw by + /// construction. + pub fn set_has_consumers(&mut self, has: bool) { + if !has { + self.grid_valid = false; + } + self.has_consumers = has; + } + /// Drop cached bind group (call when the Hi-Z chain is reallocated, /// e.g. on resize / render-scale change). pub fn invalidate_bindings(&mut self) { @@ -218,7 +238,7 @@ impl OcclusionCuller { vp: [[f32; 4]; 4], ) { self.recorded_this_frame = false; - if !self.enabled { + if !self.enabled || !self.has_consumers { return; } let rb = &mut self.readbacks[self.parity];