Skip to content

AGC: Super Meat Boy 3D (PPSA31162) renders 3D gameplay in monochrome cyan-white ([224, 248, 255]) with black shadows #10568

Open

Description

@Feuris

What happens

The 3D gameplay (Meat Boy, stage geometry, vegetation) has valid geometry, vertex transforms, depth, and normals, but renders in uniform monochrome cyan-white ([224, 248, 255]) with pitch black shadows and sky (#000000), while 2D UI and FMV cinematics render with full colors.

Title, test or build step

Super Meat Boy 3D (01.000.000) PPSA31162

AnyPS5 commit

f90a3f7294bb8964d50ca31b0c95026601b0f929 (+ cherry-pick of PR #9026)

System

Windows 11, AMD Radeon RX 6700 XT (gfx1031, driver amdvlk64.dll)

Steps to reproduce

  1. Run Super Meat Boy 3D (PPSA31162).
  2. Progress past the title screen and enter 3D gameplay.
  3. Observe that 2D UI elements and cinematics render in full color, but all 3D gameplay geometry renders in uniform cyan-white with no diffuse textures and black shadows.

Log

[dcc-keys] title fills keys 0x31998b0000+0x28000 with 00000000 00000000 00000000 00000000 (queue 0x0)
[dcc-keys] 0000 key fill over 0x31978d0000+0x1fe0000 (uploaded uncompressed, dirty 1)
[dcc-keys] sampled 0x31978d0000 through keys 0x31998b0000 reads the 0000 fill, not the pending image
[src/Texture.cpp:335 Texture] Texture address=0x31978d0000 3840x2160 mips=1 layers=1 dim=1 tile=7 format=36 vk=122 element=4
[dcc-keys] refresh 0x31978d0000+0x1fe0000 keys uncompressed -> 0000 (dirty 1, 510 pending units, 0 cpu-stamped units)
[gpu] storage image 0x31978d0000: guest memory changed while GPU results were pending; keeping the CPU's blocks
[dcc-keys] refresh 0x3098d10000+0x1fe0000 keys uncompressed -> 0000 (dirty 0, 0 pending units, 0 cpu-stamped units)
[src/Texture.cpp:335 Texture] Texture address=0x3098d10000 3840x2160 mips=1 layers=1 dim=1 tile=7 format=130 vk=43 element=4

What was checked

  • Material Textures in GPU Memory: Texture dumping (APS5_DUMP_TEXTURE) confirmed that all diffuse textures (including Meat Boy's 1024x1024 BC1 red diffuse texture with RGB [159, 20, 18]) are intact in GPU memory and bound to the BasePass draw calls.
  • CPU Write-Watch / Texture Cache: Ruled out GetWriteWatch, saveBeforeUnwatch, droppable, VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT and SameSurfaceShape.
  • MRT[3] Fragment Shader Witness Test: Patching the BasePass fragment shader to write vec4(1.0, 0.0, 0.0, 1.0) pure red into out_mrt_3 produced no visual change on screen (still rendered cyan-white [224, 248, 255]), suggesting out_mrt_3 is either not routed to GBufferC, overwritten, or not bound to the active color attachment slot expected by FDeferredLightPS.
  • Pipeline & Deferred Lighting Analysis (Unreal Engine 5 PBR Deferred):
    • In FDeferredLightPS (analyzed in SPIR-V from pipelines.journal):
      • Directional light has LightColor = [224, 248, 255].
      • For dielectric materials (Metallic = 0), the specular reflection is 0.04 * SpecularBRDF * LightColor regardless of BaseColor.
      • If GBufferC (MRT[3], BaseColor in sRGB guestFormat=130, VK_FORMAT_B8G8R8A8_SRGB) reads as zero, diffuse lighting evaluates to 0, producing pitch black shadows and leaving only the cyan-white specular reflection visible.
  • DCC Clear Keys & Storage Images:
    • GBuffer targets are cleared at frame start by filling DCC keys with 00000000.
    • When sampled during deferred shading, storage textures see the DCC clear fill and drop GPU pending results to read zeroes from guest memory snapshots.
  • Full Log Gist: https://gist.github.com/Feuris/b9244f380d0507bf562c9d060055e88c
  • Screenshot:
    Super Meat Boy 3D Gameplay

AI-assisted: yes

Activity

  1. changed the title [-]Super Meat Boy 3D (01.000.000) PPSA31162 - 3D gameplay renders in monochrome cyan-white ([224, 248, 255]) with black shadows[/-] [+]AGC: Super Meat Boy 3D (PPSA31162) renders 3D gameplay in monochrome cyan-white ([224, 248, 255]) with black shadows[/+] on Oct 24, 2026
  2. MarcZX10 commented on Oct 24, 2026

    @MarcZX10
    Contributor

    The red-output witness narrows the path, but it does not yet distinguish a fragment-output routing problem from a later store/readback problem. A useful next capture would record, for the affected BasePass draw, (1) the fragment SPIR-V output Location values, (2) the Vulkan color attachment index, format, write mask, and guest address/descriptor for each bound attachment, and (3) a small GPU readback of the suspected BaseColor attachment immediately after the draw and again before FDeferredLightPS samples it. If the red write is absent from that attachment after the draw, the shader-output-to-MRT mapping is suspect; if present then zero before lighting, focus on ordering/storage synchronization; if it remains red, compare the sampled descriptor and attachment address/format. That would make the next investigation step reproducible without guessing from the final frame.

  3. milescoviello commented on Oct 24, 2026

    @milescoviello
    Contributor

    A Linux data point: on current main e7e99ad, with no extra PRs (Ubuntu 26.04.1, RTX 4070 Ti, NVIDIA 615.71.09, release libs, warm cache), Super Meat Boy 3D's 3D gameplay renders in full colour. In the first stage about 3 minutes in, the foliage is green, Meat Boy red, the crystals purple and the ground brown, with shadows, at about 5 FPS. So I don't see the cyan-white BaseColor here. It may be specific to Windows and AMD, or something merged since your base (f90a3f7294 isn't on main, so I can't tell how far apart they are). A run on current main on the RX 6700 XT would tell which.

    AI-assisted: yes (Claude Code)

  4. MarcZX10 commented on Oct 24, 2026

    @MarcZX10
    Contributor

    Thanks for adding the current-main Linux comparison. Since that run used a different GPU/OS from the original RX 6700 XT Windows report, it narrows the issue but does not yet separate a Windows/AMD-specific path from a regression between f90a3f7294 and current main (e7e99adcc4). Could you repeat the same short gameplay capture on the RX 6700 XT with current main and the same settings? If it renders in color there, the change window or configuration becomes the lead; if it remains cyan-white, the platform/GPU path becomes more likely. Please include the exact commit and whether #9026 is present in that build.

  5. milescoviello commented on Oct 24, 2026

    @milescoviello
    Contributor

    @MarcZX10 I don't have an RX 6700 XT, so that run would have to come from @Feuris. For the Linux run: main e7e99ad already contains #9026 (merged 10-21 as f0de89a), so it matches the report's f90a3f7294 + #9026 on that point. f90a3f7294 isn't in the upstream history, so I can't tell how far it is from main.

    @Feuris, a short gameplay run on current main with your RX 6700 XT would separate the two cases: colour there points to the change window, cyan-white points to the Windows/AMD path.

    AI-assisted: yes (Claude Code)

  6. Feuris commented on Oct 25, 2026

    @Feuris
    ContributorAuthor

    @MarcZX10 @milescoviello Thanks for the follow-up and the Linux comparison data.

    I repeated the test on Windows 11 with the RX 6700 XT on clean upstream main at commit 0a77bbf18 (Merge pull request #9170, which includes PR #9026 / commit f0de89a506).

    Result on clean main (Windows 11 + RX 6700 XT):

    The issue persists identically. The 3D gameplay renders in uniform monochrome cyan-white ([218, 246, 255] for foliage and [186, 201, 208] for terrain) with black stippled shadows. Meat Boy is completely desaturated ([130, 140, 145]). Meanwhile, 2D UI elements (HUD timer, icons, video cutscenes) continue to render in full color.

    This confirms the second hypothesis: the problem is platform/GPU-specific to the Windows + AMD Vulkan path (amdvlk64.dll), rather than a commit delta or general regression.

    Additional Diagnostic Clues:

    1. When FDeferredLightPS executes to sample BaseColor, ViewsStorageImage() evaluates to false (storageSource == nullptr), meaning it samples from a snapshot rather than directly viewing the active GPU storage image from the BasePass.
    2. A GPU readback of that sampled texture right before lighting executes shows uniform raw bytes [0xff, 0xff, 0x00, 0x01] (pure cyan [255, 255, 0, 1] in BGRA8), which matches the uniform cyan-white tint observed across all lit geometry.

    It looks like under the Windows AMD Vulkan driver, the BaseColor target either fails to maintain its storage image binding into the deferred lighting pass or the BasePass writes are not synchronized into the sampled snapshot.

    AI-assisted: yes

  7. milescoviello commented on Oct 26, 2026

    @milescoviello
    Contributor

    @Feuris thanks for repeating it on clean main. Together with the Linux run (NVIDIA, full colour on main e7e99ad), that points at the Windows + AMD driver path rather than a commit range. I can't reproduce that here: my machines are NVIDIA on Linux and a GT 1030 Windows VM.

    One thing about the readback: ff ff 00 01 is the same value for every texel, with alpha 1/255. That looks like a fill or a clear value still in guest memory, not BasePass output. So the snapshot the lighting pass samples probably predates the BasePass writes, or the writes never reached memory on that driver. The capture @MarcZX10 listed (output locations, attachment index and the write-back of that target) would tell those two apart.

    AI-assisted: yes (Claude Code)

  8. Feuris commented on Oct 26, 2026

    @Feuris
    ContributorAuthor

    @MarcZX10 @milescoviello Here are the telemetry and GPU readback captures from the BasePass and deferred lighting draws on Windows 11 + RX 6700 XT:

    1. Fragment SPIR-V Output Locations (BasePass draw #4):

    The SPIR-V output decorations confirm that the BasePass fragment shader maps variables directly to MRT Locations 0 through 6:

    • Output Var ID=9 -> Location=0 (SceneColor)
    • Output Var ID=10 -> Location=1 (GBufferA - WorldNormal)
    • Output Var ID=11 -> Location=2 (GBufferB - Metallic/Roughness)
    • Output Var ID=12 -> Location=3 (GBufferC - BaseColor)
    • Output Var ID=13 -> Location=4 (GBufferD - CustomData)
    • Output Var ID=28 -> Location=5 (GBufferE - Velocity)
    • Output Var ID=29 -> Location=6

    2. Vulkan Color Attachments at BasePass:

    All 7 color targets are bound with full write masks (0xf):

    • Attachment 0: slot=0, exportIndex=0, format=122, writeMask=0xf, addr=0x3197940000, extent=3840x2160, tileMode=27, dcc=0x3199920000
    • Attachment 1: slot=1, exportIndex=1, format=64, writeMask=0xf, addr=0x3094820000, extent=3840x2160, tileMode=27, dcc=0x3096800000
    • Attachment 2: slot=2, exportIndex=2, format=44, writeMask=0xf, addr=0x3096830000, extent=3840x2160, tileMode=27, dcc=0x3098810000
    • Attachment 3: slot=3, exportIndex=3, format=50 (VK_FORMAT_B8G8R8A8_SRGB), writeMask=0xf, addr=0x3098840000, extent=3840x2160, tileMode=27, dcc=0x309a820000
    • Attachment 4: slot=4, exportIndex=4, format=91, writeMask=0xf, addr=0x309a850000, extent=3840x2160, tileMode=27, dcc=0x309e810000
    • Attachment 5: slot=5, exportIndex=5, format=44, writeMask=0xf, addr=0x309e860000, extent=3840x2160, tileMode=27, dcc=0x30a0840000
    • Attachment 6: slot=6, exportIndex=6, format=44, writeMask=0xf, addr=0x30a0870000, extent=3840x2160, tileMode=27, dcc=0x30a2850000

    3. Immediate GPU Readback right after BasePass draw:

    Reading back Attachment 3 (BaseColor, 0x3098840000) immediately after the BasePass draw:

    [diag-readback] BasePass-postdraw completed: address=0x3098840000 center 32x32 nonzero=1024/1024
      center_raw=[0x05, 0x05, 0x20, 0x00] ([5, 5, 32, 0])
      min=[0, 1, 26, 0] max=[127, 148, 173, 0]
    

    The BasePass draw is writing valid, varied color values into the GPU attachment. The shader-output-to-MRT routing is completely functional.

    4. GPU Readback before FDeferredLightPS samples:

    When FDeferredLightPS executes to sample BaseColor:

    [diag-capture] FDeferredLightPS draw #4 detected sampling BaseColor:
      Views storage image: 0, StorageSource ptr: 0000000000000000, Layout: 5
    [diag-readback] FDeferredLightPS-presample completed:
      center 32x32 nonzero=1024/1024
      center_raw=[0xff, 0xff, 0x00, 0x01] ([255, 255, 0, 1]) min=[255, 255, 0, 1] max=[255, 255, 0, 1]
    

    ViewsStorageImage() evaluates to false (StorageSource == nullptr), falling back to a host snapshot that reads the unwritten/clear fill ([255, 255, 0, 1]).

    Summary / Root Cause:

    Per MarcZX10's diagnosis criteria, since the color is present in the attachment immediately after the draw, the issue is narrowed down to ordering / storage image synchronization:
    In ShaderResources.cpp (around line 809), when resolving the sampled texture descriptor for FDeferredLightPS, *keys == source->FilledKeys() triggers source.reset(), dropping the active GPU storage image because the DCC keys are seen as matching the initial clear fill (0000). Consequently, FDeferredLightPS falls back to reading the CPU snapshot containing the stale clear value ([255, 255, 0, 1]), causing the entire 3D scene to be illuminated in cyan-white.

    AI-assisted: yes

  9. milescoviello commented on Oct 26, 2026

    @milescoviello
    Contributor

    @Feuris thanks, that narrows it a lot. The branch you found is ShaderResources.cpp lines 802–816 on current main (from 4ce4033, "make DCC key fills clear what reads through the surface's keys"). When the pending storage image was filled by a clear, and the DCC keys in guest memory still equal that clear, the texture reads the clear value instead of the image.

    That is only right if nothing wrote the surface after the clear. On hardware, the BasePass draw to a DCC target rewrites its keys. So the question is why the keys at 0x309a820000 still read as the clear fill when FDeferredLightPS samples, after the draw you read back.

    Two things would tell:

    • APS5_TRACE_DCC_KEYS=1 prints a [dcc-keys] sampled 0x... through keys 0x... reads the ... fill, not the pending image line each time that branch fires. If it names 0x3098840000, this is the path.
    • The first bytes at 0x309a820000 right after the BasePass draw, next to the same read before the clear. If they're unchanged, the draw's key update never reached guest memory on that driver.

    I'll run Super Meat Boy 3D with APS5_TRACE_DCC_KEYS=1 on Linux/NVIDIA, where it renders in colour, and post whether that line appears for the same surface.

    AI-assisted: yes (Claude Code)

  10. milescoviello commented on Oct 26, 2026

    @milescoviello
    Contributor

    @Feuris @MarcZX10 the Linux side of that comparison: Super Meat Boy 3D on main e7e3e9c (Ubuntu 26.04.1, RTX 4070 Ti, NVIDIA 615.71.09), warm, 300 s with APS5_TRACE_DCC_KEYS=1. The world map renders in full colour.

    • The branch you found fires only once in 300 s, for a different surface: [dcc-keys] sampled 0x3191c60000 through keys ... reads the 0001 fill, not the pending image.
    • Draws keep rewriting the keys instead: 58,314 uncompressed keys stored on the gpu and 58,315 uncompressed store by first write lines, plus 53,778 uncompressed store by block write-back (storage refresh).

    So on NVIDIA the draws to a DCC target store "uncompressed" keys over the clear. When lighting samples the target, the keys no longer match the clear, and it views the image. My guess for your machine is that those stores don't happen for the BasePass targets. With the same switch, these lines would tell:

    • grep 'sampled 0x3098840000': if this matches, the branch fires for BaseColor.
    • grep '0x309a820000' on the uncompressed keys stored on the gpu and uncompressed store by first write lines: if none match, its keys were never rewritten after the clear.

    AI-assisted: yes (Claude Code)

  11. Feuris commented on Oct 26, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here is the diagnostic data from Windows 11 + RX 6700 XT on clean main:

    1. DCC Key Store Traces

    • grep '0x309a820000':
      Matches. The uncompressed GPU store is recorded for BaseColor Attachment 3:
      [dcc-keys] uncompressed store by first write (?) for surface 0x3098840000+0x1fe0000: keys 0x309a820000+0x28000 (packet 0x35 queue 0x0)
      [dcc-keys] uncompressed keys stored on the gpu: 0x309a820000+0x28000 (packet 0x35 queue 0x0)
      
    • grep 'sampled 0x3098840000':
      No match. However, it fires for Attachment 0 (SceneColor):
      [dcc-keys] sampled 0x3197940000 through keys 0x3199920000 reads the 0000 fill, not the pending image
      

    2. Run with APS5_CPU_DCC_KEYS=1

    • 3D environment geometry and material textures (rocks, foliage, leaves, Meat Boy) render with their textures instead of the uniform cyan-white clay model.
    • Because GuestMemory::Write stamps host pages, it triggers:
      [gpu] storage image 0x...: guest memory changed while GPU results were pending; keeping the CPU's blocks
      for SceneColor and intermediate lighting buffers, blowing out exposure and producing black shadows.

    3. Queue Switching and KeyStoreMemo

    In DccMetadata.cpp, MemoRecorder(memo) clears memo.entries on recorder change (active != memo.recorder).
    In the log, work alternates between graphics (queue 0x0) and compute (queue 0x20), clearing the uncompressed memo before lighting samples. When currentDccKeys falls back to reading guest RAM via VK_EXT_external_memory_host, it still sees the initial 0000 clear fill.

    AI-assisted: yes

  12. milescoviello commented on Oct 26, 2026

    @milescoviello
    Contributor

    @Feuris thanks, that's a clear result. APS5_CPU_DCC_KEYS=1 bringing the textures back says the keys are right, and their GPU-side store just isn't visible when lighting reads them.

    What I see in DccMetadata.cpp fits your queue observation. currentDccKeys checks for a pending write over the key range only on the active recorder:

    if (auto* recorder = Recorder::Active(); recorder != nullptr && recorder->PendingWriteOverlaps(metaAddress, count)) { ... SyncThrough ... }
    return readDccKeys(metaAddress, count, memoized);

    The uncompressed keys stored on the gpu store might be recorded on queue 0x0, with the sampling running on queue 0x20 (or the other way round). Then the active recorder holds no pending write over the keys, nothing syncs, and readDccKeys reads the host-imported memory before the other queue's batch has written it. It still holds the 0000 clear, so the branch reads the fill. On a GPU where that batch finishes sooner, the same race would mostly pass, which may be why it shows on your machine.

    One log line would confirm or rule it out. At the [dcc-keys] sampled ... reads the ... fill message (ShaderResources.cpp), print:

    • the queue of the active recorder;
    • recorder->PendingWriteOverlaps(resource.dccAddress, keyBytes);
    • Graphics::Recorder::SnapshotWriteOverlaps(resource.dccAddress, keyBytes), which isn't tied to one recorder.

    If the first is false and the third is true, the keys' writer is pending on another queue. The fix then belongs in currentDccKeys: wait for or sync that queue's pending key store before reading, not just the active recorder's. @MarcZX10, does that match how you'd expect the cross-queue case to be handled?

    AI-assisted: yes (Claude Code)

  13. milescoviello commented on Oct 26, 2026

    @milescoviello
    Contributor

    @Feuris a correction to my last comment: the cross-queue explanation is wrong. The device has a single recorder (VulkanDevice.cpp makes it once and calls Activate()), and every queue records into it. So no key store can sit on "another queue's recorder". KeyStoreMemo is dropped only when the active recorder changes, i.e. on device replacement, so a memo clear between queue 0x0 and 0x20 doesn't fit either.

    Here's a lead that does fit the message you got. The reads the ... fill, not the pending image branch (ShaderResources.cpp ~802) runs only when the pending storage image's FilledKeys() is a clear code and the keys read back equal it. filledKeys is set by StorageTexture::NoteKeysFill when the title fills a live image's keys with a clear byte. After that:

    • StorageTexture::MarkDirty (Texture.cpp ~3753) stores uncompressed keys on an image's first write only if (... && !IsDccClear(filledKeys)), and it doesn't reset filledKeys;
    • the write-backs (~6043, ~6181, ~6325) have the same !IsDccClear(filledKeys) condition.

    So if SMB3D clears SceneColor's keys with a 0x00 fill and then writes SceneColor through a storage image, nothing marks the keys uncompressed. Sampling then sees Clear0000 == FilledKeys() and reads the fill, which matches your SceneColor trace. Why AMD only is open; one guess is that the format takes the storage-image path on your GPU and not on mine.

    Your existing APS5_TRACE_DCC_KEYS=1 log can show the order. For SceneColor, what's the sequence of these lines?

    • [dcc-keys] Clear0000 key fill over 0x3197940000+... (from NoteKeysFill, with uploaded and dirty);
    • [dcc-keys] uncompressed store by first write ... for surface 0x3197940000, if there is one;
    • [dcc-keys] sampled 0x3197940000 through keys 0x3199920000 reads the Clear0000 fill.

    If the fill line comes before the frame's lighting writes and no first write store follows, this is the cause. @Celegans12, your b8d3060 added "unless the title filled the keys with a clear code since". Was that meant to cover a fill that comes before the write as well, or only a clear after it?

    AI-assisted: yes (Claude Code)

  14. Feuris commented on Oct 26, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here is the exact sequence for SceneColor (0x3197940000) from clean main (repeating every frame):

    1291: [dcc-keys] 0000 key fill over 0x3197940000+0x1fe0000 (uploaded uncompressed, dirty 1)
    1307: [dcc-keys] sampled 0x3197940000 through keys 0x3199920000 reads the 0000 fill, not the pending image
    1310: [dcc-keys] refresh 0x3197940000+0x1fe0000 keys uncompressed -> 0000 (dirty 1, 510 pending units, 0 cpu-stamped units)
    1311: [gpu] storage image 0x3197940000: guest memory changed while GPU results were pending; keeping the CPU's blocks
    1318: [dcc-keys] uncompressed store by first write (?) for surface 0x3197940000+0x1fe0000: keys 0x3199920000+0x28000 (packet 0x35 queue 0x0)
    

    The 0000 key fill occurs first, sampling immediately reads the 0000 fill and drops the pending image, and the first write uncompressed store arrives only afterwards.

    AI-assisted: yes

  15. milescoviello commented on Oct 26, 2026

    @milescoviello
    Contributor

    @Feuris thanks, that order narrows it a lot. The fill is noted first and the sample sees it, but what matters is whether that order is the title's or the driver's.

    If the title really clears SceneColor's keys and then samples it before drawing into it, reading the clear is correct. The cyan-white points the other way: the sample comes before the clear in the title's command order (a post pass reads this frame's SceneColor, then the keys are cleared for the next frame), and the driver notes the fill too early.

    The code allows that. In Driver::storeBufferFill (BufferFill.cpp), a fill on queue 0 with the draw pipeline active is enqueued behind the queued draws (DrawPipeline::Queue0().Enqueue), so it stays in order. A fill on any other queue runs store() right away, and StorageTexture::NoteKeysFill sets filledKeys then. Say the clear comes from the async compute queue, released by a label queue 0 writes after the post pass. If that label becomes visible before queue 0's draw pipeline has recorded the sampling draw, the draw is recorded after the fill was noted, and reads the fill. How far behind the pipeline runs depends on the machine, which would fit AMD-only.

    Two checks, both cheap:

    1. Your trace has a [dcc-keys] title fills keys 0x3199920000+... with 00000000 ... (queue 0x..) line just before line 1291. Which queue does it name? Not 0x0 would fit this.
    2. Run once with APS5_PIPELINED_DRAWS=0, which turns the draw pipeline off so queue 0 records each draw when its packet executes. If SceneColor renders correctly then, the draw pipeline's ordering against the other queue's fill is the bug. It will be slower; only the picture matters.

    AI-assisted: yes (Claude Code)

  16. 9 remaining items

  17. milescoviello commented on Nov 1, 2026

    @milescoviello
    Contributor

    @Feuris thanks, that rules out the float controls, and it leaves the module itself out too.

    One device-dependent branch is left that fits "only the storage-backed attachment loses its data". StorageFormatOrUndefined in Texture.cpp picks the format of a storage image from the GPU's format features. For B8G8R8A8_SRGB it falls back to B8G8R8A8_UNORM if the device reports STORAGE_IMAGE for it with optimal tiling. If the device doesn't, the format has no storage form, and the image goes down a path that a GPU reporting the bit never takes. My RTX 4070 Ti (NVIDIA 615.71.09) reports FORMAT_FEATURE_2_STORAGE_IMAGE_BIT in optimalTilingFeatures for FORMAT_B8G8R8A8_UNORM, so on NVIDIA attachment 3's storage image keeps that format.

    Could you paste what amdvlk64 reports? vulkaninfo --show-formats, then the optimalTilingFeatures of the group that lists FORMAT_B8G8R8A8_UNORM, and the same for FORMAT_B8G8R8A8_SRGB. That only needs the Vulkan runtime's vulkaninfo, not the SDK layers.

    • If STORAGE_IMAGE_BIT is missing there, attachment 3 takes that no-storage-form path on your card, and I'll follow it from StorageFormatOrUndefined to where the BasePass result is lost.
    • If it's present, both drivers create the same image, and the next thing to compare is the attachment's image and view creation (usage, flags and format list) on the two GPUs.

    AI-assisted: yes (Claude Code)

  18. Feuris commented on Nov 1, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here is what �mdvlk64 (driver 2.0.353 / AMD 26.8.1 on Windows 11) reports for the RX 6700 XT via �ulkaninfo --show-formats:

    1. optimalTilingFeatures for FORMAT_B8G8R8A8_UNORM (Common Format Group [7])

    FORMAT_FEATURE_2_STORAGE_IMAGE_BIT is present:
    Common Format Group[7]: Formats: count = 12 ... FORMAT_B8G8R8A8_UNORM ... Properties: optimalTilingFeatures: count = 12 FORMAT_FEATURE_2_SAMPLED_IMAGE_BIT FORMAT_FEATURE_2_STORAGE_IMAGE_BIT FORMAT_FEATURE_2_COLOR_ATTACHMENT_BIT FORMAT_FEATURE_2_COLOR_ATTACHMENT_BLEND_BIT FORMAT_FEATURE_2_BLIT_SRC_BIT FORMAT_FEATURE_2_BLIT_DST_BIT FORMAT_FEATURE_2_SAMPLED_IMAGE_FILTER_LINEAR_BIT FORMAT_FEATURE_2_TRANSFER_SRC_BIT FORMAT_FEATURE_2_TRANSFER_DST_BIT FORMAT_FEATURE_2_SAMPLED_IMAGE_FILTER_MINMAX_BIT FORMAT_FEATURE_2_STORAGE_READ_WITHOUT_FORMAT_BIT FORMAT_FEATURE_2_STORAGE_WRITE_WITHOUT_FORMAT_BIT

    2. optimalTilingFeatures for FORMAT_B8G8R8A8_SRGB (Common Format Group [11])

    As expected for sRGB, storage image is absent:
    Common Format Group[11]: Formats: count = 5 FORMAT_R8_SRGB FORMAT_R8G8_SRGB FORMAT_R8G8B8A8_SRGB FORMAT_B8G8R8A8_SRGB FORMAT_A8B8G8R8_SRGB_PACK32 Properties: optimalTilingFeatures: count = 10 FORMAT_FEATURE_2_SAMPLED_IMAGE_BIT FORMAT_FEATURE_2_COLOR_ATTACHMENT_BIT FORMAT_FEATURE_2_COLOR_ATTACHMENT_BLEND_BIT FORMAT_FEATURE_2_BLIT_SRC_BIT FORMAT_FEATURE_2_BLIT_DST_BIT FORMAT_FEATURE_2_SAMPLED_IMAGE_FILTER_LINEAR_BIT FORMAT_FEATURE_2_TRANSFER_SRC_BIT FORMAT_FEATURE_2_TRANSFER_DST_BIT FORMAT_FEATURE_2_SAMPLED_IMAGE_FILTER_MINMAX_BIT FORMAT_FEATURE_2_STORAGE_READ_WITHOUT_FORMAT_BIT

    3. Image & View Creation Details on AMD

    Because STORAGE_IMAGE_BIT is supported for B8G8R8A8_UNORM, StorageFormatOrUndefined selects VK_FORMAT_B8G8R8A8_UNORM and takes the storage-backed path (matching Views storage image: 1 in our diagnostic logs).

    For Attachment 3, the image and views are created as follows (Texture.cpp:1450-1480 and 1900-1915):

    • Base VkImageCreateInfo:
      • ormat: VK_FORMAT_B8G8R8A8_UNORM (format 44)
      • lags: VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT | VK_IMAGE_CREATE_EXTENDED_USAGE_BIT
      • usage: VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT
      • pNext: VkImageFormatListCreateInfo with �iewFormats = [VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_B8G8R8A8_SRGB]
    • Attachment View (AttachmentView):
      • ormat: VK_FORMAT_B8G8R8A8_SRGB (format 50)
      • pNext: VkImageViewUsageCreateInfo with usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT
    • Sampled View (FDeferredLightPS):
      • Samples via a view created from the storage image.

    Since both drivers create the same image configuration, does NVIDIA differ in how VkImageFormatListCreateInfo / EXTENDED_USAGE_BIT interacts with attachment views, or in the image memory barriers / layout transitions applied between �kCmdEndRenderPass and the sampled descriptor?

    AI-assisted: yes

  19. milescoviello commented on Nov 1, 2026

    @milescoviello
    Contributor

    @Feuris thanks. With STORAGE_IMAGE present, both drivers create the same image, so that branch is out.

    I don't find another device-dependent branch for that attachment. The draw pass loads and stores every bound color attachment (LOAD_OP_LOAD / STORE_OP_STORE in Pipeline.cpp). Its layouts and the barriers around the pass don't depend on the GPU. The partial-write path that writes a target through a storage image needs fragmentShaderPixelInterlock, and on a device without it it throws instead of writing zeros.

    What's left is ordering, which the two drivers handle differently. Attachment 3 is the storage-backed one, and a storage-backed image can be refreshed from guest memory by a copy recorded before the pass. If something recorded before the BasePass writes that image (an upload, clear or copy) and isn't properly ordered against the pass's color writes, AMD can finish it after the pass and leave zeros. NVIDIA tends to run those in order anyway. Your readback right after vkCmdEndRenderPass would show exactly that.

    The validation layer's synchronization validation reports that kind of hazard directly. It comes with the LunarG Vulkan SDK for Windows. With it installed:

    set VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation
    set VK_LAYER_ENABLES=VK_VALIDATION_FEATURE_ENABLE_SYNCHRONIZATION_VALIDATION_EXT
    

    Then grep the log for SYNC-HAZARD lines that name attachment 3's image, from a run through the same scene. The hazard type (WRITE_AFTER_WRITE or READ_AFTER_WRITE) and the two commands it names would show which recorded operation races the BasePass.

    AI-assisted: yes (Claude Code)

  20. Feuris commented on Nov 1, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here are the results from running with VK_LAYER_KHRONOS_validation (Vulkan SDK 1.4.363) and synchronization validation enabled through the 3D scene:

    1. Synchronization Validation (SYNC-HAZARD)

    • Result: 0 SYNC-HAZARD reports.
    • Synchronization validation was active throughout the entire run and 3D gameplay (confirmed by layer initialization).
    • No hazards (WRITE_AFTER_WRITE or READ_AFTER_WRITE) were flagged against Attachment 3 or any other color attachment around the BasePass.

    2. Validation Errors Caught During the Run

    The validation layer caught two Vulkan specification violations that directly involve attachment views and pipeline creation on �mdvlk64:

    A. Missing View Format in VkImageFormatListCreateInfo (VUID-VkImageViewCreateInfo-pNext-01585)

    Validation Error: [ VUID-VkImageViewCreateInfo-pNext-01585 ] | MessageID = 0xdd1d0240 vkCreateImageView(): pCreateInfo->pNext<VkImageFormatListCreateInfo>.pViewFormats has no formats that match the VkImageViewCreateInfo::format (VK_FORMAT_R32_UINT). The Vulkan spec states: If a VkImageFormatListCreateInfo structure was included in the pNext chain of the VkImageCreateInfo structure used when creating image and VkImageFormatListCreateInfo::viewFormatCount is not zero then format must be one of the formats in VkImageFormatListCreateInfo::pViewFormats

    • In Texture.cpp:1435-1455, StorageTexture creates the storage image with VkImageFormatListCreateInfo containing only [VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_B8G8R8A8_SRGB].
    • Later, in Draw.cpp:488 (materializeCmaskClear),
      esident->ElementView() calls createView(0, false, VK_FORMAT_R32_UINT) (for 4-byte texel elements), creating a VK_FORMAT_R32_UINT storage view on that image.
    • Because VK_FORMAT_R32_UINT is not listed in pViewFormats, this violates VUID-01585. On AMD, format lists inform DCC/compression layout; accessing the image with an unlisted view format can corrupt DCC or invalidate attachment writes.

    B. independentBlend Not Enabled on VkDevice (VUID-VkPipelineColorBlendStateCreateInfo-pAttachments-00605)

    Validation Error: [ VUID-VkPipelineColorBlendStateCreateInfo-pAttachments-00605 ] | MessageID = 0xcb4717d3 vkCreateGraphicsPipelines(): pCreateInfos[0].pColorBlendState->pAttachments[2] is different than pAttachments[0] and independentBlend feature was not enabled. Validation Error: [ VUID-VkPipelineColorBlendStateCreateInfo-pAttachments-00605 ] | MessageID = 0xcb4717d3 vkCreateGraphicsPipelines(): pCreateInfos[0].pColorBlendState->pAttachments[1] is different than pAttachments[0] and independentBlend feature was not enabled.

    • In VulkanDevice.cpp:1868, enabled.independentBlend is never set to �vailable.independentBlend (the member is omitted from device feature enablement). When the BasePass pipeline sets differing blend states across MRT attachments (e.g. attachment 2 vs 0), it triggers this spec error.

    AI-assisted: yes

  21. milescoviello commented on Nov 1, 2026

    @milescoviello
    Contributor

    @Feuris thanks, that's a clean result: no sync hazards, so the zeros aren't an ordering race, and the two VUIDs are good finds.

    VUID-VkImageViewCreateInfo-pNext-01585 comes from the local change, not main. On main, StorageTexture chains no VkImageFormatListCreateInfo (Texture.cpp has none; only MultisampleTarget.cpp does). The [B8G8R8A8_UNORM, B8G8R8A8_SRGB] list is the experiment from your 10-26 comment, and the R32_UINT element view then falls outside it. Please drop that change for the next run, or add R32_UINT to the list, so it doesn't confound the test.

    VUID-VkPipelineColorBlendStateCreateInfo-pAttachments-00605 is real on main. VulkanDevice.cpp enables dualSrcBlend and logicOp but never independentBlend, so a pipeline whose attachments have different blend or write-mask states is outside the spec on every GPU. A driver may then apply attachment 0's state to every target. NVIDIA evidently uses each target's own state; amdvlk64 doesn't have to. #6782 (@XairRrR) enables the feature. It's a two-line change, so the next test is that PR's change on top of your build (without the format-list experiment), through the same scene. If BaseColor comes back, this issue is #6782's, and that would be the evidence to post there.

    AI-assisted: yes (Claude Code)

  22. Feuris commented on Nov 1, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here are the results from testing PR #6782 (independentBlend) on top of clean main (with the format-list experiment completely reverted) under VK_LAYER_KHRONOS_validation:

    1. Validation Cleanliness

    2. Visual Outcome

    Since sync hazards, float controls, shader recompilation differences, image format list, and independent blend are all ruled out, the issue remains isolated to �mdvlk64's attachment writeback or DCC state handling for VK_FORMAT_B8G8R8A8_SRGB / storage-backed Attachment 3.

    AI-assisted: yes

  23. milescoviello commented on Nov 1, 2026

    @milescoviello
    Contributor

    @Feuris thanks. #6782 is still worth having for the VUID, and it settles that independent blend isn't the cause here.

    That leaves the one thing only attachment 3 has: it's storage-backed (STORAGE usage, MUTABLE_FORMAT | EXTENDED_USAGE). I don't have a switch on main to render that target without the storage backing, so a cheap env-var A/B isn't available. The most direct check left is a RenderDoc capture of the BasePass on the RX 6700 XT. RenderDoc supports Vulkan on Windows with amdvlk64.

    1. Pixel History on one pixel of attachment 3 that should hold BaseColor. It shows whether the BasePass draw's fragment reaches that attachment, the shader output value, and the write mask and blend state the driver used.
    2. If the fragment writes a non-zero value there but the attachment still reads zero, it's the attachment write or resolve inside the driver. If the history shows no write to attachment 3 at all, the pipeline or subpass setup for that attachment is the place to look, and the capture's pipeline state view shows it.

    If you can attach the .rdc (or screenshots of the pixel history and the pipeline's color blend state for attachment 3), I'll compare it with what the same draw records on NVIDIA.

    AI-assisted: yes (Claude Code)

  24. Feuris commented on Nov 4, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here are the RenderDoc capture results from the BasePass draw (EID 100515, vkCmdDrawIndexed with 26,766 indices) on the RX 6700 XT under Windows 11 (amdvlk64.dll):

    1. Pipeline State & Blend State for Attachment 3 (out_mrt_3)

    • Slot: Color 3: out_mrt_3 -> 2D Color Attachment 1666506 viewed by Image View 1666517 (3840x2160, B8G8R8A8_SRGB).
    • Color Write Mask: RGBA (0xF) across all 6 color targets.
    • Blend Enable: False (disabled).
    • The write mask is fully open for all four channels.

    Pipeline State for Attachment 3

    2. Attachment 3 Direct View & Pixel History (2D Color Attachment 1666506)

    Viewing Color 3: out_mrt_3 directly in the Texture Viewer shows the entire 3D scene in full natural color (green trees, foliage, textured terrain):

    Color View of Attachment 3

    Inspecting a pixel on the 3D tree foliage (coordinate (971, 601)):

    • Depth / Stencil Test: Passed (D: 0.00038, S: 0x00).
    • Shader Out: Non-zero (R: 0.12634, G: 0.26001, B: 0.00059, A: -0.00165).
    • Tex Before: [0.00, 0.00, 0.00, 0.00].
    • Tex After: [0.00784, 0.54510, 0.39216, 0.00] (visible foliage green swatch).

    Pixel History with Shader Out

    Conclusion

    1. The fragment shader is definitely outputting valid non-zero BaseColor.
    2. The pipeline write mask and blend states are correct.
    3. The AMD Vulkan driver does not drop the write: Attachment 3 in VRAM physically holds the complete green foliage color buffer immediately after the BasePass.

    This decisively isolates the bug downstream of the BasePass write: the monochrome cyan appearance in the final frame occurs when FDeferredLightPS samples from a decoupled/stale texture snapshot instead of viewing/sampling 2D Color Attachment 1666506 directly under the Windows AMD path.

    (I have the full 5.6 GB .rdc capture saved locally if you need any additional passes inspected or want me to upload it to a cloud drive).

    AI-assisted: yes

  25. milescoviello commented on Nov 4, 2026

    @milescoviello
    Contributor

    @Feuris thank you, that pins it down: attachment 3 receives correct BaseColor, so the cyan-white is introduced after the BasePass.

    One detail in your pixel history is worth noting. "Tex After" is exactly the shader output, sRGB-encoded, with red and blue swapped:

    Channel Shader out sRGB of it Tex After
    R 0.12634 0.3905 0.00784 (= sRGB of shader B)
    G 0.26001 0.5469 0.54510
    B 0.00059 0.0076 0.39216 (= sRGB of shader R)

    So the write goes through a component swap and an sRGB encode into the B8G8R8A8_SRGB view. That's only right if the guest's CB format for MRT 3 is a swapped, sRGB format. If the guest format is plain RGBA or non-sRGB, a reader that samples this image with the guest's own format would see swapped and re-encoded values. That is a lead, but it can't by itself turn the scene monochrome cyan-white.

    For the monochrome part, the lighting pass is the place to look. In the same capture, could you check the first FDeferredLightPS draw after EID 100515:

    1. Descriptor binding for the GBuffer sampler that reads BaseColor: the image ID and its view format. Is it 2D Color Attachment 1666506 itself, or a different image?
    2. If it's a different image: its contents in the Texture Viewer (cyan-white?) and, in the Resource Inspector, the command that last wrote it. A copy made before EID 100515, or one that was never refreshed, would be the "stale snapshot".
    3. If it's 1666506: the sampler view's format and swizzle. A view that reinterprets the bytes (for example an R32_UINT or a non-swapped format view of the same memory) would be my next suspect.

    No need to upload the 5.6 GB capture; screenshots of those three views are enough.

    AI-assisted: yes (Claude Code)

  26. Feuris commented on Nov 4, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here are the exact details for the GBuffer sampler that reads BaseColor:

    1. Image Identity

    It is 2D Color Attachment 1666506 itself (not a separate image or stale snapshot copy).

    In the lighting pass (vkCmdDrawIndexed at EID 182576 and 182604 using pipelines 937516 / 922754), the shader binds descriptor set 1675333 / 1675343. Binding slot 68 (VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE) holds the array of GBuffer image views, where element 7 is Image View 1675338, derived directly from 2D Color Attachment 1666506.

    2. Sampler View Format & Swizzle

    Looking at the creation parameters of Image View 1675338:

    • Parent Image: 2D Color Attachment 1666506
    • View Type: VK_IMAGE_VIEW_TYPE_2D
    • Format: VK_FORMAT_R8G8B8A8_SRGB (43)
    • Component Mapping (Swizzle):
      • r: VK_COMPONENT_SWIZZLE_B (5)
      • g: VK_COMPONENT_SWIZZLE_G (4)
      • b: VK_COMPONENT_SWIZZLE_R (3)
      • a: VK_COMPONENT_SWIZZLE_A (6)

    ImageView 1675338 Format and Swizzle

    3. Comparison with the BasePass Write View

    • BasePass Write View (Image View 1666517):
      • Format: VK_FORMAT_B8G8R8A8_SRGB (50)
      • Swizzle: IDENTITY (r: R, g: G, b: B, a: A)
    • Lighting Pass Read View (Image View 1675338):
      • Format: VK_FORMAT_R8G8B8A8_SRGB (43)
      • Swizzle: Swapped (r: B, g: G, b: R, a: A)

    Because the BasePass wrote to memory through B8G8R8A8_SRGB, physical memory byte 0 holds the shader's red value. When Image View 1675338 samples it as R8G8B8A8_SRGB, channel 0 would naturally read byte 0 (red), but the view explicitly applies r = B, causing a second swap. On top of that, reinterpreting a B8G8R8A8 attachment as an R8G8B8A8 sampled view with non-identity swizzle on AMD Vulkan (amdvlk64.dll) appears to trigger a driver-level cache/DCC aliasing mismatch when deferred lighting executes.

    AI-assisted: yes

  27. milescoviello commented on Nov 4, 2026

    @milescoviello
    Contributor

    @Feuris thanks, that rules out a stale copy: the lighting pass samples attachment 1666506 itself.

    On the swizzle: the read view R8G8B8A8_SRGB with r = B, b = R over the B8G8R8A8_SRGB image doesn't undo the write's swap. Following your pixel through:

    • Byte 0 holds the sRGB of the shader's red.
    • The view's raw red is byte 0. Its r = B swizzle then returns byte 2, which is the shader's blue.

    So the lighting shader sees red and blue exchanged, unless the guest's own texture descriptor expects that order. That shifts hue (red to blue). It can't turn green foliage into a uniform cyan-white, so I don't think attachment 3 explains the monochrome.

    A uniform cyan-white with black shadows looks more like every surface being lit as a pure specular or mirror material reflecting the sky. In a deferred pass that comes from the other GBuffer inputs, mainly the one holding metallic / specular / roughness and the shading-model ID. Could you check, in the same lighting draw (EID 182576), the other elements of binding 68?

    1. For each element, the parent image and the view's format and swizzle, as you listed for element 7.
    2. In the Texture Viewer, each of those views next to the BasePass attachment it comes from. A view whose contents read as a constant (all 1.0 or all 0 in a channel) while the attachment holds varied values is the one to look at.

    The pairs to compare are each element's view against the attachment (and write view) it was rendered through. A format or swizzle mismatch there would show up exactly like this.

    AI-assisted: yes (Claude Code)

  28. Feuris commented on Nov 5, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Here are the complete findings from inspecting the deferred lighting pass inputs and outputs in RenderDoc (RenderDoc GUI event EID 118201, corresponding to capture chunk 182576 vkCmdDrawIndexed(2376, 1) in Colour Pass #26):

    1. GBuffer Binding 68 Layout & Swizzle Table

    Binding Slot ImageView Parent Image Read View Format & Swizzle BasePass Write View Format & Swizzle Description
    image_3[0] 1675335 1666494 VK_FORMAT_R8G8B8A8_UNORM (r=B, g=G, b=R, a=A) 1666505: VK_FORMAT_B8G8R8A8_UNORM (IDENTITY) GBufferA (World Normal)
    image_3[1] 928324 924969 VK_FORMAT_D32_SFLOAT_S8_UINT (r=R, g=0, b=0, a=1) (Depth Attachment) Scene Depth
    image_3[2] 1675336 1666524 VK_FORMAT_R8G8B8A8_UNORM (r=B, g=G, b=R, a=A) 1666535: VK_FORMAT_B8G8R8A8_UNORM (IDENTITY) GBufferE (Precomputed Shadow)
    image_3[6] 1675337 1666489 VK_FORMAT_A2B10G10R10_UNORM_PACK32 (IDENTITY) 1666493: VK_FORMAT_A2B10G10R10_UNORM_PACK32 (IDENTITY) GBufferB (Metallic/Roughness/ShadingModel)
    image_3[7] 1675338 1666506 VK_FORMAT_R8G8B8A8_SRGB (r=B, g=G, b=R, a=A) 1666517: VK_FORMAT_B8G8R8A8_SRGB (IDENTITY) GBufferC (BaseColor / AO)

    2. Texture Viewer Inspection Results

    BaseColor (image_3[7]):

    Inspecting image_3[7] directly at EID 118201 shows the complete 3D scene in full, natural, vibrant color (green tree foliage, purple flowers, brown soil, blue atmosphere). The BasePass color writes are fully intact and available in memory to the deferred lighting pass.

    Lighting Pass BaseColor Input

    GBufferB (image_3[6]):

    The read view matches the write view (VK_FORMAT_A2B10G10R10_UNORM_PACK32 with identity swizzle). In the Texture Viewer, the values are not constant: geometry surfaces show detailed, varied values across all channels.
    Sampling a pixel on the tree foliage (X=2022, Y=280):

    • RGBA = [0.09091, 0.78495, 0.53861, 0.33333]
    • Low metallic (0.09), specular (0.78), medium roughness (0.54), and ShadingModelID (0.33333 = DefaultLit).
      None of the channels are collapsed to 1.0 or 0.0.

    Lighting Pass GBufferB Input

    SceneColor Output (EID 118201 / Attachment 1666483 R11G11B10_FLOAT):

    Draw 118201 is a local light volume mesh (2376 indices) for the central purple flower. Inspecting Outputs shows that the deferred lighting shader evaluates properly and deposits vibrant purple lighting onto the flower, with the unlit background correctly dark. The lighting shader is not writing monochrome cyan-white here.

    Lighting Pass SceneColor Output

    The BaseColor texture memory is intact, and GBufferB material parameters decode as valid non-mirror materials. The cyan-white appearance occurs downstream (either during full-screen directional/sky light accumulation in subsequent passes or during tonemapping/swapchain blit).

    AI-assisted: yes

  29. milescoviello commented on Nov 6, 2026

    @milescoviello
    Contributor

    @Feuris thanks, that's a clear capture. First a correction to my earlier comment: with the base pass writing through a B8G8R8A8 view (identity), byte 0 holds blue. So the read view's r=B, b=R swizzle on R8G8B8A8 puts the channels back in order, which fits the natural colours you see in image_3[7]. The swizzle isn't the problem.

    With BaseColor, GBufferB and that local light all correct, the pattern looks downstream to me. Lit surfaces saturating to one near-white tint while unlit areas and the sky stay exactly black is what a much-too-large exposure does: everything above zero is pushed into the tonemapper's shoulder, and zero stays zero. Two places in a UE frame would give that:

    1. Eye adaptation. The tonemap pass reads a small exposure texture or buffer (EyeAdaptation) written by a compute chain earlier in the frame (luminance histogram, then the adaptation pass). If that compute's output never lands, or reads back as 0 or denormal, the exposure scale explodes. In RenderDoc: find the tonemap draw, check the value bound as its exposure input, then go to the dispatch that wrote it.
    2. The colour-grading LUT. UE bakes a 32x32x32 LUT (a CombineLUTs pass, compute or pixel) that the tonemapper samples. A LUT that holds one colour maps every lit pixel to it. Its contents in the texture viewer would show that at once.

    Comparing SceneColor just before the tonemap pass tells the two apart. If it still looks natural there and the tonemap output is cyan-white, it's 1 or 2. If SceneColor is already cyan-white, look at the full-screen directional and sky light passes after your EID 118201.

    AI-assisted: yes (Claude Code)

  30. Feuris commented on Nov 6, 2026

    @Feuris
    ContributorAuthor

    @milescoviello Good news: on clean current main (commit 82fcf46d25), Super Meat Boy 3D's 3D gameplay now renders in full natural colour on Windows 11 with the AMD Radeon RX 6700 XT (amdvlk64.dll) as well.

    The foliage is green, Meat Boy is red, the dirt paths and wooden fences are brown, and the atmospheric lighting and shadows evaluate correctly without any cyan-white saturation or stuck black shadows.

    Super Meat Boy 3D Full Color Gameplay

    The upstream changes in libSceAgcDriver alongside running cleanly without the active RenderDoc capture layer resolved the exposure/tonemapping saturation that was blowing out the lit surfaces.

    Thank you very much for guiding the investigation through the GBuffer layout and tonemapping chain!

    AI-assisted: yes

  31. milescoviello commented on Nov 6, 2026

    @milescoviello
    Contributor

    @Feuris great, thanks for checking it on clean main. Since the colours are right at 82fcf46d25, I think this can be closed; you opened it, so that's your call. Super Meat Boy 3D is in today's game rotation on Linux (RTX 4070 Ti, main 9a4bcc3018), and I'll add a line here if it looks any different on NVIDIA.

    AI-assisted: yes (Claude Code)

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions