Repository navigation
Description
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:
- 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.
- Drop the pending view when another image overlaps. When
FindPendingreturns a source andPendingStorageOverlaps(address, bytes, source)is true, the source is dropped so thatcachedTextureflushes 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. - Let only CPU writes override pending results. Only CPU stamps (
cpuBlocks, thecpuoutput ofChangedBlocks) may override pending results, both inRefresh's drop rule and inwriteBackLayers's keep rule. - Force a snapshot for every cube view. On an older base, every cube view took flush plus snapshot.
- 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=1128×128 mips=8 images at each face address; - at the cube base,
dim=2 layers=N base=Nimages for each slice, plus onedim=3 layers=5 base=0image.
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
Tamalero commentedon Oct 13, 2026
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:
dim=1, 1 layer, 8 mips) atbase + f × 0x30000, which receive the draws;dim=3, 6 layers) atbase, which the downsample samples.With
APS5_TRACE_UPLOADandAPS5_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,
vkCmdCopyImageToBufferat 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: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:
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
main3377986aplus feat(agc): render layered draws into 3D color targets #1077, feat(agc): depth planes sampled as 2D arrays, cubes and integer views #425 and fix(agc): bind a buffer descriptor past the range limit by its committed bytes, or empty #3202, plus local diagnostics. Linux, RTX 4090 (NVIDIA 615.78), GCC 16, Release.0x2100d80000, faces at+ f × 0x30000. The draws are the sky PS, then the downsample PS for mips 1–7; the GGX filter writes cube B.APS5_TRACE_MIPS=2: the ordered draw list.APS5_DUMP_FACES: queues 128×128, 8-mip, single-layer RGBA16F storage images for a flip-time dump at their first write-back.APS5_REFRESH_ALL_UNITS: the re-upload experiment above.APS5_VIEW_OVERLAP_FLUSH: the flush experiment.Tested and written by Claude (AI assistant) on a contributor's machine.
pushkarreddyy commentedon Oct 13, 2026
Working on this - implementing cube packed-tail mip per-face render target handling in libSceAgcDriver. PR incoming
Tamalero commentedon Oct 13, 2026
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_VIEWslice f (0x1c00a005for face 5) withCB_COLOR_ATTRIB3MIP0_DEPTH = 0 (0x4dc6c000, SW_64KB_R_X).DecodeColorBufferonly keeps an array slice when MIP0_DEPTH is nonzero. Otherwise it renders face f as a standalone 2D surface atbase + 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:
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=1the 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
main3377986aplus feat(agc): render layered draws into 3D color targets #1077, feat(agc): depth planes sampled as 2D arrays, cubes and integer views #425 and fix(agc): bind a buffer descriptor past the range limit by its committed bytes, or empty #3202 (as before), the fix(agc): render a nonzero slice of an XOR color view with MIP0_DEPTH 0 into its array slice #4937 change, and local diagnostics. fix(agc): render a nonzero slice of an XOR color view with MIP0_DEPTH 0 into its array slice #4937 itself is rebased on2cc97e10. The house and city also needed feat(agc): sample the base level through unnormalized samplers #1418 and feat(agc): sample non-sRGB textures through FORCE_DEGAMMA samplers as sRGB views #4487 (both cherry-picked; thanks), plus three local sampler changes. Those three are approximations, not ready to upstream; each stop is listed below. Linux, RTX 4090, GCC 16, Release.what()texts. My local workarounds are guesses, not measured:guest sampler descriptor uses unnormalized coordinates with different minification and magnification filters, which is not implemented. Local: the magnification filter for both (LOD 0).guest sampler descriptor uses a secondary LOD bias which is not implemented. Local: LOD_BIAS_SEC read as a signed 2.4 value added to LOD_BIAS.guest sampler descriptor uses unnormalized coordinates with clamp mode 0 on X; only clamp-to-last-texel and clamp-to-border are implemented. Local: clamp to the edge.APS5_DUMP_TARGETSwith a 9×1 and 1×1 extent filter, from launch. Both are computed once at level load.Tested and written by Claude (AI assistant) on a contributor's machine.
pushkarreddyy commentedon Oct 13, 2026
Working on a fix in
libSceAgcDriverfor 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.pushkarreddyy commentedon Oct 13, 2026
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.
Tamalero commentedon Oct 13, 2026
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:
main18c438b10with no local write-back experiment. With today's storage write-back changes the sky light reaches the GPU. My local "store pending results before labels" experiment now causes a black scene (an exposure blow-up), so drop 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.
oneandonlydean commentedon Oct 14, 2026
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.