Skip to content

AGC: a cube's packed-tail mips rendered per face are not seen by the cube's sampled view; the reflection capture filters a clear value (High on Life PPSA15388, black lighting) #4736

Closed

Description

@Tamalero

High on Life (PPSA15388, Unreal Engine 4) renders every lit surface black on main. Emissive surfaces, bloom, particles and the UI draw correctly, and geometry occludes correctly. The cause is in the sky reflection capture. The GGX prefilter samples the lower mips of the captured cube, and most of those mips still hold a clear value (R 0, G 10000, B 0, A 0) when the filter reads them. The game renders each of those mips, one face at a time, just before the filter runs. The filtered cube, the sky SH and the average brightness are then wrong in G and A, and the lighting goes to zero.

I couldn't find which lookup hands the stale cube to the sampler. Five coherence experiments (listed below) don't change the result at all, so I'm reporting the evidence rather than a fix.

What the title does, in draw order (one capture; addresses change between runs). The cube is 128×128, 6 faces, 8 mips, RGBA16F, SW_64KB_R_X. Each face is drawn as its own color target at the face's base address (cube base + f × 0x30000), with CB_COLOR_VIEW selecting the mip.

draws pixel shader target
1–6 sky capture mip 0 of each face
7–48 downsample: samples the cube at mip N−1 mips 1–7 of each face
49–54 constant colour from a constant buffer mip 0 of each face
55–96 downsample again mips 1–7 of each face
97–144 GGX prefilter: samples the cube above at LOD > 0 mips 0–7 of the output cube

No draw writes (0, 10000, 0, 0), so it is an initial clear made outside this sequence.

What the prefilter samples. This is a copy of every mip of every layer of the storage image behind the cube texture view, taken at the next flip after a device wait. Each cell is the mean of R / G for one face and mip, and clear means every texel is (0, 10000, 0, 0):

mip face 0 face 1 face 2 face 3 face 4 face 5
0 (128²) 2.22 / 2.25 2.22 / 2.26 2.08 / 2.16 3.02 / 3.19 0.53 / 0.57 1.95 / 1.95
1 (64²) 2.23 / 2.26 2.22 / 2.26 2.03 / 2.11 3.02 / 3.19 0.57 / 0.62 1.95 / 1.95
2 (32²) 2.24 / 2.28 clear clear (96 %) clear (87 %) 0.58 / 0.62 clear
3 1.95 / 1173 clear clear 1.73 / 1.77 clear clear
4–7 partly clear clear 2.08, 0.57, 0.88, 4.18 clear clear clear (one 31 %)

Mips 0 and 1 are right on every face. Mips 2–7 mostly hold the clear. Of the slots that do hold data, some hold another face's level: face 2's mip 5 has a mean of 0.57, which is face 4's. Another 128×128 cube in the same dump has no clear texels in any of its 48 slots, and every mip has the same mean as its mip 0, as a correct box-filtered chain would. The pattern is bit-identical from run to run.

This explains the visible symptom exactly. Mip 0 of the prefilter's output cube is right on faces 0 and 4. On faces 1, 2, 3 and 5, G ≈ 234.8 and A ≈ 0.977, while R and B are within a few per cent of the input. That is the weighted GGX sum with some (0, 10000, 0, 0) samples in it.

Layout is not the problem. ComputeElementMipLayout(kR64KBX, 8, 128, 128, 8) and ComputeColorTargetMipLayout(RenderTarget, 8, 128, 128, 8, m) agree for every mip:

  • mip 0 is at +0x10000;
  • mips 1–7 are in the face's first 64 KiB block, at tail coordinates (64,0), (0,32), (32,0), (0,16), (16,0), (8,8), (0,12).

So rendering and sampling address the same bytes. Each downsample draw for face f, mip N renders into the per-face storage image at face f's address, but reads mip N−1 through the cube image at the cube base. So it looks like a read-after-write between two storage images that cover the same memory, and the read side never sees the per-face results for the packed tail. Mip 1 works because it reads mip 0, which was written long before.

Experiments. Each one leaves the dumps bit-identical:

  1. Render mip targets through guest memory. Mipmapped 2D color targets go through the non-resident path, which fix(agc): render into non-resident packed mip tails #4328 made work for packed tails.
  2. Drop the pending view when another image overlaps. When FindPending returns a source and PendingStorageOverlaps(address, bytes, source) is true, the source is dropped so that cachedTexture flushes all overlapping images first. It never triggered: the cube's lookups never take that branch. Every cube texture here is "viewed from storage image", 8 views per capture.
  3. Let only CPU writes override pending results. Only CPU stamps (cpuBlocks, the cpu output of ChangedBlocks) may override pending results, both in Refresh's drop rule and in writeBackLayers's keep rule.
  4. Force a snapshot for every cube view. On an older base, every cube view took flush plus snapshot.
  5. Turn the idle store off. With APS5_NO_STORE_AT_IDLE=1 (older base), the pattern is the same.

Related, but already merged and in my base: #4070/#4328 (rendering into non-resident packed mip tails) and #3475 (the SW_64KB_R_X layout at 64 and 128 bpp).

What I don't know: which path supplies the cube view for the downsample's reads between these draws, and whether the per-face images' tail results reach guest memory before each read. A unit test with two storage images over one cube, one per-face color target rendering mip N and then a cube sample at mip N, would probably reproduce it without the title.

Details and logs

Setup: CachyOS (Linux 7.2), RTX 4090 (NVIDIA 615.78), GCC 16, Release. main 3377986a plus #1077 (layered 3D targets; HoL needs it to load), #425 and #3202, plus local diagnostics. The relink is byte-identical to one from d06c0656.

Draw trace, one line per draw into a 128×128 surface (local trace at the driver draw entry):

#1-6    sky PS          surface <face f> mip 0
#7-48   downsample PS   surface <face f> mip 1..7  (face-major within each mip)
#49-54  constant PS     surface <face f> mip 0
#55-96  downsample PS   surface <face f> mip 1..7
#97-144 GGX filter PS   surface <output face f> mip 0..7

Storage images over the captured cube:

  • per-face dim=1 128×128 mips=8 images at each face address;
  • at the cube base, dim=2 layers=N base=N images for each slice, plus one dim=3 layers=5 base=0 image.

Compute writes to mips 1–7 of the cubes (storage views with base level N) also appear in the same captures.

Dump format (local APS5_DUMP_VIEW=<w>x<h>): vkCmdCopyImageToBuffer of every mip and layer of the storage image behind a cube view of that size, at the next flip after a device wait.

Tested and written by Claude (AI assistant) on a contributor's machine.

