Skip to content

fix(renderer): decal per-channel colour masks, and the four defects hiding behind them (#853) - #886

Open
drsnuggles8 wants to merge 7 commits into
masterfrom
feature/decal-channel-masks-853
Open

fix(renderer): decal per-channel colour masks, and the four defects hiding behind them (#853)#886
drsnuggles8 wants to merge 7 commits into
masterfrom
feature/decal-channel-masks-853

Conversation

@drsnuggles8

@drsnuggles8 drsnuggles8 commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Fixes the per-channel colour-mask leak in #853 — and four other live defects found while trying to observe it, none of which the suite could see.

Closes #853

The short version

#853 is real and is fixed. But the capture it asked for as "the first thing the fix needs" could never have shown the leak, because no decal had produced a single pixel in any real scene, on either rendering path, for as long as decals have existed. The mask flattening was genuine and is now pinned by tests — it was simply corrupting a draw that never happened.

What was wrong

# Defect Commit
1 Opening any Deferred scene with an opaque decal killed the editor. DeferredOpaqueDecalPass reads SceneDepth, an attachment view of the same framebuffer as the four colour views it writes; the read fans out onto every sibling and trips four same-pass feedback hazards. The assert on that list is a __debugbreak, so a Debug build with no debugger terminates. afa4eac5d
2 Decals drew zero fragments, on both paths. cullFace = Front keeps the projection box's back faces; depthFunction = LessOrEqual rejects them, because they are behind the surface the decal projects onto. Both paths read the one PODRenderState, so both were dead. Predates the RHI refactor as GL_LEQUAL + GL_FRONT. bb283315c
3 #853 itselfApplyPODRenderState's global SetColorMask is the indexed call for every draw buffer and flattened the mode's channel masks; the narrowing loop after it could only re-disable whole attachments, and a decal packet carries the default 0xFF. 55a2c687a
4 No failed asset load in this engine has ever rendered magenta. PlaceholderTexture builds its 64×64 magenta/black checkerboard into a local vector, creates the texture, and never uploads it — the comment saying the upload API is unavailable is stale. Every placeholder was an uninitialised texture, sampling as zeros. a582d74af
5 The tenant that was supposed to catch #853 verified the setup, not the draw. 7e43617ae

The fix for #853

PODRenderState::colorAttachmentWriteMask is one bit per attachment and cannot express "write only RT1.xy". Added colorAttachmentChannelMask: one nibble per attachment, R/G/B/A low bit first.

This is shape (a) of the two the issue named — the mask travels on the command, not as a pass-scoped "these masks are mine for the next N draws" override. Deliberately, for three reasons:

Composition is an AND — global mask, then the nibble, then the attachment bit, never widening past the global call. That makes the old behaviour a strict special case, so Renderer3DUtilityDraws' skeleton/joint (0x01) and infinite-grid (0xFF & ~(1 << 2)) draws are byte-identical, and an all-writable nibble costs no indexed call at all.

DecalGBufferChannelMask is the single source for the routing — DecalRenderPass drives its SetColorMaskForAttachment calls from it and Renderer3D::DrawDecal stamps the same value onto the packet, so the two cannot drift. Deferred path only: a forward decal draws into scene colour or the WB-OIT accum/revealage MRT, where masking RT1/RT2 would break compositing outright. The issue did not call this out and it is the one way shape (a) could have silently broken OIT.

Evidence

Tenant, failing before the fix (real Vulkan pixels, production dispatch)

RT0.a (metallic) is masked out and must survive  ->  255     (clear was 64)
RT0.rgb (albedo) is masked out under RMA         ->  0,0,0   -- the floor goes BLACK
RT1.xy (the oct normal) is masked out under RMA  ->  moved by 0.25
RT1.z (roughness) ... must survive               ->  off by 0.25
RT1.w (AO) ... must survive                      ->  off by 0.75

RT0.rgb -> 0 is Decal_GBuffer_RMA.glsl's gAlbedo = vec4(0, 0, 0, metallic) reaching an unmasked target — predicted in TEST_SCENES.md before the test was ever run.

Contract tests in the same state read 15 (0xF, every channel open) where 3 and 7 were required.

Live G-Buffer probes after the fix (OpenGL, DecalModeMatrixTest.olo)

probe RT0 rgb=albedo, a=metallic RT1 xy=normal, z=rough, w=AO RT2 rgb=emissive, a=unlit
outside 0.549, 0.549, 0.549, 0.0 0.0, 1.0, 0.8496, 1.0 0, 0, 0, 0
Albedo 1.0, 0.251, 0.149, a=0.0 preserved unchanged unchanged
Normal unchanged 0.3047, 0.2255, zw preserved unchanged
RMA unchanged — preserved xy preserved, zw=0.0, 0.0 written unchanged
Emissive unchanged unchanged 0.2, 0.8999, 1.0, a=0.0 preserved

1.0, 0.251, 0.149 and 0.2, 0.8999, 1.0 are the decals' authored colours exactly. Zero render-graph hazards, zero assertions, zero shader errors, ticking: true.

Suite: 110/110 across ChannelMaskTest, the whole VulkanPassSuite, CommandBucket, FramePipeline, PODCommand and the component-coverage tests.

Two things the evidence does not show

  • The Emissive arm's mask leak is not observable and is not cited as evidence. RT2 blends One/One, so dst.a + 0 leaves the unlit flag alone whether or not the mask survives. The issue lists an Emissive RT2.a leak among the things to capture; it cannot be captured. The tenant corroborates this — its Emissive arm does not fail before the fix.
  • RMA's RT0.a reads the same inside and outside the footprint, because with no RMA texture bound the metallic sample is 0, which happens to equal the floor's. The table demonstrates RMA's mask (RT0.rgb preserved — the Decal per-channel colour masks are wiped before the draw on both backends (the mode matrix is defeated in production) #853 arm), not that RT0.a is written.

Why none of this was caught

VulkanPassSuite.DecalGBufferModeMatrixMasksItsTargetRenderTargets is a careful 300-line tenant that made three substitutions, and each was hiding a different one of the defects above:

It substituted What that hid
FixtureDecalDispatch for CommandDispatch::DrawDecal #853 — the fixture never called SetColorMask
a proxy quad in front of the surface for the production cube the cull/depth pairing — defect 2
a hand-built G-Buffer for the render graph the feedback hazard — defect 1

Plus: no sandbox scene put a decal on either path, so nothing executed the feature end to end.

Written up as docs/agent-rules/substituted-seams-compound.md, linked from both indexes. It also records the two green-looking non-results this work produced — PASSED 0 tests from a file commented out of tests/CMakeLists.txt, and a Running 7 tests abort with no [ FAILED ] line at all.

Out of scope

🤖 Generated with Claude Code

drsnuggles8 and others added 5 commits August 22, 2026 12:12
…ead/colour write

Opening any Deferred scene containing an opaque decal killed the editor.

SceneDepth, SceneNormals and the three GBuffer* views are all attachment views
of the SAME framebuffer (RenderPipeline.cpp's resolvedGBuffer). Read()
propagates to the parent framebuffer and the hazard validator expands that
parent read back down onto every sibling attachment view, so the decal shader's
depth sample reads -- by name -- every colour view the pass writes. Four
same-pass feedback hazards, none of them real: one depth attachment read, four
colour attachments written, subresources disjoint throughout. This is the
legitimate-RMW case RGBuilder::Write's own comment names, and
AllowSamePassReadWrite is how a pass states it.

Not cosmetic: OLO_CORE_ASSERT on the compiled-hazard list is a __debugbreak, so
a Debug build with no debugger terminates. It went unnoticed because the graph
validation only runs when the graph's shape changes and the pass only declares
these accesses when it has decal work -- so it took the first scene to put a
decal on the deferred path to reach it.

Verified live: scene opens, RenderGraph hazard count 0, assertion count 0,
editor ticking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…paths

Renderer3D::DrawDecal paired cullFace = Front -- which keeps the projection
box's BACK faces -- with depthFunction = LessOrEqual, which rejects them: the
box's far side is behind the surface the decal projects onto. So a decal
straddling its receiving surface produced no fragments at all. Both paths read
this one PODRenderState, so deferred and forward were equally dead.

It predates the RHI refactor (GL_LEQUAL + GL_FRONT) and nothing pinned it. The
pass tenant substitutes a proxy QUAD drawn in front of the surface for the
production cube, so the one property the cube has and the quad does not -- which
of its faces survive the depth test -- was untested; and no sandbox scene put a
decal on either path.

Back-face decal rendering wants "the box's far side is at or behind the
geometry" (GreaterOrEqual); the shader's own in-box test then rejects surfaces
that are merely further away.

Measured before/after at the same camera pose: with LessOrEqual, GBufferAlbedo
is a uniform floor colour with no decal anywhere; with GreaterOrEqual an Albedo
decal's authored (1, 0.25, 0.15) lands as (255, 64, 38).

Also extracts the decal render state into CreateDecalPODRenderState so the pass
tenant applies the state production builds rather than a hand-written copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DecalRenderPass installs channel-level colour masks per decal mode, then the
next packet wipes them before the draw: ApplyPODRenderState issues the GLOBAL
SetColorMask, which is the indexed call for EVERY draw buffer on both backends
(docs/agent-rules/gl-global-setter-resets-indexed-state.md), and the narrowing
loop that follows could only re-DISABLE whole attachments -- the decal packet
carries the default 0xFF, so it re-narrowed nothing.

PODRenderState::colorAttachmentWriteMask is one BIT per attachment and cannot
express "write only RT1.xy", so this adds colorAttachmentChannelMask: one
nibble per attachment, R,G,B,A low bit first, default all-writable.

Shape (a) of the two the issue named -- the mask travels on the COMMAND, not as
a pass-scoped "these masks are mine for the next N draws" override. Three
reasons: a pass-scoped override's lifetime is manual and a missed clear is the
process-permanent indexed-state leak #823 was about; the loop it replaces is
already the same idea one bit wide, so this generalises existing code instead of
adding a parallel mechanism; and DrawDecalCommand's own OIT-override comment
states the design rule ("keeps the queue stateless and replay-safe").

Composition is an AND -- global mask, then the nibble, then the attachment bit;
never widening past the global call. That makes the previous behaviour a strict
special case, so Renderer3DUtilityDraws' skeleton/joint (0x01) and infinite grid
(0xFF & ~(1 << 2)) draws are byte-identical, and an all-writable nibble costs no
indexed call at all.

DecalGBufferChannelMask is the single source for the routing: DecalRenderPass
drives its SetColorMaskForAttachment calls from it and Renderer3D::DrawDecal
stamps the same value onto the packet, so the masks the pass installs and the
masks the draw re-asserts cannot drift. Deferred path only -- a forward decal
draws into scene colour or the WB-OIT accum/revealage MRT, where masking RT1/RT2
would break compositing.

Closes #853

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vable scene texture paths

PlaceholderTexture::CreatePlaceholderTexture built a full 64x64 magenta/black
checkerboard into a local vector, created the texture, and never uploaded it --
"Note: Data upload may require additional API not available here", which is
stale (Texture2D::SetData has been on the interface throughout). So every
placeholder in the engine was an UNINITIALISED texture, sampling as zeros in
practice.

The whole purpose of this asset is to be loud. Instead, a failed asset load
rendered as transparent black, which does not read as "this asset is missing" --
it reads as "there is nothing here". Found via a decal whose texture path did
not resolve: the sample zeroed the decal's alpha and every fragment was
discarded, so the decal vanished with nothing on screen connecting it to the
"Failed to load asset" lines thousands of entries earlier in the log.

Also warns, naming the path, when a scene texture path does not resolve to a
Texture2D. Scene texture paths go through EditorAssetManager::ImportAsset and
resolve against the PROJECT asset root -- not the working directory, which is
the spelling shaders and environment maps use. That warning caught a second bad
path during verification of this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h, add the missing scene

The tenant substituted FixtureDecalDispatch -- a test-owned dispatch that set
depth/blend/culling by hand and never called SetColorMask -- so its channel
assertions passed for a reason that did not hold in production. It now applies
the production render state through the production function,
CommandDispatch::ApplyPODRenderState, on a state built by the shared
CreateDecalPODRenderState rather than a hand-written copy. Only the
resource-binding half stays substituted, because Renderer3D's statics are live
OpenGL objects mid-suite and cannot be bound on the Vulkan device; that half is
orthogonal to the colour-mask contract.

Confirmed failing before the fix and passing after:

  RT0.a (metallic) is masked out and must survive  ->  255  (clear was 64)
  RT0.rgb (albedo) is masked out under RMA         ->  0,0,0  -- the floor goes black
  RT1.xy (the oct normal) is masked out under RMA  ->  moved by 0.25
  RT1.z (roughness) ... must survive               ->  off by 0.25
  RT1.w (AO) ... must survive                      ->  off by 0.75

The Emissive arm does NOT fail, and that is correct: RT2 blends One/One, so
dst.a + 0 leaves the unlit flag alone whether or not the mask survives. The
issue lists an Emissive RT2.a leak as evidence to capture; it is not observable
and is not cited as such.

Adds PODRenderStateChannelMaskTest -- seven backend-independent contract tests
driving the real ApplyPODRenderState against MockRendererAPI, which is the only
coverage of the OpenGL arm of the same flattening. Includes a control (a
default state must issue NO indexed call, so a loop that re-asserted everything
unconditionally would fail) and a guard that the new field is in
PODRenderState::operator==, without which FrameDataBufferManager would hand back
a cached index for a different mask and silently reinstate the bug. The mock now
records colour-mask CHANNELS, not just the call name -- a name-only record
cannot tell a mask that masks from one that does nothing.

Adds DecalModeMatrixTest.olo: one decal per mode on one uniform floor. No
sandbox scene had put a decal on either rendering path, which is a large part of
why all of this stayed invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@drsnuggles8, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 57 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6ff6ca5d-5dcd-4046-8daf-467871151eac

📥 Commits

Reviewing files that changed from the base of the PR and between eade118 and b0ecd99.

⛔ Files ignored due to path filters (23)
  • OloEditor/assets/tests/visual/ObserverCamera_GpuCull_frozen_pose_view.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ObserverCamera_frozen_pose_view.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ObserverCamera_frozen_without_frustum.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ObserverCamera_restored.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ObserverCamera_wireframe_pose_view.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMapLocalSingle_Atlas.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMapLocalSingle_NoShadow.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMapLocalSingle_Orbit000_NoShadow.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMapLocalSingle_Pages.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMapLocal_Atlas.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMapLocal_Pages.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_CSM_Angled.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_CSM_Higher.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_Probe_Plain.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_Probe_Residency.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_VSM_Angled.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_VSM_Angled_ShadowFactor.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_VSM_Higher.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/VirtualShadowMap_VSM_Higher_ShadowFactor.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ddgi_cascade_back.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ddgi_cascade_far.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ddgi_cascade_mid.png is excluded by !**/*.png
  • OloEditor/assets/tests/visual/ddgi_cascade_near.png is excluded by !**/*.png
📒 Files selected for processing (20)
  • CLAUDE.md
  • OloEditor/SandboxProject/AssetRegistry.oar
  • OloEditor/SandboxProject/Assets/Scenes/DecalModeMatrixTest.olo
  • OloEditor/SandboxProject/Assets/Scenes/TEST_SCENES.md
  • OloEngine/src/OloEngine/Asset/PlaceholderAsset.cpp
  • OloEngine/src/OloEngine/Renderer/Commands/CommandDispatch.cpp
  • OloEngine/src/OloEngine/Renderer/Commands/CommandDispatch.h
  • OloEngine/src/OloEngine/Renderer/Commands/RenderCommand.h
  • OloEngine/src/OloEngine/Renderer/Passes/DecalRenderPass.cpp
  • OloEngine/src/OloEngine/Renderer/Passes/DeferredOpaqueDecalPass.cpp
  • OloEngine/src/OloEngine/Renderer/Renderer3DDrawHelpers.h
  • OloEngine/src/OloEngine/Renderer/Renderer3DSpecializedDraws.cpp
  • OloEngine/src/OloEngine/Scene/SceneSerializer.cpp
  • OloEngine/src/Platform/Vulkan/VulkanRendererAPI.h
  • OloEngine/tests/CMakeLists.txt
  • OloEngine/tests/Rendering/MockRendererAPI.h
  • OloEngine/tests/Rendering/PODRenderStateChannelMaskTest.cpp
  • OloEngine/tests/Rendering/VulkanPassSuiteTest.cpp
  • docs/agent-rules/README.md
  • docs/agent-rules/substituted-seams-compound.md

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

drsnuggles8 and others added 2 commits August 22, 2026 14:05
…nts at their issues

Two comment-only corrections, both found while fixing #853.

DecalRenderPass's guard comment claimed "the current GLStateGuard only detects
leaks, it does not roll back". GLStateGuard::Policy::Restore exists and does
roll back via GLStateSnapshot::ApplyCore() -- so the comment is wrong, and is
plausibly why this call site never asked for it. Corrected, and pointed at #895
along with what the pass actually leaks (8-9 fields at ERROR level on every
frame that drains a decal, on both rendering paths).

VulkanRendererAPI's AttachmentBlend comment said the asymmetry "is filed rather
than changed here" without saying where. It is #896 now, with the note that the
naive symmetric fix was already written and reverted during #823.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…scades, observer camera)

23 evidence PNGs that their originating features never committed. Same shape as
8d771bf, which did this after the fact for #439.

  VirtualShadowMap*     14 files -- the whole family, ZERO tracked before this
  ObserverCamera_*       5 files -- that PR committed some of its outputs but
                                    not these (different names, same test)
  ddgi_cascade_*         4 files -- ddgi_angle_*/ddgi_leak_* were tracked, the
                                    cascade set was not

No .gitignore rule covers them; 305 siblings in the same directory are tracked.
The likely reason nobody noticed: these tests are GPU-gated and SKIP cleanly on
headless CI, so CI never produces the files and an author without a GL 4.6
context never sees them appear.

These are EVIDENCE, not goldens. assets/tests/visual/ is write-only -- 59 test
files write into it and nothing reads from it (verified by grep). The L8 SSIM
baselines that ARE loaded and compared live in assets/tests/golden/, all 8 are
tracked, and none were missing. So committing these defends nothing and cannot
bake in a false gate; it only completes the human-facing record.

Provenance: rendered on an RTX 4090 during the #853 verification run, not by the
features' own authors.

TWO PAIRS ARE BYTE-IDENTICAL AND PROBABLY SHOULD NOT BE, flagged rather than
quietly committed:

  ObserverCamera_frozen_pose_view.png == ObserverCamera_wireframe_pose_view.png
      Both are Capture(MakeFrozenPoseCamera(), ...) at ObserverCameraVisual
      EvidenceTest.cpp:545 and :596; only the wireframe lever differs, and the
      frames are identical, so that lever may not be reaching the capture. The
      wireframe shot's result is passed as `ignored`, so nothing asserts it.

  VirtualShadowMap_Probe_Plain.png == VirtualShadowMap_VSM_Angled.png
      Two different configurations in VirtualShadowMapVisualEvidenceTest
      producing the same bytes.

Byte-identical frames across a changed input is the #823 signature -- prove the
lever acts before concluding anything from its silence. Neither is investigated
here. The other two duplicate pairs ARE expected: _restored == _unfrozen (that
is what restoring means) and _frozen == _frozen_without_frustum (the base frozen
shot draws no frustum overlay).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant