Skip to content

RenderGraph: second consecutive Deferred-path switch in one process culls the entire graph (reads=0/writes=0) #530

Closed

Description

@drsnuggles8

Summary

A RendererAttachedTest fixture that switches RendererSettings::Path to
RenderingPath::Deferred and drives a frame, when run immediately after
another test in the same process that also switched to Deferred
, sees its
entire RenderGraph compile with zero declared reads/writes across all 37
passes
— every pass (ScenePass, ShadowPass, DeferredLightingPass,
UICompositePass, ...) is marked culled, and ReadbackComposite() fails
because UICompositePass never produces its output framebuffer.

No GL errors, no warnings, no crash — the frame silently renders nothing.

Reproduction

Run the existing, unmodified OcclusionCullDeferredVisualEvidenceTest
(OloEngine/tests/Rendering/PropertyTests/OcclusionCullDeferredVisualEvidenceTest.cpp)
twice in the same process:

build\OloEngine\tests\Debug\OloEngine-Tests.exe --gtest_filter=*DeferredOccludedInstanceFieldScene* --gtest_repeat=2
  • Iteration 1: passes. Log shows RenderGraph BuildFrameGraph stats: passes=37, reads=69, writes=49, derivedEdges=35, orderSensitiveResults=10.
  • Iteration 2: fails. Log shows RenderGraph BuildFrameGraph stats: passes=37, reads=0, writes=0, derivedEdges=3, orderSensitiveResults=0, every pass culled, ReadbackComposite returns false.

No test-body changes are needed to reproduce — this is a pure re-entry issue with the render graph / Renderer3D process-wide state, not anything content-specific (same scene, same entities, same everything — just run twice).

Impact

This is a CI-flakiness risk for any two RendererAttachedTest-based visual-evidence tests that both switch to RenderingPath::Deferred and happen to run back-to-back in the same test binary — several already exist (OcclusionCullDeferredVisualEvidenceTest, and the SSGI/SSR/EASU evidence tests per RendererAttachedTest.h's own teardown comment referencing them). Today it just hasn't been triggered because gtest's registration/link order happens to keep them apart, but that's fragile — a new test, a reordered CMakeLists.txt, or --gtest_shuffle could trip it at any time.

Discovered while working on #515 (routing instanced statics to the deferred G-Buffer shader) — a new visual-evidence test (DeferredInstancedShadingEvidenceTest.cpp) landed right after OcclusionCullDeferredVisualEvidenceTest in one run and hit this, which is how it surfaced. It is unrelated to #515's shader-routing fix — the fix itself is verified correct (both tests pass reliably in isolation / as the first Deferred-switch in a run).

Suspected area

RendererAttachedTest::TearDown() already documents that a naive settings-struct comparison in ApplyRendererSettings can miss a stale graph left by a previous test ("the render graph's internal 'configured-for' state... A struct-level comparison can therefore read 'unchanged' while the graph was left reconfigured") and works around it by unconditionally reasserting ApplyRendererSettings() in teardown. That mitigation is evidently not sufficient — something in Renderer3D's process-wide state (Renderer3D::s_Data) survives a full render-graph rebuild (a fresh ConfigureRenderGraph + BuildFrameGraph call both show Adding graph node: ... for every pass) and still yields zero declared resources on the second Deferred entry. Likely candidates: the retained Hi-Z occlusion pyramid (OcclusionHZB/OcclusionHZBValid), FrameDataBufferManager/FrameResourceManager per-frame ring state, or some other per-process cache not reset between Scene lifetimes.

Suggested next steps

  1. Reproduce with the command above and get a debugger/instrumented build on the second iteration's BeginScene/EndScene to see which pass's Setup() is the first to see an invalid blackboard resource (or whether Setup() is being skipped for all passes entirely).
  2. Compare Renderer3D::s_Data state after iteration 1's teardown vs. a truly fresh process to find what didn't reset.
  3. Consider whether RendererAttachedTest::TearDown() needs to force a harder reset (e.g. via a dedicated Renderer3D::ResetPerSceneState() call) rather than relying solely on ApplyRendererSettings().

Score

capability: 3
craft: 1
stability: 8
decay: 5
effort: 3
confidence: 0.8
learning: 5
fun: 5
kano: table-stakes
blocked_by: []
blocks: []

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

Activity

  1. coderabbitai commented on Jul 3, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #263 - fix(tests): untangle Renderer3D test coupling for full-pipeline render tests (#258) [merged]
    #465 - Fix/flaky fog visual evidence test [merged]
    #514 - feat: Implement deferred two-phase occlusion culling [merged]
    #529 - fix(renderer): lazily upload deferred font textures so headless-first-touch fonts render (#520) [merged]
    #532 - fix(renderer): resize GTAO scratch textures with the FSR1 scene band [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 4, 2026

    @drsnuggles8
    OwnerAuthor

    Root cause confirmed and fixed in #548.

    What's happening: ConfigureRenderGraph (path / AO-technique switch) rebuilds
    the graph through RenderGraph::ResetTopology(), which wipes the blackboard and
    imported-resource maps. But the RenderPipeline blackboard-populate cache
    (m_HasValidBlackboardCache / m_BlackboardFingerprint, process-wide in
    s_Data.Pipeline) is keyed only on a fingerprint of scene/settings inputs. Two
    consecutive Deferred entries of the same scene compute an identical
    fingerprint
    , so PopulateBlackboard short-circuits past the just-wiped
    blackboard. Every pass's Setup() then resolves empty handles, RGBuilder
    silently drops every Read/Write, and all 37 passes cull → reads=0/writes=0,
    a blank frame with no GL error.

    The fixture TearDown reassert doesn't rescue it: it reconfigures to Forward but
    renders no frame, so the cache retains the prior Deferred fingerprint for the
    next test to collide with. That's why it's order-dependent flakiness.

    Fix: couple the cache key to the event that invalidates it. RenderGraph
    gets a monotonic m_TopologyGeneration (+ GetTopologyGeneration()) bumped by
    every ResetTopology() / Shutdown(), and ComputeBlackboardFingerprint hashes
    it. Since that fingerprint feeds both the populate cache and the BuildFrameGraph
    cache key, any reconfigure self-invalidates without enumerating call sites —
    robust against a future reconfigure path that forgets an explicit hook.

    Verification: the repro (*DeferredOccludedInstanceFieldScene* --gtest_repeat=2)
    now passes both iterations (reads stay at 69). Added a CPU contract test
    (ResetTopologyAdvancesTopologyGenerationForCacheInvalidation) and a GPU
    regression that reproduces the reconfigure→reconfigure→rerender sequence in one
    test body. Also documented the class of bug in
    docs/agent-rules/render-pipeline-caches.md.

  3. added a commit that references this issue on Jul 4, 2026
  4. added a commit that references this issue on Jul 11, 2026
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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions