| name | scene-cull-integration |
| description | When adding GPU-driven culling passes to Titan, add standalone dispatch arms (DepthPrepass, SceneCull) WITHOUT modifying GBufferFill/MainForward draw code — keep the existing draw_indexed path intact, add draw_indirect as a separate step after baseline rendering is confirmed. |
| source | auto-skill |
| extracted_at | 2026-06-16T01:53:47.455Z |
Scene Culling Integration — Standalone Pass Pattern
When integrating GPU-driven scene culling (DepthPrepass + SceneCull) into the
Titan rendering pipeline, follow this order and isolation discipline.
The mistake pattern
Modifying the GBufferFill and MainForward dispatch arms to add
draw_indirect alongside the existing draw_indexed path broke ALL rendering
— even for benches that don't use DepthPrepass/SceneCull. The probe bench
produced solid gray frames with no geometry because the draw call restructuring
introduced subtle bugs that were impossible to isolate from the per-frame BVH
build + indirect buffer upload + HZB construction all happening simultaneously.
The correct pattern
-
Add the new passes as standalone dispatch arms. Pass::DepthPrepass and
Pass::SceneCull get their own match arms in lib.rs. They use their
own bind groups, pipelines, and resources. They do NOT touch GBufferFill
or MainForward.
-
Populate scene cull data before the pass loop. When
DepthPrepass | SceneCull are in the pass list, build the BVH, run
frustum cull, upload AABBs + indirect buffer + visibility bitmask +
camera UBO. Gate this behind needs_cull && !groups.is_empty().
-
Leave GBufferFill/MainForward UNCHANGED. They use the original
draw_indexed path. The indirect buffer is populated but not consumed
yet — it exists for readback in the bench tool's visibility report.
-
Confirm baseline rendering works FIRST. Run the probe bench (or any
existing bench) to verify geometry renders correctly. Only then add
draw_indirect consumption.
-
Add draw_indirect as a separate step. Once baseline rendering is
confirmed, add a fallback: if indirect_draw_buffer is Some, use
draw_indirect; otherwise, use draw_indexed. This way benches that
don't populate scene cull data (probe bench, original render bench)
continue to work with the fallback path.
Key invariants
-
Never change the depth_compare or Clear value of existing pipelines.
The GBufferFill pipeline uses Less with Clear(1.0) — these are correct
for reverse-Z and must not be changed to Greater/Clear(0.0) as a
side effect of GPU-driven work.
-
Depth prepass writes to a SEPARATE depth texture (Depth32Float,
half-res). It does NOT write to the main target.depth. The occlusion
HZB is built from the prepass depth texture. The main depth buffer is
managed by GBufferFill independently.
-
Use per-frame inline BGLs + bind groups for the depth prepass. The
main frame_bind_group() has VERTEX|FRAGMENT visibility which conflicts
with the depth-prepass pipeline's VERTEX-only camera BGL.
-
Camera UBO layout must exactly match the WGSL struct field order.
The CPU-side upload puts fields at specific float offsets; the WGSL
CameraUniforms struct reads them by field name. A mismatch (e.g.
putting candidate_count at flags.w when WGSL reads flags.y) is
a silent bug — the shader reads screen dimensions as the candidate
count, threads process garbage AABB data, and occlusion results are
meaningless. Verify with: the WGSL struct field order IS the layout.
Write the CPU upload in the same order.
-
Depth prepass depth_compare must be Greater under reverse-Z.
Always means the last fragment drawn at each pixel wins, which may
be the back cube behind the occluder. The HZB then stores the back
cube's depth, not the occluder's depth, and every candidate finds
itself "visible" because it's comparing against its own depth.
Greater ensures the closest fragment to the camera sticks in the
depth buffer, giving the HZB the correct conservative occluder depth.
-
Depth prepass Clear value must be 0.0 under reverse-Z.
Clear(1.0) = near plane, Greater rejects all fragments. The
prepass writes nothing, HZB is empty, no occlusion detected.
Files touched
crates/titan-rendering-3d/src/lib.rs — SceneCullData struct, accessor
methods, per-frame data population, Pass::DepthPrepass arm,
Pass::SceneCull arm, resize path additions
crates/titan-rendering-3d/src/frame_graph_build.rs — pipeline construction
for depth prepass (render) and scene cull (compute)
crates/titan-rendering-3d/src/frame_graph_resources.rs — pipeline and
BGL fields
crates/titan-rendering-3d/src/offscreen_viewport.rs — depth prepass
texture + occlusion HZB fields
crates/titan-rendering-3d/src/manifest.rs — pass variants, resources,
manifest entries
crates/titan-rendering-frame-graph/src/lib.rs — pass enum, validation
crates/titan-rendering-frame-graph/configs/*.ron — all 8 RONs