Activity

  1. Tamalero commented on Oct 13, 2026

    @Tamalero
    ContributorAuthor

    Update: three more traced runs narrow this down. The per-face render targets hold correct data. What goes stale is the handover from a face's 2D storage image to the cube storage image over the same memory.

    The order of operations is right. Every storage image over cube A is one of:

    • six per-face 2D images (dim=1, 1 layer, 8 mips) at base + f × 0x30000, which receive the draws;
    • the full cube image (dim=3, 6 layers) at base, which the downsample samples.

    With APS5_TRACE_UPLOAD and APS5_TRACE_FLUSH, before every downsample draw (face f, mip N), the cube image's refresh stores the previous face's image ("for storage refresh") and then uploads from guest memory ("guest memory changed"). The cube image is never written back during the downsample phase.

    The per-face images hold the data. I dumped each per-face image (every mip, vkCmdCopyImageToBuffer at the next flip) after its last write-back. On every face, mips 0–2 are complete and correct. Mips 3–7 are 25–81 % exact clear (0, 10000, 0, 0), with mixed values further down. So each downsample draw wrote what it sampled.

    The cube's copy of the face being drawn is stale. Clear (#) versus data (.) in mip 3 (16×16) of two faces:

    face 1                face 5
    ##############..      ..##......######
    #######.#####...      ...##.....######
    #############...      ...#############
    #############...      ...#############   (rows 4-15 like row 3)
    

    Only the columns next to one face edge have data. Those are the texels whose sample direction lands on the neighbouring face. So when face f's mip N is drawn, the cube image's slice f at mip N−1 is stale, while its neighbours' slices are right.

    Ruled out:

    • Re-uploading every unit of the cube image on each refresh: a local switch that marks all units changed whenever any is. No change, so the guest memory the cube reads from is already wrong.
    • Storing other images' pending results under the cube's sampled view before using it: a local experiment. It never triggers, because nothing else is pending there.
    • Rendering mip targets through guest memory, and sampling the cube as a CPU snapshot, both from the first report. Both are bit-identical, which also says guest memory, not the cube image, is where the data is missing.

    So the remaining suspect is the round trip of packed-tail mips. Mips 1–7 sit in the face's first 64 KiB block. They are stored from a 1-layer 2D image (writeBackLayers → sliceWindows, one whole-block window per tail level), and then read back by a 6-layer image over the same block. Either the per-face store doesn't put tail levels ≥ 2 where the cube upload reads them, or a later store of that block from a stale copy overwrites them. (An older build showed the loss from mip 2 down; this one shows it from mip 3.)

    I'm writing a unit test for exactly this round trip: known values in a per-face image's tail mips, written back, then read through a cube image over the same memory. I'll post it here if it reproduces.

    Setup and switches

    Tested and written by Claude (AI assistant) on a contributor's machine.

  2. pushkarreddyy commented on Oct 13, 2026

    @pushkarreddyy
    Contributor

    Working on this - implementing cube packed-tail mip per-face render target handling in libSceAgcDriver. PR incoming

  3. Tamalero commented on Oct 13, 2026

    @Tamalero
    ContributorAuthor

    Root cause found for the stale cube mips, and fixed in #4937. The scene is still dark, though, for a second reason further down.

    Cause. The downsample draws face f as CB_COLOR_VIEW slice f (0x1c00a005 for face 5) with CB_COLOR_ATTRIB3 MIP0_DEPTH = 0 (0x4dc6c000, SW_64KB_R_X). DecodeColorBuffer only keeps an array slice when MIP0_DEPTH is nonzero. Otherwise it renders face f as a standalone 2D surface at base + f × slice size, which drops the slice term of the XOR swizzle. At 8 bytes per texel, byte-address bits 8–11 are XORed with slice bits 3–0. So faces 1–5 land in permuted 256-byte blocks, and the cube view reads them back scrambled. Mip 0's mean survives the permutation, which is why it looked right. The packed tail mips share one 64 KiB block, so there the permutation mixes mips. That produced the "clear" texels and the wrong face's data in my earlier dumps.

    The unit test I mentioned reproduced it at first: per-face 2D images read back through a cube image, with slice 0 intact and slices 1–5 scrambled. The PR's test instead draws through a MIP0_DEPTH 0 view, which is what the game does.

    After the fix:

    • the downsampled cube is smooth on every face, from 128² down to 1×1;
    • cube B (GGX) is ~2–3 everywhere, no longer G ≈ 234;
    • the 1×1 capture brightness is 0.73 (was 2660);
    • the 9×1 sky SH holds sane coefficients (2.09, −0.003, −1.16, …).

    What's still wrong. In a later save (the home base and the city), direct light renders correctly: lit, textured walls and structures with specular highlights, and the inventory model is fully lit. Everything without direct light is black, including the sky, so the sky/indirect light still doesn't arrive. The GPU side now computes it correctly, so I'm looking at how the result reaches the game. UE4 reads the sky SH and the capture brightness back to the CPU after computing them. With APS5_EAGER_WRITEBACK=1 the picture changes, from black to fully drawn but in rainbow false colour alternating with black. That points at that read-back path, but eager write-back is too blunt to prove it. I'll trace it next.

    Details

    Tested and written by Claude (AI assistant) on a contributor's machine.

    Image Image
  4. pushkarreddyy commented on Oct 13, 2026

    @pushkarreddyy
    Contributor

    Working on a fix in libSceAgcDriver for the sampler runtime stops in High on Life (secondary LOD bias decoding and unnormalized coordinate clamp/filter handling). Will add unit tests and open PR shortly.

  5. pushkarreddyy commented on Oct 13, 2026

    @pushkarreddyy
    Contributor

    Opened #5156 to support secondary LOD bias (LOD_BIAS_SEC) and relax unnormalized coordinate sampler validation (clamp mode 0 to clamp-to-edge, and differing min/mag filter assignment). Passes all CI checks and unit tests. @Tamalero please feel free to validate against your High on Life home base scene.

  6. Tamalero commented on Oct 13, 2026

    @Tamalero
    ContributorAuthor

    Update: the black scene is solved. High on Life's home base now renders fully lit, and looks like the PC version in the same save and spot.

    What fixed it:

    Still open, filed separately:

    The cube part of this issue is fixed on main, so I think it can be closed. I'll leave that to a maintainer in case you want to keep it for the other scene problems.

    Tested and written by Claude (AI assistant) on a contributor's machine.

    Image
  7. oneandonlydean commented on Oct 14, 2026

    @oneandonlydean
    Collaborator

    Closing: #4551 is on main and keeps the slice XOR for these cube faces; the remaining scene problems are in #5487 and #5488. Comment here if the cube mips are still stale on current main.

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