Run FSR 3.1 on the frame chain FSR 4.1 uses #1096

Closed
N3oTraX wants to merge 14 commits from fsr3/backend into main
N3oTraX commented 2026-11-09 11:07:47 +00:00 (Migrated from github.com)

Part 2 of 3 of the split of #871. Adds FSR 3.1 as a second engine on the frame chain from #1008, so cards and systems without FSR 4 still get a temporal upscaler.

This depends on two open PRs and cannot be separated from them: #1095 (the compiled passes) and #1008 (dlss_dispatch, the jitter and the FSR 4.1 engine, none of which exist on main). Their commits are real ancestors here, so GitHub drops them as they land. The diff to review is the last five commits: 12 files, +1,166/-17. If this is better kept out of the queue until #1008 lands, close it and it will be rebased then.

The branch merges cleanly on current main. It carries one merge of #1008, whose conflict with #946 in vk_menu.cpp is resolved by keeping both sides (kMenuWidth = menu_hit::kWidth and #1008's help_of).

What changed

  • gpu/vk/vk_fsr3.cpp and .h: FSR 3.1's images, pipelines and nine dispatches. Three commits are notquiteog's, from #822.
  • FSR 3.1 runs on the inputs FSR 4.1 already gets: the same colour, depth, camera motion and jitter, with no second input path. Near, far and field of view come from the view-projection matrix.
  • gpu/vk/upscale_engine.h: the choice between Off, Auto, FSR 4.1 and FSR 3.1, as plain data that tests/upscale_engine_test.cpp checks without a game or a GPU. Auto takes FSR 4.1 when AMD's library is present with a model for the card, else FSR 3.1, else the game's upscale. If FSR 4.1 fails in play, FSR 3.1 takes over.
  • Compatibility: RX_FSR4=0/1 and fsr4= still read as Off and Auto. New: RX_UPSCALER=off|auto|fsr4|fsr3 and upscaler=.

Known limit until part 3: the menu row is still AMD FSR 4.1, whose On now means Auto. Without AMD's library FSR 3.1 runs while the row says no FSR 4 was found. Part 3 replaces the row.

Left out on purpose from #821 and #822, because none of it is on the tested path: the input contract, the seam, the census, the camera hooks and HOS_FPS_FORCE.

How it was tested

GPU, driver RX 7900 XTX (RDNA3), RADV Mesa 26.2.6, Linux
Game v131072, build e0f3ccfa, Ultrawide on
In game, on the #871 tree (6095c4d) FSR 4.1.1 INT8 and FSR 3.1 each drew 5120x2160 -> 5120x2160, switched live several times, no crash or device lost in the log. vk_fsr3.cpp, vk_fsr4.cpp and the selection code are unchanged from that tree
On this branch hookshot_vk.so builds with 0 warnings. tests/run_all.py fsr upscale menu: 8 passed

That run used RX_VK_NO_WARM=1, RX_VK_NO_PREDICT=1 and RX_VK_PRECOMPILE=0 because of #803, and RX_VK_ANISO=16.

Not tested

  • This exact tree has not run in game yet. Its base is newer than the tree above.
  • Resolution below 100%: the run above was anti-aliasing only, with no real upscale.
  • Image quality and fps were not measured.
  • RDNA2 and RDNA4, the real FSR 4.1 failure path to FSR 3.1, and Windows.

🤖 Generated with Claude Code

Part 2 of 3 of the split of #871. Adds FSR 3.1 as a second engine on the frame chain from #1008, so cards and systems without FSR 4 still get a temporal upscaler. > **This depends on two open PRs and cannot be separated from them:** #1095 (the compiled passes) and #1008 (`dlss_dispatch`, the jitter and the FSR 4.1 engine, none of which exist on `main`). Their commits are real ancestors here, so GitHub drops them as they land. **The diff to review is the last five commits: 12 files, +1,166/-17.** If this is better kept out of the queue until #1008 lands, close it and it will be rebased then. The branch merges cleanly on current `main`. It carries one merge of #1008, whose conflict with #946 in `vk_menu.cpp` is resolved by keeping both sides (`kMenuWidth = menu_hit::kWidth` and #1008's `help_of`). ## What changed - `gpu/vk/vk_fsr3.cpp` and `.h`: FSR 3.1's images, pipelines and nine dispatches. Three commits are notquiteog's, from #822. - **FSR 3.1 runs on the inputs FSR 4.1 already gets:** the same colour, depth, camera motion and jitter, with no second input path. Near, far and field of view come from the view-projection matrix. - `gpu/vk/upscale_engine.h`: the choice between Off, Auto, FSR 4.1 and FSR 3.1, as plain data that `tests/upscale_engine_test.cpp` checks without a game or a GPU. Auto takes FSR 4.1 when AMD's library is present with a model for the card, else FSR 3.1, else the game's upscale. If FSR 4.1 fails in play, FSR 3.1 takes over. - **Compatibility:** `RX_FSR4=0/1` and `fsr4=` still read as Off and Auto. New: `RX_UPSCALER=off|auto|fsr4|fsr3` and `upscaler=`. Known limit until part 3: the menu row is still `AMD FSR 4.1`, whose On now means Auto. Without AMD's library FSR 3.1 runs while the row says no FSR 4 was found. Part 3 replaces the row. Left out on purpose from #821 and #822, because none of it is on the tested path: the input contract, the seam, the census, the camera hooks and `HOS_FPS_FORCE`. ## How it was tested | | | |---|---| | GPU, driver | RX 7900 XTX (RDNA3), RADV Mesa 26.2.6, Linux | | Game | v131072, build `e0f3ccfa`, Ultrawide on | | In game, on the #871 tree (`6095c4d`) | FSR 4.1.1 INT8 and FSR 3.1 each drew `5120x2160 -> 5120x2160`, switched live several times, no crash or device lost in the log. `vk_fsr3.cpp`, `vk_fsr4.cpp` and the selection code are unchanged from that tree | | On this branch | `hookshot_vk.so` builds with 0 warnings. `tests/run_all.py fsr upscale menu`: 8 passed | That run used `RX_VK_NO_WARM=1`, `RX_VK_NO_PREDICT=1` and `RX_VK_PRECOMPILE=0` because of #803, and `RX_VK_ANISO=16`. ## Not tested - **This exact tree has not run in game yet.** Its base is newer than the tree above. - Resolution below 100%: the run above was anti-aliasing only, with no real upscale. - Image quality and fps were not measured. - RDNA2 and RDNA4, the real FSR 4.1 failure path to FSR 3.1, and Windows. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 commented 2026-11-09 18:39:53 +00:00 (Migrated from github.com)

Hi! This PR has merge conflicts with main now (a lot has landed today). Could you merge main in or rebase and resolve them? Happy to give it a proper review once it's up to date.

Hi! This PR has merge conflicts with `main` now (a lot has landed today). Could you merge `main` in or rebase and resolve them? Happy to give it a proper review once it's up to date.
N3oTraX commented 2026-11-09 20:52:50 +00:00 (Migrated from github.com)

I will rebase all PR

I will rebase all PR
hckrman101 (Migrated from github.com) reviewed 2026-11-09 23:29:38 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for splitting #871 up. I reviewed only this PR's own commits (6de9239..1bbdb02: vk_fsr3.cpp/.h, upscale_engine.h, and the vk_draw.cpp / vk_menu.cpp / vk_fsr4.cpp glue). I didn't re-review #1095 or #1008.

The FSR 3.1 backend itself looks solid. I checked every pass's binding list against the FSR3UPSCALER_BIND_* defines in #1095's GLSL and they all match. The storage formats match the shaders' qualifiers, the luma-history rgba8 note is right, and the UNDEFINED to GENERAL transition plus the clears on a new image set are fine. So are the per-pass compute barriers, the depth-to-view constants, the horizontal tanHalfFOV, and jitter in render pixels with jitter-free motion (motion_vector_jitter_cancellation = 0). The wave/_nowave pick is a nice touch.

