Skip to content

fix(vk): FSR 3.1 draws the fixed Render resolution steps where the game's networks are left out - #2166

Merged
hckrman101 merged 1 commit into
NotRexed:mainfrom
rakefire-impala:fix/fsr3-fixed-steps
Nov 11, 2026
Merged

hckrman101 merged 1 commit into
NotRexed:mainfrom
rakefire-impala:fix/fsr3-fixed-steps

Conversation

@rakefire-impala

@rakefire-impala rakefire-impala commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On a GPU that leaves the game's cooperative-matrix networks out, FSR 3.1's own Render resolution steps never reach FSR 3.1. That covers any GPU that isn't NVIDIA's, and NVIDIA's that multiply in software. With FSR 3.1 chosen, Quality, Balanced, Performance and Ultra Performance draw the 3D as a plain stretch, with no FSR 3.1 and no anti-aliasing, while the menu still says FSR 3.1. #1829 reports it on an RX 9060 XT ("FSR 3.1 looks noticeably worse. I don't think the upscaler is working"), and I see the same on an RX 5500M.

OoT draws its 3D at a share of the picture and scales it up with a network of its own for each share. In the logs, with the picture at 1536x864:

share network its input
full 9dc98871eb7fc101 1536x864
3/4 fd029a317a9a4b94 1152x648
2/3 (Quality) 6676619641320ba7 1024x576
7/12 (Balanced) f10782f9e58b6a7c 896x504
1/2 (Performance) 72ba090c70fe2614 768x432
1/3 (Ultra Performance) 0775215f8da720e6 512x288

On these GPUs only the first two are kept by default (vk_shader.cpp, RX_VK_NETWORK_KEEP). The others don't translate ("a network left out on this card"), and their dispatch goes to upscale_stub. The stub already offers such a network to the temporal upscaler (DLSS, FSR 4) through temporal_rejected_network, but FSR 3.1 has a path of its own (vk_fsr3.cpp). With FSR 3.1 chosen, temporal_wanted() is false, so the network was never offered to it and the stub stretched the scene. The only way FSR 3.1 got used on these cards was Render resolution Auto, while the game's dynamic resolution sat at 3/4 or full.

Change

  • temporal_rejected_network offers the network to FSR 3.1 first (fsr3_upscale, as a network it doesn't know), then to the temporal upscaler. That's the same order as fsr3_stand_in before temporal_stand_in for the networks that do translate.
  • upscale_stub calls it when FSR 3.1 is asked for too (fsr3_inputs::wanted()).

fsr3_upscale already takes a network it doesn't know only when the input is this frame's scene and the output is the picture (B10G11R11, larger than the input), so anything else, the movies for example, is stretched as before. With FSR 3.1's frame interpolation on and another upscaler drawing, the same call now also makes the interpolation's inputs from these frames, as fsr3_stand_in does for the networks that translate.

Before and after

RX 5500M (RDNA1, 4 GB), Windows 11, AMD driver, Upscaler FSR 3.1. Counts come from the log: rejected compute <network> when a share's network is stretched, and fsr3: N frames drawn every 600 frames FSR 3.1 draws.

Render resolution main this PR
Quality (2/3) stretched (by the code; rejected compute 6676619641320ba7 in a session at Auto) FSR 3.1 draws it: network 6676619641320ba7 does not translate here: FSR 3.1 draws its upscale (1024x576 -> 1536x864), then 16,200 frames over about ten minutes of play, jitter over 18 phases
Balanced (7/12) stretched (by the code; rejected compute f10782f9e58b6a7c in a session at Auto) FSR 3.1 draws it: network f10782f9e58b6a7c does not translate here: FSR 3.1 draws its upscale (896x504 -> 1536x864) (a short check)
Performance (1/2) stretched: about 18,700 game frames, 0 drawn by FSR 3.1 (rejected compute 72ba090c70fe2614 ... 768x432 ... 1536x864) FSR 3.1 draws it: fsr3: 16800 frames drawn, 768x432 -> 1536x864, this one's jitter +0.125 -0.278 px of 32 phases
Ultra Performance (1/3) stretched: at most 600 of about 7,350 game frames drawn by FSR 3.1 still stretched: no FSR 3.1 frames at 512x288 over a minute (see below)

The #1829 reporter's log (RX 9060 XT, v2.0.14) has the same 72ba090c70fe2614 not translated: a network left out on this card and rejected compute 72ba090c....

A note on jitter, since the logs above show jitter -0.000 +0.000 px on FSR 3.1's first frame: FSR 3.1 publishes its jitter once it has drawn (fsr3_inputs::upscaled), and the game's projection is jittered from the next frame on. Zero on the first frame is expected. The frames drawn lines show it moving afterwards.

Not fixed here: Ultra Performance

At 1/3 the game uses 0775215f8da720e6 (512x288 into 1536x864). Its bindings hold a second 1536x864 image besides the picture, which the other shares' networks don't have. FSR 3.1 doesn't take it in this test: no fsr3 line at 512x288 and no FSR 3.1 draws its upscale line for that network. fsr3_upscale declines a network it doesn't know without logging why (the input not this frame's scene, formats, sizes), so I'll find out which check that is and send it separately. The other three steps don't depend on it.

Tests

  • tests/test_upscaler_rows.py: upscale_stub offers a network that didn't translate when FSR 3.1 is asked for, and temporal_rejected_network offers it to FSR 3.1 before the temporal upscaler. This fails on current main.
  • tests/test_upscale_fallback.py: a stub for fsr3_inputs::wanted in its harness.
  • tests/run_all.py on main 0a28ff29 with this change merged: 310 passed. The 6 that fail are the same 6 that fail on plain main on this PC.

Seen while testing, not from this PR

On this card, a bright dotted line along distant mountain silhouettes gets stronger toward sunset. It shows the same with Game's own at 100% (no upscaling at all) as with FSR 3.1 at Quality, and Sharpness and the Window scaling filter don't change it, so it isn't from FSR 3.1 or this change. The build tested here is main 92907f7, from before #1657, which changes how quad operations in fragment shaders are handled on RDNA1. That may be it, but I haven't checked yet.

Related: #1829, #1006 (AMD: no anti-aliasing, networks in software).

…me's networks are left out

On a GPU that leaves the game's cooperative-matrix networks out (any GPU but
NVIDIA's, and NVIDIA's that multiply in software), each fixed share of the
Render resolution row has a network that does not translate: 67%, 58%, 50%
(72ba090c70fe2614) and 33%. Its dispatch goes to upscale_stub, which offered it
to DLSS and FSR 4 (temporal_rejected_network) but never to FSR 3.1: with FSR 3.1
chosen, Quality, Balanced, Performance and Ultra Performance were a plain
stretch of the 3D, no FSR 3.1 and no anti-aliasing, while the menu said FSR 3.1
(issue NotRexed#1829: RX 9060 XT; also seen on an RX 5500M at Performance: 768x432
stretched, not one fsr3 line in the log).

temporal_rejected_network now offers the network to FSR 3.1 first, as
fsr3_stand_in comes before temporal_stand_in for the networks that translate:
its upscale, or its frame interpolation's inputs while another upscaler draws.
upscale_stub calls it when FSR 3.1 is asked for too. fsr3_upscale already takes
a network it does not know only when its input is this frame's scene and its
output the picture, so anything else is stretched as before.

tests/test_upscaler_rows.py: upscale_stub offers it with FSR 3.1 asked for, and
FSR 3.1 is offered before the temporal upscaler (fails on main).
tests/test_upscale_fallback.py: a stub for fsr3_inputs::wanted.

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a clean fix and the write-up makes it easy to follow.

I went through the path at 5bbfed6: upscale_stub now also calls temporal_rejected_network when fsr3_inputs::wanted(), and that offers the network to fsr3_upscale(..., known=false, ...) before temporal_upscale. That's the same order as fsr3_stand_in before temporal_stand_in, and the unknown-network guards in fsr3_upscale (B10G11R11 in and out, output larger than input, is_scene(in)) keep the movies and anything else on the stretch. With frame interpolation on and DLSS/FSR 4 drawing, fsr3_upscale makes the FI inputs and returns false, so temporal_upscale still draws, as on the translated path. With only FI wanted and no upscaler, it falls through to the stretch like before. The new (temporal_wanted() || temporal_off()) guard inside temporal_rejected_network keeps temporal_upscale from running when only FSR 3.1 brought us in. The new check in test_upscaler_rows.py (FSR 3.1 offered before the temporal upscaler, on the definition via rindex) is a good one. Leaving Ultra Performance for a follow-up is fine.

CI: the only red job is Windows Python part 1/2, and that's tests/test_recorder_sync.py (93 s), which this PR doesn't touch. Everything else passes, including the llvm-mingw build.

Overlap: #2305 renames fsr3_wanted to fsr3_chosen and, by default, sends FSR 3.1 through the temporal chain. After both land, the left-out networks reach FSR 3.1 through temporal_upscale anyway, and this path matters for RX_VK_FSR3_CHAIN=0 and the FI inputs. Both PRs edit test_upscaler_rows.py, in different hunks, so whichever lands second may need a quick rebase. #2193 (merged) and #1943 don't touch these lines.

Two comment nits inline. Not blocking.

Comment thread gpu/vk/vk_draw.cpp
// GPU without cooperative matrices they are left out): the temporal upscaler is offered it first, as temporal_stand_in
// offers the networks that did translate. It takes only the scene into the picture; anything else goes on below
if ((temporal_wanted() || temporal_off()) && temporal_rejected_network(cmd, key, src, &in, dst, &out)) return true;
// offers the networks that did translate, and so is FSR 3.1, as fsr3_stand_in. It takes only the scene into the picture;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this still reads as if the temporal upscaler goes first and FSR 3.1 after it, but temporal_rejected_network now tries FSR 3.1 first. Maybe "FSR 3.1 is offered it first, as fsr3_stand_in, then the temporal upscaler, as temporal_stand_in".

Comment thread gpu/vk/vk_draw.cpp
/// texture it samples, and its output, the largest image it writes): the Render resolution row's fixed shares (67%
/// 6676619641320ba7, 58%, 50%, 33%) are cooperative-matrix networks, left out without cooperative matrices (AMD RDNA 2), so
/// without this DLSS and FSR would only ever see the game's 3/4. True: the temporal upscaler wrote the output
/// 6676619641320ba7, 58%, 50% 72ba090c70fe2614, 33%) are cooperative-matrix networks, left out on a GPU that is not NVIDIA's or multiplies in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the wrap went odd here: this line is ~140 columns, and the next is a short software (vk_shader.cpp, RX_VK_NETWORK_KEEP), so. Worth rewrapping the three lines.

@hckrman101
hckrman101 merged commit ebb2172 into NotRexed:main Nov 11, 2026
12 of 13 checks passed
hckrman101 pushed a commit that referenced this pull request Nov 11, 2026
…ters' motion (#2305)

AMD FSR 3.1 makes its own inputs (`vk_fsr3_inputs.cpp`), and the only
motion it gets is the camera's. So anything that
moves differently from the camera is given the motion of what's behind
it: Link while he walks, his swinging arm, his
shield, and the people around him. DLSS and FSR 4 already get those
characters' own motion on `vk_temporal.cpp`'s chain.
This PR puts FSR 3.1 on that chain too, as a third engine, so all three
temporal upscalers share the same inputs.

**What changed**
- `vk_fsr3.cpp/.h`: `kChainEngine`, FSR 3.1 as a `TemporalEngine` (slot
2).
- It takes the chain's colour, depth, motion (with the actor pass drawn
over it), jitter and jitter phase count.
  - It builds FSR's depth constants from the unjittered projection.
