Skip to content

Renderer: extend two-phase GPU HZB occlusion culling to the Deferred path #486

Closed

Description

@drsnuggles8

Problem

The two-phase GPU-driven HZB occlusion cull shipped in #431 (GPUDrivenOcclusionPass)
is Forward / Forward+ only. In the Deferred path, occlusion-culled instanced
static batches stay on the legacy single-phase cull: they are frustum + HZB tested
against the previous frame's pyramid at submission and drawn through the ScenePass
G-Buffer bucket. That is correct but not disocclusion-corrected — on fast camera
moves / disocclusions, an instance that was occluded last frame but is visible this
frame can pop for one frame, exactly the artifact the two-phase scheme removes for the
forward path.

The hardened occlusion shader (InstanceOcclusionCull.comp) is already shared, so the
deferred single-phase path benefits from the UE5.8-conformance work (point-block gather,
prev-transform reprojection); what's missing is the second pass (rebuild HZB from
this frame's depth → re-test the reject list) in the deferred context.

See docs/analysis/ue5.8-hzb-occlusion-analysis.md
§7 row 17 — the one remaining deliberate scope cut from #431.

Why it matters (Tier 2 — performance ceiling)

Deferred is the path the dense-instancing genre content (foliage / debris / props on
buildings + terrain) will actually run in. Bringing it to parity with the forward
two-phase removes the one-frame pop there too, and makes "HZB occlusion culling" a
path-agnostic claim rather than a Forward-only one.

Proposed approach

Extend GPUDrivenOcclusionPass (or a deferred sibling) to draw the phase-1 survivors
and phase-2 recoveries into the G-Buffer, mirroring the established
DeferredOpaqueDecalPass integration (the precedent for a pass that mutates the
G-Buffer between ScenePass and DeferredLightingPass):

  • Hold the GBuffer (per the pipeline wiring), target GetFramebuffer() /
    GetSamplingFramebuffer() correctly for the MSAA vs resolved cases.
  • Declare the G-Buffer attachment writes (albedo / normal / emissive + their MS
    variants + velocity-MS + depth-MS, like DeferredOpaqueDecalPass::Setup) so the
    graph orders DeferredLightingPass after this pass and the hazard validator passes.
  • Build the mid-frame HZB from the G-Buffer depth; run DispatchPhase2 against it.
  • Re-export SceneDepth (depth-only is acceptable to start; the G-Buffer normal RT is
    packed, so a normal re-export is a follow-up nicety).
  • Drop the Path != Deferred gate on the submission reroute
    (Renderer3D::SubmitGPUCulledInstanced); confirm deferred instanced batches resolve
    to the G-Buffer shader (DrawMeshInstanced currently does not swap to
    PBRGBufferShader for instanced — verify / fix as part of this).
  • Register the pass before DeferredOpaqueDecalPass / DeferredLightingPass in the
    deferred topology.

Spike first (confidence 0.5): prototype the G-Buffer draw + a deferred visual test
before committing — the MSAA / per-sample-resolve interaction and the deferred instanced
shader selection are the unknowns that can make this "tests green, screen wrong."

Acceptance criteria

  • Two-phase occlusion runs in Deferred: phase-1 vs previous HZB → reject list → mid-frame
    HZB → phase-2 recovery, drawn into the G-Buffer before lighting.
  • A deferred full-pipeline visual-evidence test (RendererAttachedTest, Path = Deferred, dense instanced field behind an occluder) proves no corruption / no holes
    and a measurable survivor reduction — mirroring OccludedInstanceFieldScene.
  • No regression to the forward two-phase path or to non-instanced deferred rendering;
    render-graph hazard validation stays clean.
  • AO/SSR see the instanced depth in Deferred (depth re-export), matching the forward
    Stage-3 behaviour.

Score

capability: 3
craft: 3
stability: 2
decay: 1
effort: 5
confidence: 0.5
learning: 3
fun: 3
kano: performance
blocked_by: []
blocks: []

Rated per issue-scoring · score = confidence × (capability + craft + stability + decay) / effort, derived by the picker.

Activity

  1. coderabbitai commented on Jun 30, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #155 - Implement Depth Prepass and Occlusion Culling in Renderer [closed]
    #203 - feat: Implement Jump Flood Algorithm for selection outlines in OloEditor [merged]
    #416 - feat(renderer): consume per-object velocity buffer in motion blur [merged]
    #465 - Fix/flaky fog visual evidence test [merged]
    #479 - feat(renderer): CAS contrast-adaptive sharpening pass (#432) [merged]


    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. drsnuggles8 commented on Jul 2, 2026

    @drsnuggles8
    OwnerAuthor

    Implemented in PR #514 (commit 3ac6540, branch feature/renderer-hzb-deferred).

    What shipped: a new DeferredGPUOcclusionPass that runs after ScenePass and before DeferredOpaqueDecalPass / AO / DeferredLightingPass. Phase-1 draws stay on the normal ScenePass G-Buffer bucket (the phase-1 cull already ran vs the previous frame's HZB); the new pass rebuilds the HZB from this frame's resolved G-Buffer depth (occluders + phase-1 survivors), re-tests the reject list, draws the disoccluded instances into the G-Buffer, resolves MSAA (per-sample mode), and re-exports the G-Buffer attachments (SceneDepth / SceneNormals / GBuffer{Albedo,Normal,Emissive} / Velocity + MSAA companions) — mirroring DeferredOpaqueDecalPass. Registration order derives the correct SceneDepth/SceneNormals/GBufferAlbedo ordering edges; verified hazard-free.

    Verification: DeferredTwoPhaseOcclusionTest (CPU, pins the disocclusion decision math), OcclusionCullDeferredVisualEvidenceTest (real deferred pipeline on GPU, no false hole / no corruption vs the occlusion-off baseline). Full hazard + path-switch + forward-occlusion suite still green.

    docs/analysis/ue5.8-hzb-occlusion-analysis.md §7 row 17 updated ⚠️ → ✅.

    Follow-up filed separately: instanced statics in Deferred still select DefaultForwardShader (pre-existing, unrelated to occlusion routing).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions