fix(vk): snapshot uniform buffers on Intel when the buffer pools have no VRAM mirror #2464

Merged
Sellierm merged 1 commit from fix/igpu-ub-snapshot into main 2026-11-12 03:04:59 +00:00
Sellierm commented 2026-11-11 20:19:38 +00:00 (Migrated from github.com)

This is my first contribution to Hookshot, so I am happy to change anything about the approach, the style or the scope. I did the investigation and the change with the help of AI, as the commit trailers say. I checked the results on my own machine, described below.

On Intel Core Ultra iGPUs, Kokiri Forest is covered by a white veil: the ground is missing and the sky/fog is painted over it. The mechanism is not Intel-specific (any GPU whose buffer pools have no VRAM mirror can read uniform buffers stale), but the new default is limited to Intel for now.

Why

With no VRAM mirror (mirror_on() off: integrated GPUs, GCN since #2343, RX_VK_MIRROR=0), a draw's uniform buffers are bound straight into the imported guest pools. The GPU reads them when it runs the frame. By then the game has already written the next frame's per-object matrices over the ones that were submitted.

In Kokiri, the depth prepass (generic program ffb53ed102dd9908) drew the terrain with the wrong c4 matrices, so the terrain landed off screen. The depth there stayed at far (1.0). The fog/atmosphere composite (1a7482c0ec9991ea) then treats every pixel above 0.9999 as sky and painted fog over the ground. The forward pass draws the ground colour correctly, which is why only the fog layer looked wrong. On a discrete GPU the mirror is copied at submission time, so it never sees the rewrite.

What changed

RX_VK_UB_SNAPSHOT already copies a uniform buffer into the stream as submitted (memory_read_as_submitted). It is now on by default on Intel when the pools are not mirrored (vendorID == 0x8086 && !pools_mirrored()). Other GPUs without a mirror (AMD APUs, Steam Deck, GCN, RX_VK_MIRROR=0) keep it off until someone measures the cost there. RX_VK_UB_SNAPSHOT=0 turns it off; =1 forces it on anywhere.

How it was found

  • RX_VK_DUMP_EACH=ffb53ed102dd9908 + RX_VK_DUMP_ARGS read the depth back after each prepass draw. 551 of 558 draws added no depth at all, including 12k-24k-index terrain meshes drawn while the depth was still empty.
  • A CPU projection of the same draws, using the logged vertices and c3/c4, put 454 of them on screen with depth ~0.99.
  • Writing depth with compare ALWAYS, no cull and depth clamp changed nothing. Neither did copying the vertex and index buffers into the stream as submitted. Copying the uniform buffers did: the prepass depth went from ~45,000 far samples (of 57,600) to ~1,000, and the veil went away.

Testing

  • Machine: Lenovo laptop (type 83DJ).
    • CPU: Intel Core Ultra 7 155H (Meteor Lake).
    • GPU: integrated Intel Arc Graphics (8 Xe-cores), no dedicated GPU; video memory is shared with system RAM.
    • RAM: 32 GB LPDDR5x-7467.
    • OS: Windows 11 Pro, build 26300 (Insider).
    • Driver: Intel 32.0.101.9175, installed by hand (the version from #741).
    • Game settings: output resolution setting 3 (HOS_OUTPUT_RES=3), RX_VK_FIFO=1, RX_VK_PREPARE_BACKGROUND=1.
  • Build: v2.1.0 with this change; then current main (3e32949, with #2419) built from source with this branch rebased on it.
  • Current main without this change: the veil is still there (#2419 does not cover it). Numbers and screenshots in the comment below.
  • Kokiri, from the house to the village:
    • before: white veil over the ground in every wide view;
    • after: ground, paths, deck and ivy correct, light fog as intended;
    • frame rate unchanged, ~8.5-11 fps in Kokiri at the same settings.
  • No AMD or discrete GPU tested; the default there is unchanged.

Related: #999 (Intel tracker); report and workaround: #999

🤖 Generated with Claude Code

This is my first contribution to Hookshot, so I am happy to change anything about the approach, the style or the scope. I did the investigation and the change with the help of AI, as the commit trailers say. I checked the results on my own machine, described below. On Intel Core Ultra iGPUs, Kokiri Forest is covered by a white veil: the ground is missing and the sky/fog is painted over it. The mechanism is not Intel-specific (any GPU whose buffer pools have no VRAM mirror can read uniform buffers stale), but the new default is limited to Intel for now. ## Why With no VRAM mirror (`mirror_on()` off: integrated GPUs, GCN since #2343, `RX_VK_MIRROR=0`), a draw's uniform buffers are bound straight into the imported guest pools. The GPU reads them when it runs the frame. By then the game has already written the next frame's per-object matrices over the ones that were submitted. In Kokiri, the depth prepass (generic program `ffb53ed102dd9908`) drew the terrain with the wrong `c4` matrices, so the terrain landed off screen. The depth there stayed at far (1.0). The fog/atmosphere composite (`1a7482c0ec9991ea`) then treats every pixel above 0.9999 as sky and painted fog over the ground. The forward pass draws the ground colour correctly, which is why only the fog layer looked wrong. On a discrete GPU the mirror is copied at submission time, so it never sees the rewrite. ## What changed `RX_VK_UB_SNAPSHOT` already copies a uniform buffer into the stream as submitted (`memory_read_as_submitted`). It is now on by default on Intel when the pools are not mirrored (`vendorID == 0x8086 && !pools_mirrored()`). Other GPUs without a mirror (AMD APUs, Steam Deck, GCN, `RX_VK_MIRROR=0`) keep it off until someone measures the cost there. `RX_VK_UB_SNAPSHOT=0` turns it off; `=1` forces it on anywhere. ## How it was found - `RX_VK_DUMP_EACH=ffb53ed102dd9908` + `RX_VK_DUMP_ARGS` read the depth back after each prepass draw. 551 of 558 draws added no depth at all, including 12k-24k-index terrain meshes drawn while the depth was still empty. - A CPU projection of the same draws, using the logged vertices and `c3`/`c4`, put 454 of them on screen with depth ~0.99. - Writing depth with compare ALWAYS, no cull and depth clamp changed nothing. Neither did copying the vertex and index buffers into the stream as submitted. Copying the uniform buffers did: the prepass depth went from ~45,000 far samples (of 57,600) to ~1,000, and the veil went away. ## Testing - **Machine:** Lenovo laptop (type 83DJ). - **CPU:** Intel Core Ultra 7 155H (Meteor Lake). - **GPU:** integrated Intel Arc Graphics (8 Xe-cores), no dedicated GPU; video memory is shared with system RAM. - **RAM:** 32 GB LPDDR5x-7467. - **OS:** Windows 11 Pro, build 26300 (Insider). - **Driver:** Intel 32.0.101.9175, installed by hand (the version from #741). - **Game settings:** output resolution setting 3 (`HOS_OUTPUT_RES=3`), `RX_VK_FIFO=1`, `RX_VK_PREPARE_BACKGROUND=1`. - **Build:** v2.1.0 with this change; then current `main` (3e32949, with #2419) built from source with this branch rebased on it. - **Current main without this change:** the veil is still there (#2419 does not cover it). Numbers and screenshots in the comment below. - **Kokiri, from the house to the village:** - before: white veil over the ground in every wide view; - after: ground, paths, deck and ivy correct, light fog as intended; - frame rate unchanged, ~8.5-11 fps in Kokiri at the same settings. - No AMD or discrete GPU tested; the default there is unchanged. Related: #999 (Intel tracker); report and workaround: https://git.magicbean.dev/MagicBeanDev/Hookshot/issues/999 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 20:19:38 +00:00
thomas-montano commented 2026-11-11 21:09:55 +00:00 (Migrated from github.com)

I tested this on a second iGPU, on Linux. My machine doesn't reproduce the veil, so I can't confirm the fix, but I found no regressions, and I have some history on the same root cause that may help.

Setup

  • Intel Iris Xe (Raptor Lake-P), integrated, 32 GB RAM, Linux (Bazzite), Mesa ANV 26.2.4
  • This PR's commit cherry-picked onto current main (4b5266a0): no conflicts, and the build is clean
  • The log shows vk: VRAM mirror of buffer pools off, so the new default applies on this GPU

Result

  • With the default (snapshot on): Kokiri looks normal. The ground and paths show under light fog, and Link and the HUD are correct.
  • With RX_VK_UB_SNAPSHOT=0: the same. I don't see the white veil on this machine with the snapshot off either, so I can't confirm the fix from here. I saw no regression with it on.

Earlier report of the same cause
On 2026-11-10 I tracked down a different symptom on this Iris Xe to what looks like the same root cause. Link's hair, hat and sleeves and the leaves around his door were white in most frames, and the hearts and button prompts were drawn huge or out of place. The iGPU, replaying behind the game, read a later frame's constants from the unmirrored pools. My local fix turned mirrors on for Intel iGPUs (mirror_on()), which fixed it at a cost of roughly 2–3 fps (22 vs 24 in Link's house). By the time I meant to open a PR, those artifacts no longer showed on upstream main without mirrors, so I dropped it.

That suggests the stale reads are timing-dependent: whether a frame's uniforms get overwritten before the GPU reads them depends on how far the replay falls behind the game. That fits the bug showing on your Core Ultra in Kokiri and not on my Iris Xe today. This PR fixes it more narrowly and more cheaply than mirroring every pool: only the uniform buffers are copied, and you measured no fps cost. That's what I'd want on iGPUs.

I tested this on a second iGPU, on Linux. My machine doesn't reproduce the veil, so I can't confirm the fix, but I found no regressions, and I have some history on the same root cause that may help. **Setup** - Intel Iris Xe (Raptor Lake-P), integrated, 32 GB RAM, Linux (Bazzite), Mesa ANV 26.2.4 - This PR's commit cherry-picked onto current `main` (`4b5266a0`): no conflicts, and the build is clean - The log shows `vk: VRAM mirror of buffer pools off`, so the new default applies on this GPU **Result** - With the default (snapshot on): Kokiri looks normal. The ground and paths show under light fog, and Link and the HUD are correct. - With `RX_VK_UB_SNAPSHOT=0`: the same. I don't see the white veil on this machine with the snapshot off either, so I can't confirm the fix from here. I saw no regression with it on. **Earlier report of the same cause** On 2026-11-10 I tracked down a different symptom on this Iris Xe to what looks like the same root cause. Link's hair, hat and sleeves and the leaves around his door were white in most frames, and the hearts and button prompts were drawn huge or out of place. The iGPU, replaying behind the game, read a later frame's constants from the unmirrored pools. My local fix turned mirrors on for Intel iGPUs (`mirror_on()`), which fixed it at a cost of roughly 2–3 fps (22 vs 24 in Link's house). By the time I meant to open a PR, those artifacts no longer showed on upstream `main` without mirrors, so I dropped it. That suggests the stale reads are timing-dependent: whether a frame's uniforms get overwritten before the GPU reads them depends on how far the replay falls behind the game. That fits the bug showing on your Core Ultra in Kokiri and not on my Iris Xe today. This PR fixes it more narrowly and more cheaply than mirroring every pool: only the uniform buffers are copied, and you measured no fps cost. That's what I'd want on iGPUs.
Joey-Magnus (Migrated from github.com) requested changes 2026-11-11 21:24:10 +00:00
Joey-Magnus (Migrated from github.com) left a comment

Good investigation, but:

  • It was tested on 6c8c1bf, before #2419 landed. #2419 fixes the same class of bug: the iGPU game overwrites uniforms before later parts of a split submission read them (#997). Please retest the veil on current main without this change first.
  • tests/test_env_on.py fails on getenv(...) ? env_on(...) : ... at vk_draw.cpp:2780.
  • "Discrete GPUs are unchanged" is true, but mirror_on() is off on every non-discrete GPU, so this also switches on per-draw uniform snapshots on the Steam Deck and AMD APUs like the 780M. It was only tested on one Intel laptop. #2343 also turns mirrors off on GCN dGPUs, which would quietly enable this there too.
Good investigation, but: - It was tested on `6c8c1bf`, before #2419 landed. #2419 fixes the same class of bug: the iGPU game overwrites uniforms before later parts of a split submission read them (#997). Please retest the veil on current main without this change first. - `tests/test_env_on.py` fails on `getenv(...) ? env_on(...) : ...` at `vk_draw.cpp:2780`. - "Discrete GPUs are unchanged" is true, but `mirror_on()` is off on every non-discrete GPU, so this also switches on per-draw uniform snapshots on the Steam Deck and AMD APUs like the 780M. It was only tested on one Intel laptop. #2343 also turns mirrors off on GCN dGPUs, which would quietly enable this there too.
hckrman101 (Migrated from github.com) reviewed 2026-11-11 22:18:26 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, this is a careful write-up, and the depth readback numbers make the mechanism easy to follow. I agree with @Joey-Magnus's three points and won't repeat them in full. Here's where I land on each:

  • Retest on current main first. #2419 merged today. It fixes the fence waits that returned after the first ~120-draw part of a split submission, so the game was rewriting uniforms that the later parts still had to read. Your Kokiri prepass alone is 558 draws, so that frame is certainly split, and the "next frame's matrices over the submitted ones" symptom is what #2419 describes. @thomas-montano's note that his Iris Xe artifacts depended on timing points the same way. If the veil is gone on main without this change, the default flip isn't needed, and RX_VK_UB_SNAPSHOT=1 stays as the opt-in it already is. If it's still there, that's a strong result and worth posting.
  • test_env_on.py: see the inline comment for a form that passes the check.
  • Scope. !mirror_on() covers more than Intel iGPUs. It's true on every non-discrete GPU (Deck, 780M/8060S APUs), on GCN dGPUs since #2343, and with RX_VK_MIRROR=0. On all of those, every UBO bind becomes a stream allocation plus a copy through memory_read_as_submitted, which takes pools_mu when the range spans pages. You measured no fps change on the 155H, but nobody has measured the Deck or an RX 580, where the frame budget is tighter. If the retest still shows the veil, I'd either want a number from one AMD APU and one GCN card or limit the new default to Intel for now.

No CI ran on this branch. It's 52 commits behind main, but the hunk's context is unchanged there, so it should rebase cleanly.

Thanks, this is a careful write-up, and the depth readback numbers make the mechanism easy to follow. I agree with @Joey-Magnus's three points and won't repeat them in full. Here's where I land on each: - **Retest on current main first.** #2419 merged today. It fixes the fence waits that returned after the first ~120-draw part of a split submission, so the game was rewriting uniforms that the later parts still had to read. Your Kokiri prepass alone is 558 draws, so that frame is certainly split, and the "next frame's matrices over the submitted ones" symptom is what #2419 describes. @thomas-montano's note that his Iris Xe artifacts depended on timing points the same way. If the veil is gone on main without this change, the default flip isn't needed, and `RX_VK_UB_SNAPSHOT=1` stays as the opt-in it already is. If it's still there, that's a strong result and worth posting. - **`test_env_on.py`**: see the inline comment for a form that passes the check. - **Scope.** `!mirror_on()` covers more than Intel iGPUs. It's true on every non-discrete GPU (Deck, 780M/8060S APUs), on GCN dGPUs since #2343, and with `RX_VK_MIRROR=0`. On all of those, every UBO bind becomes a stream allocation plus a copy through `memory_read_as_submitted`, which takes `pools_mu` when the range spans pages. You measured no fps change on the 155H, but nobody has measured the Deck or an RX 580, where the frame budget is tighter. If the retest still shows the veil, I'd either want a number from one AMD APU and one GCN card or limit the new default to Intel for now. No CI ran on this branch. It's 52 commits behind main, but the hunk's context is unchanged there, so it should rebase cleanly.
@ -2787,7 +2771,17 @@ VkDescriptorBufferInfo uniform_range(const NvnState& s, const Stage& st, uint32_
return {cur->stream, at, n};
hckrman101 (Migrated from github.com) commented 2026-11-11 22:18:26 +00:00

test_env_on.py flags getenv(name) ? when that is the name's only getenv read. Reading the variable once into a local keeps the tri-state and passes the check (same shape as mirror_on()):

static const bool snapshot = [] {
    const char* e = getenv("RX_VK_UB_SNAPSHOT");
    return e ? env_on("RX_VK_UB_SNAPSHOT") : !pools_mirrored();
}();

Nit: the comment above says the pools are unmirrored on "an integrated GPU", but that's also the case on GCN dGPUs (#2343) and with RX_VK_MIRROR=0. Worth saying "no VRAM mirror (mirror_on() off)" rather than naming the GPU class.

`test_env_on.py` flags `getenv(name) ?` when that is the name's only `getenv` read. Reading the variable once into a local keeps the tri-state and passes the check (same shape as `mirror_on()`): ```cpp static const bool snapshot = [] { const char* e = getenv("RX_VK_UB_SNAPSHOT"); return e ? env_on("RX_VK_UB_SNAPSHOT") : !pools_mirrored(); }(); ``` Nit: the comment above says the pools are unmirrored on "an integrated GPU", but that's also the case on GCN dGPUs (#2343) and with `RX_VK_MIRROR=0`. Worth saying "no VRAM mirror (`mirror_on()` off)" rather than naming the GPU class.
Sellierm commented 2026-11-12 00:00:27 +00:00 (Migrated from github.com)

Thanks for the reviews. I retested on current main (3e32949, with #2419), built from source with this branch rebased on it, and ran the same Kokiri scene with RX_VK_UB_SNAPSHOT=0 (main's behaviour) and without it (this change).

The veil is still there on main. Same passes, same order, "far" depth samples out of 57,600:

main, RX_VK_UB_SNAPSHOT=0 main + this change
depth after the early prepass draws (f8d01f66) 45,313 603
depth after 879fe82e 35,499 37
fog/atmosphere pass 1a7482c0, 4 dumps over 4 min 21,238 - 34,839 0, 0, 0, 0

That is the same profile as on 6c8c1bf (~45,000 vs ~1,000), so the split-submission fence fix doesn't cover this case. Screenshots: main without / with the snapshot.
main, RX_VK_UB_SNAPSHOT=0:
main_sans_fix
main + this change:
main_avec_fix

Caveats:

  • The run without the snapshot still had ~40 draws a frame left out for compiling pipelines (the run with it had 0-3), but the prepass depth numbers above come from the same passes in both runs, and the screenshot shows the same veil as before.
  • I disabled the pipeline cache file in these runs (RX_VK_PCACHE_LOAD=0): on this driver vkGetPipelineCacheData faults (read 0x190 in igvk64.dll, in the pipeline_cache_save thread) on a cache created from file data. That happens on v2.1.0 too and is unrelated to this PR.
  • In two of three further runs without the snapshot the device was lost (VK_ERROR_DEVICE_LOST) while Kokiri loaded; none with it. Only an observation, I haven't dug into it. Edit: later runs with the snapshot (and with #2419 reverted) lost the device the same way, so that's unrelated to this change. It looks like something on current main on this machine: the same scene and settings on v2.1.0 didn't lose the device. I'm still looking at it.
  • No usable fps number from these runs (they read back depth every minute). On v2.1.0 the fps were unchanged with the snapshot (~8.5-11 in Kokiri).

Changes (force-pushed, one commit on current main):

  • The switch is read with the form from the inline comment, so tests/test_env_on.py passes.
  • The default is now limited to Intel: vendorID == 0x8086 && !pools_mirrored(). AMD APUs, GCN (#2343) and RX_VK_MIRROR=0 keep it off until someone measures it there; RX_VK_UB_SNAPSHOT=1 still turns it on anywhere.
  • The comment says "no VRAM mirror (mirror_on() off)" instead of naming a GPU class, and mentions #2419.
  • The DLL builds clean on 3e32949.

Thanks also to the Iris Xe tester for checking it on Mesa ANV.

Thanks for the reviews. I retested on current main (3e32949, with #2419), built from source with this branch rebased on it, and ran the same Kokiri scene with `RX_VK_UB_SNAPSHOT=0` (main's behaviour) and without it (this change). **The veil is still there on main.** Same passes, same order, "far" depth samples out of 57,600: | | main, `RX_VK_UB_SNAPSHOT=0` | main + this change | |---|---|---| | depth after the early prepass draws (`f8d01f66`) | 45,313 | 603 | | depth after `879fe82e` | 35,499 | 37 | | fog/atmosphere pass `1a7482c0`, 4 dumps over 4 min | 21,238 - 34,839 | 0, 0, 0, 0 | That is the same profile as on 6c8c1bf (~45,000 vs ~1,000), so the split-submission fence fix doesn't cover this case. Screenshots: main without / with the snapshot. **main, `RX_VK_UB_SNAPSHOT=0`:** <img width="1280" height="720" alt="main_sans_fix" src="https://github.com/user-attachments/assets/c0ad8e74-e2a5-4dde-a19c-b2f1eba4d3cc" /> **main + this change:** <img width="1280" height="720" alt="main_avec_fix" src="https://github.com/user-attachments/assets/57f52b9f-1f59-4813-b36b-1ef704e8b16d" /> Caveats: - The run without the snapshot still had ~40 draws a frame left out for compiling pipelines (the run with it had 0-3), but the prepass depth numbers above come from the same passes in both runs, and the screenshot shows the same veil as before. - I disabled the pipeline cache file in these runs (`RX_VK_PCACHE_LOAD=0`): on this driver `vkGetPipelineCacheData` faults (read 0x190 in igvk64.dll, in the `pipeline_cache_save` thread) on a cache created from file data. That happens on v2.1.0 too and is unrelated to this PR. - ~~In two of three further runs without the snapshot the device was lost (`VK_ERROR_DEVICE_LOST`) while Kokiri loaded; none with it. Only an observation, I haven't dug into it.~~ Edit: later runs with the snapshot (and with #2419 reverted) lost the device the same way, so that's unrelated to this change. It looks like something on current main on this machine: the same scene and settings on v2.1.0 didn't lose the device. I'm still looking at it. - No usable fps number from these runs (they read back depth every minute). On v2.1.0 the fps were unchanged with the snapshot (~8.5-11 in Kokiri). **Changes** (force-pushed, one commit on current main): - The switch is read with the form from the inline comment, so `tests/test_env_on.py` passes. - The default is now limited to Intel: `vendorID == 0x8086 && !pools_mirrored()`. AMD APUs, GCN (#2343) and `RX_VK_MIRROR=0` keep it off until someone measures it there; `RX_VK_UB_SNAPSHOT=1` still turns it on anywhere. - The comment says "no VRAM mirror (`mirror_on()` off)" instead of naming a GPU class, and mentions #2419. - The DLL builds clean on 3e32949. Thanks also to the Iris Xe tester for checking it on Mesa ANV.
Sellierm (Migrated from github.com) reviewed 2026-11-12 02:16:14 +00:00
@ -2787,7 +2771,17 @@ VkDescriptorBufferInfo uniform_range(const NvnState& s, const Stage& st, uint32_
return {cur->stream, at, n};
Sellierm (Migrated from github.com) commented 2026-11-12 02:16:14 +00:00

Done in 83c18ac: the switch is read with this form, and the comment now says "no VRAM mirror (mirror_on() off)".

Done in 83c18ac: the switch is read with this form, and the comment now says "no VRAM mirror (`mirror_on()` off)".
Commenting is not possible because the repository is archived.
No description provided.