Fix the black screen on Intel iGPUs after loading a save #1960

Closed
thomas-montano wants to merge 2 commits from fix/intel-black-screen-rgb9e5 into main
thomas-montano commented 2026-11-10 21:50:50 +00:00 (Migrated from github.com)

On Intel iGPUs like the Iris Xe, loading a save gives a black screen with only the HUD on top. The scene is rendered as B10G11R11, but the fallback upscaler reads it back as RGB9E5, so almost all of the brightness is lost.

These GPUs don't have cooperative matrices, so the upscale networks get left out and stand-ins run instead: upscale_stub at 50% and taau_stand_in at 75%. Both sampled the scene through an RGB9E5 view. In Link's house at 50% the input averaged 0.033 and the output 0.00018.

The fix tracks the format a render pass last drew an image in (Texture::written_as, cleared by a shader store). upscale_stub skips the RGB9E5 decode when its input was rendered in its own format, and taau_stand_in hands such an input to upscale_stub. Inputs a shader stored as RGB9E5 (#425) still get decoded.

Tested on an Iris Xe (Raptor Lake-P), Mesa 26.2.4, Bazzite/KDE Wayland, i9-13900H. I loaded a save in Link's house at 50% (Minimum preset) and at 75%: the room shows up properly at both now. 16-21 fps at 50%. GPUs with cooperative matrices don't take these paths.

Tests: new cases in test_upscale_fallback.py and test_taau_env_lookup.py; the rendered ones fail on main. The other tests that read the changed functions still pass.

Refs #999, #1001. Not tested on Arc, so this may not cover every report there.

Screenshots:

Before:

image

After:

image
On Intel iGPUs like the Iris Xe, loading a save gives a black screen with only the HUD on top. The scene is rendered as B10G11R11, but the fallback upscaler reads it back as RGB9E5, so almost all of the brightness is lost. These GPUs don't have cooperative matrices, so the upscale networks get left out and stand-ins run instead: `upscale_stub` at 50% and `taau_stand_in` at 75%. Both sampled the scene through an RGB9E5 view. In Link's house at 50% the input averaged 0.033 and the output 0.00018. The fix tracks the format a render pass last drew an image in (`Texture::written_as`, cleared by a shader store). `upscale_stub` skips the RGB9E5 decode when its input was rendered in its own format, and `taau_stand_in` hands such an input to `upscale_stub`. Inputs a shader stored as RGB9E5 (#425) still get decoded. Tested on an Iris Xe (Raptor Lake-P), Mesa 26.2.4, Bazzite/KDE Wayland, i9-13900H. I loaded a save in Link's house at 50% (Minimum preset) and at 75%: the room shows up properly at both now. 16-21 fps at 50%. GPUs with cooperative matrices don't take these paths. Tests: new cases in `test_upscale_fallback.py` and `test_taau_env_lookup.py`; the rendered ones fail on main. The other tests that read the changed functions still pass. Refs #999, #1001. Not tested on Arc, so this may not cover every report there. Screenshots: Before: <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/416db8fe-d38b-4226-b9ab-171ff40bf1a7" /> After: <img width="2538" height="1430" alt="image" src="https://github.com/user-attachments/assets/651530ff-908a-44de-b585-d62acb82cc65" />
hckrman101 (Migrated from github.com) reviewed 2026-11-11 01:43:19 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for tracking this down. The numbers (0.033 in, 0.00018 out) and the before/after shots make the symptom clear, and deciding the decode by what last wrote the image is a better test than checking the vendor.

Root cause. The description shows how it breaks: a B10G11R11 input that a pass rendered gets read through an RGB9E5 view. It doesn't say why Iris Xe ends up there when #425 found real RGB9E5 bits in the same image on AMD. The game samples this image as RGB9E5 on the console too, so something normally stores RGB9E5 bits there after the scene pass, and on Iris Xe that store seems not to happen. If that's a pass that gets skipped or fails on ANV, this PR makes the fallback cope with it, which is worth having. But the missing writer is the real cause and may matter elsewhere. Could you say which program last writes the stub's input on Iris Xe (the img/watch logs show it), and whether it's the same on an AMD card without cooperative matrices?

Gating. You're right that GPUs with cooperative matrices never get here: taau_key only stands in under mma_software/RX_VK_NO_F16, and the networks are only left out without cooperative matrices. It isn't Intel-only, though. Steam Deck, RDNA1/2, Polaris and Pascal take both paths as well. In upscale_stub the new check only skips the decode when the input really was last rendered in its own format, so on those GPUs it's harmless or a fix. The taau_stand_in change reaches further (see inline): it can replace the 75% stand-in on those GPUs, and it can leave the output unwritten. A Steam Deck or RDNA2 run at 75% before/after would settle it.

Sync / memory / cost. No new barriers, images or allocations. upscale_stub does its own end_pass + full_barrier. The extra work is one loop over the dispatch's sampled textures, so no measurable cost. Nit: written_as is set in begin_pass under ParLock(marks) (often not taken) and read unlocked in the stub. With RX_VK_PARALLEL, segments record out of order, so the stub can see another segment's value. Parallel replay is off by default, so this is minor. The bigger gap is the writers that don't touch the field at all (inline).

Description vs testing. Tested on one machine: Iris Xe, Mesa 26.2.4 on Linux, 50% and 75%, game upscaler. Not tested on Intel's Windows driver, on Arc (which you call out), or on the non-Intel GPUs without cooperative matrices that this also reaches. I'd add that last point to the description.

Tests / CI. The new cases go into test_upscale_fallback.py and test_taau_env_lookup.py, which run_all.py already discovers, so nothing extra to declare. There's no case for upscale_stub returning false from taau_stand_in, or for a texture the TAAU samples that isn't its input. CI hasn't run yet: the three workflows are waiting for maintainer approval ("action required"). The patch applies cleanly to current main (48 commits behind).

The direction looks right to me. Once the two inline points on taau_stand_in are addressed and there's a run on a non-Intel GPU without cooperative matrices, I'd be happy with it.

Thanks for tracking this down. The numbers (0.033 in, 0.00018 out) and the before/after shots make the symptom clear, and deciding the decode by what last wrote the image is a better test than checking the vendor. **Root cause.** The description shows how it breaks: a B10G11R11 input that a pass rendered gets read through an RGB9E5 view. It doesn't say why Iris Xe ends up there when #425 found real RGB9E5 bits in the same image on AMD. The game samples this image as RGB9E5 on the console too, so something normally stores RGB9E5 bits there after the scene pass, and on Iris Xe that store seems not to happen. If that's a pass that gets skipped or fails on ANV, this PR makes the fallback cope with it, which is worth having. But the missing writer is the real cause and may matter elsewhere. Could you say which program last writes the stub's input on Iris Xe (the img/watch logs show it), and whether it's the same on an AMD card without cooperative matrices? **Gating.** You're right that GPUs with cooperative matrices never get here: `taau_key` only stands in under `mma_software`/`RX_VK_NO_F16`, and the networks are only left out without cooperative matrices. It isn't Intel-only, though. Steam Deck, RDNA1/2, Polaris and Pascal take both paths as well. In `upscale_stub` the new check only skips the decode when the input really was last rendered in its own format, so on those GPUs it's harmless or a fix. The `taau_stand_in` change reaches further (see inline): it can replace the 75% stand-in on those GPUs, and it can leave the output unwritten. A Steam Deck or RDNA2 run at 75% before/after would settle it. **Sync / memory / cost.** No new barriers, images or allocations. `upscale_stub` does its own `end_pass` + `full_barrier`. The extra work is one loop over the dispatch's sampled textures, so no measurable cost. Nit: `written_as` is set in `begin_pass` under `ParLock(marks)` (often not taken) and read unlocked in the stub. With `RX_VK_PARALLEL`, segments record out of order, so the stub can see another segment's value. Parallel replay is off by default, so this is minor. The bigger gap is the writers that don't touch the field at all (inline). **Description vs testing.** Tested on one machine: Iris Xe, Mesa 26.2.4 on Linux, 50% and 75%, game upscaler. Not tested on Intel's Windows driver, on Arc (which you call out), or on the non-Intel GPUs without cooperative matrices that this also reaches. I'd add that last point to the description. **Tests / CI.** The new cases go into `test_upscale_fallback.py` and `test_taau_env_lookup.py`, which `run_all.py` already discovers, so nothing extra to declare. There's no case for `upscale_stub` returning false from `taau_stand_in`, or for a texture the TAAU samples that isn't its input. CI hasn't run yet: the three workflows are waiting for maintainer approval ("action required"). The patch applies cleanly to current main (48 commits behind). The direction looks right to me. Once the two inline points on `taau_stand_in` are addressed and there's a run on a non-Intel GPU without cooperative matrices, I'd be happy with it.
hckrman101 (Migrated from github.com) commented 2026-11-11 01:43:19 +00:00

written_as is only updated here and in the storage-image path. copy_t2t (vkCmdCopyImage is a bit copy), buffer-to-texture copies, clear_tex and CPU uploads all leave it alone. So an image that was rendered once and later refilled with RGB9E5 bits by a copy or an upload keeps written_as == format, and the #425 decode is skipped. Resetting it to VK_FORMAT_UNDEFINED inside texture_written() (vk_texture.cpp) would cover every GPU writer in one place. begin_pass already calls that before setting it here, and the explicit reset in build_descriptors would then be redundant. upload() doesn't go through texture_written, so it needs the same reset.

`written_as` is only updated here and in the storage-image path. `copy_t2t` (`vkCmdCopyImage` is a bit copy), buffer-to-texture copies, `clear_tex` and CPU uploads all leave it alone. So an image that was rendered once and later refilled with RGB9E5 bits by a copy or an upload keeps `written_as == format`, and the #425 decode is skipped. Resetting it to `VK_FORMAT_UNDEFINED` inside `texture_written()` (vk_texture.cpp) would cover every GPU writer in one place. `begin_pass` already calls that before setting it here, and the explicit reset in `build_descriptors` would then be redundant. `upload()` doesn't go through `texture_written`, so it needs the same reset.
hckrman101 (Migrated from github.com) commented 2026-11-11 01:43:19 +00:00

Follow-up, not blocking: temporal_rejected_network runs above this when an upscaler other than the game's is picked, and it still samples the input through its RGB9E5 view. So with FSR or the temporal upscaler at 50% on Iris Xe, I'd expect the same dark picture. Untested on my side; worth a quick run.

Follow-up, not blocking: `temporal_rejected_network` runs above this when an upscaler other than the game's is picked, and it still samples the input through its RGB9E5 view. So with FSR or the temporal upscaler at 50% on Iris Xe, I'd expect the same dark picture. Untested on my side; worth a quick run.
hckrman101 (Migrated from github.com) commented 2026-11-11 01:43:19 +00:00

Two things here:

  1. If upscale_stub returns false, this returns false too. That happens when its input/output pick fails, out_area <= in_area, or RX_VK_NO_UPSCALE_STUB is set. A stood-in network is never compiled, so the dispatch is then skipped and the output isn't written at all, where before typed_upscale below ran. Suggest if (upscale_stub(cmd, s, p.key)) return true; and let it fall through.
  2. The check is "any B10G11R11 texture this dispatch samples was last rendered in its own format", not "the input was". Other render targets the network samples will usually pass that on any GPU, and nothing checks that the input is actually sampled as RGB9E5. On Steam Deck / RDNA2 / Pascal at 75%, this can replace the history-bounded stand-in (the issue 52 path) with the stub's plain blit, even where the input held stored RGB9E5 bits and the old path was right. upscale_stub then also makes its own pick of the input, which may not be the texture that matched here. Narrower: get the Texture behind texture_descriptors[0] (the one dw().imgs[0] views) with texture_view_of, as you already do for out, and test only that one.
Two things here: 1. If `upscale_stub` returns false, this returns false too. That happens when its input/output pick fails, `out_area <= in_area`, or `RX_VK_NO_UPSCALE_STUB` is set. A stood-in network is never compiled, so the dispatch is then skipped and the output isn't written at all, where before `typed_upscale` below ran. Suggest `if (upscale_stub(cmd, s, p.key)) return true;` and let it fall through. 2. The check is "any B10G11R11 texture this dispatch samples was last rendered in its own format", not "the input was". Other render targets the network samples will usually pass that on any GPU, and nothing checks that the input is actually sampled as RGB9E5. On Steam Deck / RDNA2 / Pascal at 75%, this can replace the history-bounded stand-in (the issue 52 path) with the stub's plain blit, even where the input held stored RGB9E5 bits and the old path was right. `upscale_stub` then also makes its own pick of the input, which may not be the texture that matched here. Narrower: get the `Texture` behind `texture_descriptors[0]` (the one `dw().imgs[0]` views) with `texture_view_of`, as you already do for `out`, and test only that one.
thomas-montano commented 2026-11-11 10:45:40 +00:00 (Migrated from github.com)

Thanks for the review. Your root-cause question turned out to be the key one. On current main I can't reproduce the black screen anymore: on the Iris Xe (Mesa 26.2.4), plain main's renderer gives a lit Link's house at 50% and 75%, with buffer mirrors off and an empty shader cache. The writer you were asking about is compute 6637ac1d052d22d4. It stores RGB9E5 bits into the input (768×432 at 50%) after graphics 22ffdff3 renders it as B10G11R11, and it runs every frame. So the typed decode is correct, and the fallback in this PR never triggers. I don't know why the writer was missing on the build I first tested (e7210437), and my logs from back then never got past the menus.

I've reworked the PR with your inline points (input-only check with fall-through, written_as reset in texture_written()/upload(), more tests), but without a reproduction I'd rather not add code that never runs, so I'm closing this.

Separately: at a 100% render resolution the picture is completely black on this machine on current main. No upscale network runs there, so it's a different bug. I'll look into it and open an issue.

Thanks for the review. Your root-cause question turned out to be the key one. On current main I can't reproduce the black screen anymore: on the Iris Xe (Mesa 26.2.4), plain main's renderer gives a lit Link's house at 50% and 75%, with buffer mirrors off and an empty shader cache. The writer you were asking about is compute `6637ac1d052d22d4`. It stores RGB9E5 bits into the input (768×432 at 50%) after graphics `22ffdff3` renders it as B10G11R11, and it runs every frame. So the typed decode is correct, and the fallback in this PR never triggers. I don't know why the writer was missing on the build I first tested (e7210437), and my logs from back then never got past the menus. I've reworked the PR with your inline points (input-only check with fall-through, `written_as` reset in `texture_written()`/`upload()`, more tests), but without a reproduction I'd rather not add code that never runs, so I'm closing this. Separately: at a 100% render resolution the picture is completely black on this machine on current main. No upscale network runs there, so it's a different bug. I'll look into it and open an issue.
Commenting is not possible because the repository is archived.
No description provided.