Skip to content

feat(vk): FSR 3.1 as an engine of the temporal chain, with the characters' motion - #2305

Merged
hckrman101 merged 16 commits into
NotRexed:mainfrom
unixman69:feat/fsr3-temporal-chain
Nov 11, 2026
Merged

hckrman101 merged 16 commits into
NotRexed:mainfrom
unixman69:feat/fsr3-temporal-chain

Conversation

@unixman69

@unixman69 unixman69 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

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 feat(vk): AMD FSR 3.1 as the default Upscaler without matrix hardware #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

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

walking

Merge notes (from test merges against every open PR that touches these files; textual conflicts show up in git, semantic ones don't)

🤖 Generated with Claude Code

unixman69 and others added 3 commits November 11, 2026 11:06
…ters' motion

FSR 3.1 made its own inputs (vk_fsr3_inputs.cpp) with the camera's motion alone,
so everything that moves with the camera, Link above all, got the motion of what
is behind it and smeared while he walked. DLSS and FSR 4 already draw the skinned
draws' own motion on vk_temporal.cpp's chain.

FSR 3.1 is now a third engine of that chain (slot 2, kChainEngine in
vk_fsr3.cpp): the chain's colour, depth (reversed for FSR's passes where the
projection puts the near plane at 0: dlss_prep.comp flag bit 3), motion with the
actor pass drawn over it, its jitter and phase count, and the unjittered
projection for FSR's depth constants. The passes' picture is copied into the
chain's output and squared back by dlss_out.comp as before.

- A frame whose evaluation fails is left to the network for that frame only
  (TemporalEngine::retry), not for good: FSR 3.1's constants come from the
  frame's stream.
- Retired images are freed before each prepare on the chain too, and the
  engine's release lets FSR 3.1's images go when the chain stops drawing with it
  (before, they stayed after switching to another upscaler).