- Its picture is copied into the chain's output and squared back by
`dlss_out.comp` as before.
- `fsr3_chosen()` is "the row or `RX_VK_FSR=1` picks FSR 3.1".
`fsr3_wanted()` now means "FSR 3.1 on its own inputs",
    which is false while it's on the chain.
- `fsr3::prepare_sizes` and `fsr3::release` let the chain size and free
FSR's images.
- `vk_temporal.cpp/.h`:
- `temporal_mode` returns `FSR3` for the row's FSR 3.1 before it falls
back to the game's own; `engine_of` gets a third
    slot.
  - Two new `TemporalEngine` fields:
- `fsr_depth`: the chain reverses depth for FSR's passes (built with
`INVERTED_DEPTH`) where the projection puts the
      near plane at 0.
- `retry`: a failed evaluation hands that frame to the network instead
of turning the engine off for good. FSR 3.1's
constants come from the frame's stream, which can run out for one frame.
- `TemporalFrame` carries the unjittered projection and the jitter phase
count.
  - The chain's output image gets `TRANSFER_DST`.
  - `RX_VK_FSR_JITTER=0` still turns FSR 3.1's jitter off on the chain.
- `shaders/dlss_prep.comp`:
  - Flag bit 3 reverses depth (`1 - d`).
- A NaN in the scene colour is written as black, following
`fsr3_prep.comp`'s rule, because one NaN spreads through an
    upscaler's history.