The main problem is that #1008 has been rebuilt since, so almost all of the glue here now targets code that no longer exists. dlss_dispatch, FsrJitter, fsr_on() and the viewport jitter are gone. #1008 now has a TemporalEngine (vk_temporal.h) and the chain in vk_temporal.cpp, with jitter on the host side (nx2/oot_jitter.py). That's why this PR conflicts. Here's what I think has to change to fit it:

  1. Make FSR 3.1 an engine. Add const TemporalEngine* fsr3_engine() next to fsr4_engine(), built from start / prepare / evaluate / release / memory, and drop all the vk_draw.cpp changes (fsr_engine(), fsr3_evaluate, fj.engine, the output_view() branch). The chain already starts a new history when the engine changes (s.drawing != engine.e calls release() and sets last_frame = 0), so the engine-switch reset here isn't needed.
  2. prepare(cmd, rw, rh, ow, oh, &in_w, &in_h, &why). The chain's inputs are in_w x in_h with the frame in the top-left corner, and the render size can change under them. Allocate the render-size images at the maximum render size (set *in_w = ow, *in_h = oh like FSR 4, or pick your own maximum). Put that size in max_render_size and give render_size = f.render_w/h per frame. Today max_render_size is always the current render size, and any size change rebuilds and retires everything (see inline). Return 2 when the images are (re)made and -1 for a hard failure.
  3. evaluate(cmd, f). Write UPSCALED_OUTPUT into f.output.view (RGBA16F at dw x dh) and remove g.output, output_image() and output_view(); the chain's dlss_out.comp copies it into the game's target. Use f.jitter as it is (same convention as FSR 4's jitterOffset). Take near, far and fov from f.near_z/far_z/fov_y, since the chain already gets them from the projection, so camera_from_view_projection and its test can go. Take motion_vector_scale from f.render_w/h. Leave the inputs in GENERAL. FSR_AA (dw = rw) should work as is.
  4. release() and memory(). Hand the images (and, on a final release, the pipelines) to temporal_later() in place of the g_retired list counted by texture_frame_no(). The chain then frees FSR 3.1's VRAM when it goes idle (temporal_idle) or another engine takes over. Right now nothing frees it (see inline).
  5. Selection. #1008 already owns opts.upscaler, with upscaler_rows.h values GAME / DLSS / DLAA / FSR / FSR_AA, saved as dlss=, plus RX_VK_FSR=1/2. This PR's Choice enum reuses the same field with other meanings (3 is FSR 3.1 here and FSR 4 in #1008). So drop upscale::Choice, RX_UPSCALER, upscaler= and the fsr4= migration, and do the choice in upscaler_rows.h / temporal_mode() / engine_of():
    • temporal_mode() returns GAME whenever fsr4_why() != FSR_OK. That has to allow FSR 3.1.
    • engine_of() has two slots chosen by is_fsr. It needs an FSR 3.1 slot, picked when FSR 4 isn't usable or its slot has failed (that's the "FSR 3.1 takes over" fallback; today a failed FSR 4 means the game's upscale for good). With that, fsr4_failed() isn't needed.
    • temporal_jitter_size() checks engines[is_fsr ? 1 : 0].failed, so after an FSR 4 failure it would switch the jitter off while FSR 3.1 is drawing. It has to check the engine in use. name_of() needs the new names too.
    • Whether FSR 3.1 gets its own rows or sits behind FSR/FSR_AA as "Auto" is your and madebyjordan's call. Keeping it as plain data in upscaler_rows.h means tests/test_upscaler_rows.cpp can cover it.

A few smaller points (inline): the 1x1 default reactive mask is read with texelFetch at every render pixel; frame time is fixed at 16.7 ms; and the current menu row clamps forced choices back to Auto. The last one goes away with point 5.

Compared with #1335: its vk_fsr3.cpp comes from the same #822 code but from before this PR's work. It has no failed/reason state, no wave detection and no retiring, it adds RX_VK_FSR_STAGE diagnostics, and it assumes HDR colour and reversed depth. It feeds FSR 3.1 through its own seam (upscale_seam.h, its own depth and camera hooks), copies fsr3::output_image() straight into the game's target, and keeps the "jitter not extracted yet" plumbing-check path. Its upscaler_rows.h uses FSR3 = 3, FSR4 = 4, which collides with #1008's FSR = 3, FSR_AA = 4, and it doesn't use TemporalEngine either. This PR's approach is the right base: one input path shared with FSR 4, shaders compiled for exactly those inputs, and a failure state. Once ported as above it slots straight into #1008. #1335 would need the same port and more.

No CI ran on this branch. Not good to merge as is: it conflicts, and it needs the port to #1008's current interface first.

Thanks for splitting #871 up. I reviewed only this PR's own commits (`6de9239..1bbdb02`: `vk_fsr3.cpp/.h`, `upscale_engine.h`, and the `vk_draw.cpp` / `vk_menu.cpp` / `vk_fsr4.cpp` glue). I didn't re-review #1095 or #1008. The FSR 3.1 backend itself looks solid. I checked every pass's binding list against the `FSR3UPSCALER_BIND_*` defines in #1095's GLSL and they all match. The storage formats match the shaders' qualifiers, the luma-history rgba8 note is right, and the UNDEFINED to GENERAL transition plus the clears on a new image set are fine. So are the per-pass compute barriers, the depth-to-view constants, the horizontal `tanHalfFOV`, and jitter in render pixels with jitter-free motion (`motion_vector_jitter_cancellation` = 0). The `wave`/`_nowave` pick is a nice touch. The main problem is that #1008 has been rebuilt since, so almost all of the glue here now targets code that no longer exists. `dlss_dispatch`, `FsrJitter`, `fsr_on()` and the viewport jitter are gone. #1008 now has a `TemporalEngine` (`vk_temporal.h`) and the chain in `vk_temporal.cpp`, with jitter on the host side (`nx2/oot_jitter.py`). That's why this PR conflicts. Here's what I think has to change to fit it: 1. **Make FSR 3.1 an engine.** Add `const TemporalEngine* fsr3_engine()` next to `fsr4_engine()`, built from `start` / `prepare` / `evaluate` / `release` / `memory`, and drop all the `vk_draw.cpp` changes (`fsr_engine()`, `fsr3_evaluate`, `fj.engine`, the `output_view()` branch). The chain already starts a new history when the engine changes (`s.drawing != engine.e` calls `release()` and sets `last_frame = 0`), so the engine-switch reset here isn't needed. 2. **`prepare(cmd, rw, rh, ow, oh, &in_w, &in_h, &why)`.** The chain's inputs are `in_w x in_h` with the frame in the top-left corner, and the render size can change under them. Allocate the render-size images at the maximum render size (set `*in_w = ow, *in_h = oh` like FSR 4, or pick your own maximum). Put that size in `max_render_size` and give `render_size = f.render_w/h` per frame. Today `max_render_size` is always the current render size, and any size change rebuilds and retires everything (see inline). Return 2 when the images are (re)made and -1 for a hard failure. 3. **`evaluate(cmd, f)`.** Write `UPSCALED_OUTPUT` into `f.output.view` (RGBA16F at `dw x dh`) and remove `g.output`, `output_image()` and `output_view()`; the chain's `dlss_out.comp` copies it into the game's target. Use `f.jitter` as it is (same convention as FSR 4's `jitterOffset`). Take near, far and fov from `f.near_z/far_z/fov_y`, since the chain already gets them from the projection, so `camera_from_view_projection` and its test can go. Take `motion_vector_scale` from `f.render_w/h`. Leave the inputs in GENERAL. FSR_AA (`dw = rw`) should work as is. 4. **`release()` and `memory()`.** Hand the images (and, on a final release, the pipelines) to `temporal_later()` in place of the `g_retired` list counted by `texture_frame_no()`. The chain then frees FSR 3.1's VRAM when it goes idle (`temporal_idle`) or another engine takes over. Right now nothing frees it (see inline). 5. **Selection.** #1008 already owns `opts.upscaler`, with `upscaler_rows.h` values GAME / DLSS / DLAA / FSR / FSR_AA, saved as `dlss=`, plus `RX_VK_FSR=1/2`. This PR's `Choice` enum reuses the same field with other meanings (3 is FSR 3.1 here and FSR 4 in #1008). So drop `upscale::Choice`, `RX_UPSCALER`, `upscaler=` and the `fsr4=` migration, and do the choice in `upscaler_rows.h` / `temporal_mode()` / `engine_of()`: - `temporal_mode()` returns GAME whenever `fsr4_why() != FSR_OK`. That has to allow FSR 3.1. - `engine_of()` has two slots chosen by `is_fsr`. It needs an FSR 3.1 slot, picked when FSR 4 isn't usable *or its slot has `failed`* (that's the "FSR 3.1 takes over" fallback; today a failed FSR 4 means the game's upscale for good). With that, `fsr4_failed()` isn't needed. - `temporal_jitter_size()` checks `engines[is_fsr ? 1 : 0].failed`, so after an FSR 4 failure it would switch the jitter off while FSR 3.1 is drawing. It has to check the engine in use. `name_of()` needs the new names too. - Whether FSR 3.1 gets its own rows or sits behind FSR/FSR_AA as "Auto" is your and madebyjordan's call. Keeping it as plain data in `upscaler_rows.h` means `tests/test_upscaler_rows.cpp` can cover it. A few smaller points (inline): the 1x1 default reactive mask is read with `texelFetch` at every render pixel; frame time is fixed at 16.7 ms; and the current menu row clamps forced choices back to Auto. The last one goes away with point 5. **Compared with #1335:** its `vk_fsr3.cpp` comes from the same #822 code but from before this PR's work. It has no `failed`/`reason` state, no `wave` detection and no retiring, it adds `RX_VK_FSR_STAGE` diagnostics, and it assumes HDR colour and reversed depth. It feeds FSR 3.1 through its own seam (`upscale_seam.h`, its own depth and camera hooks), copies `fsr3::output_image()` straight into the game's target, and keeps the "jitter not extracted yet" plumbing-check path. Its `upscaler_rows.h` uses `FSR3 = 3, FSR4 = 4`, which collides with #1008's `FSR = 3, FSR_AA = 4`, and it doesn't use `TemporalEngine` either. This PR's approach is the right base: one input path shared with FSR 4, shaders compiled for exactly those inputs, and a failure state. Once ported as above it slots straight into #1008. #1335 would need the same port and more. No CI ran on this branch. Not good to merge as is: it conflicts, and it needs the port to #1008's current interface first.
@ -0,0 +481,4 @@
// (work recorded against the old size may still run: its images are retired, not destroyed)
for_each_image([](Image& t) {
if (t.image) g_retired.push_back({t, texture_frame_no()}), t = Image{};
hckrman101 (Migrated from github.com) commented 2026-11-09 23:29:39 +00:00

Nothing ever frees these. free_retired() only runs from prepare(), and shutdown() has no caller, so after switching to FSR 4 / Off / DLSS the current set and any retired ones stay allocated for the rest of the run. In #1008 this becomes release() handing them to temporal_later().

Nothing ever frees these. `free_retired()` only runs from `prepare()`, and `shutdown()` has no caller, so after switching to FSR 4 / Off / DLSS the current set and any retired ones stay allocated for the rest of the run. In #1008 this becomes `release()` handing them to `temporal_later()`.
@ -0,0 +515,4 @@
made = made && make_image(g.frame_info, VK_FORMAT_R32G32B32A32_SFLOAT, 1, 1);
made = made && make_image(g.spd_atomic, VK_FORMAT_R32_UINT, 1, 1);
made = made && make_image(g.lanczos_lut, VK_FORMAT_R16_SNORM, 128, 1);
made = made && make_image(g.default_reactive, VK_FORMAT_R8_UNORM, 1, 1);
hckrman101 (Migrated from github.com) commented 2026-11-09 23:29:39 +00:00

The 1x1 default_reactive is also bound as the reactive and transparency/composition masks (pass 6, bindings 3 and 4). DilateReactiveMasks calls LoadReactiveMask(), which is a texelFetch at render-pixel coordinates (ClampLoad to RenderSize()), so every pixel except (0,0) reads out of bounds. robustImageAccess is off in vk_main.cpp, which makes that undefined. AMD returns 0 in practice, but other vendors may not. The SDK does the same thing, but D3D defines out-of-bounds loads as 0. A render-size R8 image cleared to 0 (or enabling robust image access for these pipelines) avoids it. The exposure binding is fine, since it only reads (0,0).

The 1x1 `default_reactive` is also bound as the reactive and transparency/composition masks (pass 6, bindings 3 and 4). `DilateReactiveMasks` calls `LoadReactiveMask()`, which is a `texelFetch` at render-pixel coordinates (`ClampLoad` to `RenderSize()`), so every pixel except (0,0) reads out of bounds. `robustImageAccess` is off in `vk_main.cpp`, which makes that undefined. AMD returns 0 in practice, but other vendors may not. The SDK does the same thing, but D3D defines out-of-bounds loads as 0. A render-size R8 image cleared to 0 (or enabling robust image access for these pipelines) avoids it. The exposure binding is fine, since it only reads (0,0).
@ -0,0 +541,4 @@
Constants k{};
k.render_size[0] = rw, k.render_size[1] = rh;
k.upscale_size[0] = uw, k.upscale_size[1] = uh;
k.max_render_size[0] = rw, k.max_render_size[1] = rh;
hckrman101 (Migrated from github.com) commented 2026-11-09 23:29:39 +00:00

max_render_size is always the current render size, and prepare() rebuilds every image when rw/rh change. Each change resets the history, and each old set (two RGBA16F output-size images plus the rest, a few hundred MB at 4K) is kept for 120 frames, so a render size that keeps moving piles them up. The SDK's way is to allocate the render-size images at the maximum render size and pass render_size per frame (its shaders clamp UVs to RenderSize() / MaxRenderSize()). #1008's prepare(..., &in_w, &in_h, ...) is built for exactly that.

`max_render_size` is always the current render size, and `prepare()` rebuilds every image when `rw`/`rh` change. Each change resets the history, and each old set (two RGBA16F output-size images plus the rest, a few hundred MB at 4K) is kept for 120 frames, so a render size that keeps moving piles them up. The SDK's way is to allocate the render-size images at the maximum render size and pass `render_size` per frame (its shaders clamp UVs to `RenderSize()` / `MaxRenderSize()`). #1008's `prepare(..., &in_w, &in_h, ...)` is built for exactly that.
@ -0,0 +570,4 @@
else if (phases > g.jitter_phase_count) g.jitter_phase_count += 1.0f;
else if (phases < g.jitter_phase_count) g.jitter_phase_count -= 1.0f;
k.jitter_phase_count = g.jitter_phase_count;
k.delta_time = std::clamp(in.frame_time_ms / 1000.0f, 0.0f, 1.0f);
hckrman101 (Migrated from github.com) commented 2026-11-09 23:29:39 +00:00

Nit: frame_time_ms is never set by the caller, so this is always 16.7 ms. OoT runs at 20/30 fps, and the luma pyramid smooths exposure with exp(-DeltaTime()). #1008's FSR 4 engine measures it with steady_clock, and doing the same here would be easy.

Nit: `frame_time_ms` is never set by the caller, so this is always 16.7 ms. OoT runs at 20/30 fps, and the luma pyramid smooths exposure with `exp(-DeltaTime())`. #1008's FSR 4 engine measures it with `steady_clock`, and doing the same here would be easy.
hckrman101 (Migrated from github.com) commented 2026-11-09 23:29:39 +00:00

The row still has hi = 1. RX_UPSCALER=fsr3 sets 3 and the row shows On. When it's saved as upscaler=3, the next start clamps it to 1 (Auto) in the ini loop (min(max(v, lo), hi)), so a forced FSR 3.1 / FSR 4.1 doesn't persist the way the description says. This goes away anyway once the choice moves into #1008's upscaler_rows.h, where opts.upscaler already means GAME/DLSS/DLAA/FSR/FSR_AA.

The row still has `hi = 1`. `RX_UPSCALER=fsr3` sets 3 and the row shows `On`. When it's saved as `upscaler=3`, the next start clamps it to 1 (Auto) in the ini loop (`min(max(v, lo), hi)`), so a forced FSR 3.1 / FSR 4.1 doesn't persist the way the description says. This goes away anyway once the choice moves into #1008's `upscaler_rows.h`, where `opts.upscaler` already means GAME/DLSS/DLAA/FSR/FSR_AA.
NotRexed commented 2026-11-10 02:07:45 +00:00 (Migrated from github.com)

Superseded by #1386, which brings FSR 3.1 (upscaling and frame generation) onto the frame chain already on main from #841. This branch also carries #1008 and #1095 and conflicts with main in 10 files. Closing; thanks!

Superseded by #1386, which brings FSR 3.1 (upscaling and frame generation) onto the frame chain already on main from #841. This branch also carries #1008 and #1095 and conflicts with main in 10 files. Closing; thanks!
Commenting is not possible because the repository is archived.
No description provided.