- RX_VK_FSR_JITTER=0 still turns its jitter off on the chain.
- A NaN in the scene colour is written as black (fsr3_prep.comp's rule).
- RX_VK_FSR3_CHAIN=0: its own inputs, as before.

Tests: temporal_jitter_test covers FSR 3.1's slot, its failure gate and
RX_VK_FSR_JITTER=0 on the chain (a new run, "fsr-jitter-off");
test_upscaler_rows checks that the row's FSR 3.1 runs on the chain before the
fallback to the game's own and that fsr3_wanted is off while it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With FSR 3.1 on the chain, the prep writes 1 - d into the chain's depth (bit 3,
for FSR's passes). Anything else that reads that frame's depth, such as frame
generation or other passes, cannot tell. TemporalFrame::depth_reversed now
says so, set from the same fsr_flip as the prep's bit, so readers can pass the
right depth convention on instead of assuming plain depth.

test_upscaler_rows checks that temporal_upscale sets it from fsr_flip wherever
it reverses the depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/fsr3-temporal-chain

# Conflicts:
#	docs/tested-configurations.md
unixman69 and others added 5 commits November 11, 2026 14:56
…/fsr3-temporal-chain

# Conflicts:
#	gpu/vk/vk_temporal.cpp
NotRexed#2118's DLSS Frame Generation (NGX) copies the chain's depth each frame and
told NGX it was not reversed. With FSR 3.1 on the chain the depth is 1 - d
(dlss_prep.comp bit 3), so generated frames under FSR 3.1 would have had
their occlusion backwards. The frame's depth_reversed now goes with the
copied inputs (Fg::depth_reversed) and into
NVSDK_NGX_DLSSG_Opt_Eval_Params::depthInverted. DLSS and FSR 4 are
unchanged (their depth is not reversed).

test_upscaler_rows checks both lines; it fails with the hard-coded false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/fsr3-temporal-chain

# Conflicts:
#	docs/development.md
#	docs/tested-configurations.md
#	gpu/vk/shaders/dlss_prep.comp
#	gpu/vk/shaders/dlss_prep_comp_spv.h
#	gpu/vk/vk_temporal.cpp
#	gpu/vk/vk_temporal.h

@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 clear improvement: FSR 3.1 finally gets the skinned draws' motion, and the motion-vector screenshot makes the case well. I went through the inputs and they line up with what FSR 3.1 got on its own path:

  • Motion: the chain's motion is in render pixels toward the frame before with the jitter taken off, the same convention as vk_fsr3_inputs.cpp, and chain_evaluate leaves motion_scale at 1, so motion_vector_scale is 1/render size as before. Low-res motion, no display-res mixup (FSR 3.1 interpolation's #1943 question doesn't apply here, since FG keeps its own inputs).
  • Jitter: same jitter_to_pixels, and the phase count comes from the same dlss_jitter_phases the chain publishes. RX_VK_FSR_JITTER=0 still works, the jitter gate has its own slot, and vk_fsr3_inputs' own jitter stays off while FSR 3.1 is on the chain (it is gated on fsr3_wanted), so there's no double jitter.
  • Depth: the flip uses camera_from_projection on the unjittered projection, the same test the own path makes. Motion is still computed from the unflipped d, actor_motion reads the game's depth view rather than s.depth, and depth_reversed reaches NGX frame generation.
  • History: cuts and gaps reset through the chain's existing reprojection/gap logic. A change of engine releases FSR's images, and prepare_sizes then returns 2, so the next frame resets. The retry path clears last_frame, so the frame after a failed evaluation starts a new history.
  • #2166 still works on the chain: in temporal_rejected_network, fsr3_upscale now returns false and temporal_wanted() is true for FSR3, so left-out networks go through temporal_upscale. (Nit: that path's log line will then say "the temporal upscaler" rather than FSR 3.1.)
  • #1654's LOD bias: upscale_lod_bias falls through to temporal_mode(), which now returns FSR3, so the bias is unchanged. Same for the menu's upscaler().
  • DLSS and FSR 4: unchanged apart from the NaN scrub in dlss_prep.comp (harmless, arguably a fix) and bit 3, which is never set for them.
  • FSR 3.1 interpolation (RX_VK_FSR_FG=1): it keeps vk_fsr3_inputs as it already does while DLSS upscales, so this is the same pairing as DLSS + FSR FG. Fine as untested, but a quick run would be nice.

What blocks merging right now:

  1. It conflicts with main. GitHub reports the PR as not mergeable. #2166 is already in your base, so that part is fine, but #2342 (FSR 4 native HDR, merged since) touches the same lines: dlss_prep.comp's colour store (max/kSquare/kColorMax) and its SPIR-V header, the pc.flags line and the prep/out pipeline selection in temporal_upscale, TemporalEngine (hdr after memory), TemporalFrame's comment, and the docs rows. Your merge notes for #2243 apply to #2342 almost as written:
    • In the shader, scrub the NaN before the max(e, 0). GLSL's max with a NaN operand is undefined.
    • Keep kChainEngine's designated order valid (.hdr sits before .fsr_depth).
    • Regenerate dlss_prep_comp_spv.h.
    • Check that bit 3 reaches prep_hdr too.
  2. CI hasn't run. Only the title check ran, because there is no merge ref while the PR conflicts. Please rebase so the build and tests/run_all.py run on Windows and Linux.

Smaller points:

  • #1653 (reactive mask, still open) attaches its mask in fsr3_upscale, which is now only the RX_VK_FSR3_CHAIN=0 path. Whichever of the two lands second needs to carry the mask through TemporalFrame into chain_evaluate, or FSR 3.1 loses it by default. Good that you flagged this in the merge notes.
  • Since this becomes the default for FSR 3.1, which is the upscaler for GPUs without DLSS/FSR 4 (and the Steam Deck default with #2110), one frame-time number on a weaker GPU or iGPU would help. The chain adds the actor-motion pass and a display-size copy. That costs nothing measurable on a 9070 XT, but low-end hardware is where FSR 3.1 is used.

The screenshots and numbers are there, thanks. The 67% flicker and outline numbers are convincing. Once it's rebased and CI is green I'd be happy to approve.

Comment thread gpu/vk/vk_fsr3.cpp Resolved
unixman69 and others added 3 commits November 11, 2026 15:27
…ut network's log names it

- vk_fsr3.cpp: fsr3::memory() adds up the internal images it made (history,
  luma, the RGBA16F output and the rest), and kChainEngine's memory reports
  it, so the chain's status line shows FSR 3.1's own memory instead of
  nothing (217 MB at 2560x1440 native, measured).
- vk_draw.cpp temporal_rejected_network: a left-out network drawn by FSR 3.1
  on the chain is logged as "FSR 3.1 (on the temporal chain)", not "the
  temporal upscaler".

Review notes on NotRexed#2305 (hckrman101).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@unixman69

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough read. Where each point stands:

unixman69 and others added 5 commits November 11, 2026 16:08
…/fsr3-temporal-chain

# Conflicts:
#	docs/tested-configurations.md
… mask

NotRexed#1653 attached its reactive mask in fsr3_upscale, which with this PR runs
only with RX_VK_FSR3_CHAIN=0: by default FSR 3.1 drew on the chain without
it while RX_VK_REACTIVE=1 still copied the scene every frame. chain_evaluate
now makes the mask the same way DLSS's evaluation does on this chain (the
chain's render size and input image) and hands it to FSR's passes as
Inputs::reactive; without one, FSR's default reactivity, as before.

test_reactive_mask.py checks that chain_evaluate makes the mask before
fsr3::upscale; it fails without the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/fsr3-temporal-chain

# Conflicts:
#	docs/tested-configurations.md

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every earlier blocker is handled: #2342 merged in, NaN scrubbed before max, kChainEngine order valid, SPIR-V header regenerated, bit 3 reaching both prep pipelines, and #1653's reactive mask carried into chain_evaluate. The retry path drops one frame, and projection failures are filtered before evaluate, so it doesn't spin. The 11 temporal/FSR/DLSS checks pass locally.

If you have one, a Steam Deck or iGPU frame-time number would help, since FSR 3.1 is the low-end upscaler and #2110 would make it the default.

@hckrman101
hckrman101 merged commit 579c6d4 into NotRexed:main Nov 11, 2026
notquiteog added a commit to notquiteog/Hookshot that referenced this pull request Nov 11, 2026
Upstream's 30 commits since the last merge. AMD FSR 3.1 is an engine of
the temporal chain now; vk_fsr3_inputs.cpp's own frame is still made for
what is not on the chain (FSR 3.1's frame interpolation, AMD FSR 4's
frame generation, the open neural rendering network's motion).

The fork's side:
- the Frame generation by row takes a value asked outright (the control
  bridge) where it runs here, not only the next one from the arrows
  (framegen_methods.h pick);
- menu_init's moves of two older fork rows are a function of their own
  (fork_options_moved), which the checks that cut menu_init can stub;
- the readme's fork sections sit after the menu's own, under one heading.

Run in the game again on this tree (RTX 5060 Ti, Linux): FSR 3.1 on the
chain, DLSS Frame Generation from FSR 3.1's inputs (600 of 600 sets) and
from DLSS's at 3x (600 of 600), AMD FSR 3.1's interpolation with either
upscaler (600 of 600), and the open neural rendering network at 50%
before DLSS Frame Generation (600 of 600).

Tests: 427 passed, 20 skipped (Windows-only or missing tools).

Co-Authored-By: Claude Fable 5.2 <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.

3 participants