- The header was regenerated with `glslangValidator -V --target-env
vulkan1.3 --vn DLSS_PREP_COMP_SPV`, the command
    `test_dlss_actor.py` checks.
- `RX_VK_FSR3_CHAIN=0` gives FSR 3.1 its own inputs, as before, for
comparisons (`docs/development.md`). The readme's FSR 3.1
section describes the new inputs, and says what still trails: things
that aren't skinned, such as Navi's body.
- Tests:
- `temporal_jitter_test.cpp` covers FSR 3.1's slot and its failure gate,
and a new run (`fsr-jitter-off`) where only
`RX_VK_FSR_JITTER=0` is set: FSR 3.1 on the chain gets no jitter, and
DLSS and FSR 4 keep theirs.
- `test_upscaler_rows.py` checks that the row's FSR 3.1 runs on the
chain before the fallback to the game's own, that
`fsr3_wanted` is off while it does, and that `engine_of` has its slot.
  - Both fail on main. A mutated jitter gate fails `fsr-jitter-off`.

**Testing**
- `python -I -B tests/run_all.py`: 363 passed, 0 failed, 11 skipped
(Linux; the skips are Windows-only, need a Vulkan 1.3
  device, or need dotnet).
- In game: RX 9070 XT 16 GB, Mesa RADV 26.2.6, Gentoo Linux (kernel
7.3.3), KDE Plasma Wayland, windowed, 2560x1440
  output, 60 fps cap.
- Host v2.1.0-main-local (source fc2ed0d0). Renderer: main 49f75dc plus
this branch.
- "Own" means the same build with `RX_VK_FSR3_CHAIN=0`, which is what
main does.
- Every chain run logged `vk: fsr3 on the chain: 600 frames drawn ...
depth reversed for FSR by the chain`, with no
    evaluation failures.

| FSR 3.1 | Kokiri Forest, 28 s camera turn (4 runs each) | Castle Town
market at noon (3 runs each) |
  |---|---|---|
  | Own inputs (as main) | 59.2 fps, 1% low 46.4 | 53.1 fps |
  | On the chain | 59.3 fps, 1% low 48.1 | 52.5 fps |

Market runs vary by about 0.9 fps between runs, so the chain costs
nothing measurable.
- Picture: consecutive 2560x1440 shots at Kokiri's lost-woods path,
standing and walking, compared on the same build.
Edge flicker is the mean frame-to-frame change of edge pixels, 0-255,
while standing.

| FSR 3.1 | Edge flicker, standing | 1-px outlines per MP | Halos per MP
|
  |---|---|---|---|
  | 100%, own | 2.17 | 32 | 45 |
  | 100%, chain | 2.20 | 29 | 50 |
  | Quality (67%), own | 3.01 | 12 | 25 |
  | Quality (67%), chain | 1.67 | 2 | 9 |

Four independent reviewers of the walking sheets saw no visible
difference at 100%. At 67%, they found the chain
slightly better: smoother edges on Link's shield and tunic hem, and
slightly softer shield texture. No new artefacts.
The clearest evidence is the motion field itself (below). On main, Link
isn't in it at all.

**Not tested:**
- other GPUs and drivers;
- Windows;
- Steam Deck, where #2110 would make FSR 3.1 the default;
- frame interpolation (`RX_VK_FSR_FG=1`), which keeps using
`vk_fsr3_inputs.cpp`;
- switching upscalers repeatedly while playing (release and remake are
code-reviewed, not exercised in game);
- HDR: the chain still clamps FSR 3.1's colour at the paper white, as
main does (see below).

**Screenshots** (from the game's output image before the present path,
2560x1440)

The motion vectors the upscaler gets while Link walks
(`RX_VK_DLSS_DEBUG=motion`). Left: camera motion only
(`RX_VK_DLSS_ACTORS=0`, which is what FSR 3.1 got on main). Right: on
the chain, with Link's body, arm and shield carrying
their own motion:

![motion
vectors](https://raw.githubusercontent.com/unixman69/Hookshot/pr-screenshots/fsr3-chain/motion-vectors.png)

Link walking: own inputs vs the chain, at 100% and at 67% (2x zoom
crops):


![walking](https://raw.githubusercontent.com/unixman69/Hookshot/pr-screenshots/fsr3-chain/link-walking-own-vs-chain.png)

**Merge notes** (from test merges against every open PR that touches
these files; textual conflicts show up in git, semantic ones don't)
- **Depth is reversed for FSR 3.1 on the chain.**
`TemporalFrame::depth_reversed` says so. Anything that reads the chain's
  depth must check it rather than assume plain depth:
- #2118 (DLSS frame generation through NGX, now on main): done in this
PR. Commit ca2e302 passes `depth_reversed` to
    NGX's `depthInverted`, and `test_upscaler_rows` checks it.
- #1944 (DLSS frame generation through Streamline) needs the same:
`depthInverted` from `depth_reversed`, not a
    hard-coded false.
- #1831/#1833: `nr_pre` passes `s.depth` on, and the NGX backend's
`DepthInverted` should follow it too.
- **#2145** adds `bool native_aa` to `TemporalEngine::prepare`.
`chain_prepare` in `vk_fsr3.cpp` needs the same
parameter, or the build breaks. git merges the two cleanly. FSR 3.1
never has native AA.
- **#1653** (reactive mask) is on main now and merged into this branch.
Commit 7ceae45 makes the mask in `chain_evaluate`
the same way DLSS does on the chain, and `test_reactive_mask.py` checks
it. Checked in game with `RX_VK_REACTIVE=1`: the mask is
  made, and FSR 3.1 draws all 600 frames on the chain.
- **#2243** (DLSS HDR, linear light) is on main now (in #2342's squash,
a0dfd28) and is merged into this branch.
- `dlss_prep.comp` scrubs NaN first, then applies #2243's
`max`/`kSquare`/`min`, and keeps the `flags & 8` depth flip.
The header was regenerated; glslang 16.4.0 gives the same SPIR-V words
as CI's 16.6.0 for `dlss_prep` and `dlss_out`.
- `TemporalEngine` is ordered `memory`, `hdr`, `fsr_depth`, `retry`:
DLSS's and FSR 4's engines are positional,
    and `kChainEngine` is designated.
- FSR 3.1 doesn't report `hdr` yet, so it keeps the LDR pair. Giving it
HDR colour on #2243's mechanism is a follow-up.
- **#1831**:
- It reads `fsr3_wanted()` to ask whether FSR 3.1 is chosen. After this
PR that question is `fsr3_chosen()`. Its
`fsr4 = !fsr3_wanted() && fsr4_wanted()` guard and its jitter guard
should use `fsr3_chosen()`. The merge is
    textually clean, so git won't flag it.
- In `vk_temporal.cpp`, the output image's usage should become
`TRANSFER_DST | TRANSFER_SRC`, and its `proj[16]` and
    this PR's `projection[16]` should become one field.
- **#2166** only matters with `RX_VK_FSR3_CHAIN=0`.
- `docs/tested-configurations.md` and `docs/development.md`: rows next
to other PRs' rows; keep both.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants