Skip to content

feat(vk): reactive mask for DLSS and FSR 3.1 from the scene's transpa… - #1653

Merged
hckrman101 merged 4 commits into
NotRexed:mainfrom
TwoToneEddy:upscale-reactive-mask
Nov 11, 2026
Merged

hckrman101 merged 4 commits into
NotRexed:mainfrom
TwoToneEddy:upscale-reactive-mask

Conversation

@TwoToneEddy

Copy link
Copy Markdown
Contributor

…rent draws (RX_VK_REACTIVE=1)

The temporal upscalers follow a pixel by its depth and motion. What the scene blends over itself without writing depth (water, smoke, fire, glows) has neither, so it is taken for the surface behind and smears or ghosts.

With RX_VK_REACTIVE=1 the scene's colour is copied before its transparent draws (blended, depth tested, depth not written: reactive_policy.h), and at the upscale the scene's target is compared with that copy (shaders/reactive_mask.comp). The difference goes to FSR 3.1's reactivity pass as its reactive mask and to DLSS as its bias-current-colour mask.

Off by default: which draws are transparent is told from their state alone and has not been checked in the game. RX_VK_REACTIVE_DEBUG=1 shows the mask.

…rent draws (RX_VK_REACTIVE=1)

The temporal upscalers follow a pixel by its depth and motion. What the scene
blends over itself without writing depth (water, smoke, fire, glows) has
neither, so it is taken for the surface behind and smears or ghosts.

With RX_VK_REACTIVE=1 the scene's colour is copied before its transparent
draws (blended, depth tested, depth not written: reactive_policy.h), and at
the upscale the scene's target is compared with that copy
(shaders/reactive_mask.comp). The difference goes to FSR 3.1's reactivity
pass as its reactive mask and to DLSS as its bias-current-colour mask.

Off by default: which draws are transparent is told from their state alone
and has not been checked in the game. RX_VK_REACTIVE_DEBUG=1 shows the mask.

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

@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, the idea is sound and the policy/copy/compare split is easy to follow. The reactive_policy.h unit test is a good touch. I read the change through; I haven't run it, and it's off by default so the risk is contained. One real blocker:

This is based on a pre-#1008 main and no longer merges (mergeable_state: dirty). The upscaler code the hooks attach to has moved:

  • dlss_jitter_size() no longer exists on main (the DLSS side now lives in the TemporalEngine: temporal_jitter_size in vk_temporal.h, and temporal_scene_size() / temporal_note_scene_draw() in vk_draw.cpp). reactive_scene_draw() calls dlss_jitter_size(0/1), so it won't compile after a rebase as is. It probably wants temporal_scene_size(at.width, at.height) (or temporal_jitter_size) for the DLSS/FSR 4 side, keeping fsr3_inputs::jitter_size for FSR 3.1.
  • DLSS evaluation is now evaluate(cmd, const TemporalFrame& f) in vk_dlss.cpp (ep.InReset = f.reset...); the dlss_upscale(... s.color.view, s.feature_rw ...) hunk where you set pInBiasCurrentColorMask has nothing to apply to. The mask call needs to go into that function using the TemporalFrame's colour view and size.
  • vk_fsr3.cpp and the in.reactive swap into P_PREPARE_REACTIVITY look like they still apply (main still passes tex(g.default_reactive) there).

Also worth deciding while you rebase: with FSR 4 now going through the same TemporalEngine, does it have a reactive input you'd want to feed too, or should the doc say explicitly DLSS + FSR 3.1 only? (#1654 is open in the same area, so a quick look at how it lands is worthwhile.)

Things I checked that look fine: copy/mask recorded outside a pass (end_pass before the copy), full_barrier around the copy and the dispatch, descriptors_disturbed() after the push-descriptor pass, destroy-generation check before touching the scene texture, deferred freeing of replaced images, constant-initialised thread_locals (no llvm-mingw TLS-init cost), tests picked up by the run_all glob, R8 storage-image check in wanted().

Nit: the mask is the whole difference between the copy and the final scene target, so a blended no-depth-test draw that comes after the last transparent run (a full-screen fade, say) will also light the mask. You say it's unverified in-game, so fine for an opt-in switch, just worth a line in the doc once you've looked at a few scenes with RX_VK_REACTIVE_DEBUG=1.

CI: no checks reported on the branch, so a rebase will also give it a run.

On the frame chain (NotRexed#1008): the scene is found by temporal_jitter_size, and
DLSS's engine makes the mask in its evaluate from the TemporalFrame's colour
and sizes. DLSS and FSR 3.1 only: AMD FSR 4 is given no mask, and nothing is
copied while it draws. The doc says so, and that the mask is the whole
difference since the copy. tests/test_reactive_mask.py is given run_all.py's
compiler.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@hckrman101

Copy link
Copy Markdown
Collaborator

Heads-up: this conflicts with main now (a lot landed today). Could you merge main into the branch (or rebase) and resolve the conflicts? Thanks!

tests/run_all.py's ARGS table is retired on main: tests/test_reactive_mask.py
now declares its compiler itself (RUN_ALL_ARGS).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@TwoToneEddy

TwoToneEddy commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Done: main is merged in again and the conflicts are resolved. Each point from your review is answered below.

dlss_jitter_size() no longer exists on main […] reactive_scene_draw() calls dlss_jitter_size(0/1), so it won't compile after a rebase as is.

Fixed. reactive_scene_draw() now uses temporal_jitter_size for the chain, with fsr3_inputs::jitter_size kept for FSR 3.1.

DLSS evaluation is now evaluate(cmd, const TemporalFrame& f) in vk_dlss.cpp […] The mask call needs to go into that function using the TemporalFrame's colour view and size.

Done. The mask is made in evaluate from the frame's colour view and sizes, and set as pInBiasCurrentColorMask as before.

vk_fsr3.cpp and the in.reactive swap into P_PREPARE_REACTIVITY look like they still apply.

They did; those hunks are unchanged.

does [FSR 4] have a reactive input you'd want to feed too, or should the doc say explicitly DLSS + FSR 3.1 only?

DLSS + FSR 3.1 only for now, and the doc says so. FSR 4 is given no mask, and no scene copy is made while it draws. I'd add its reactive input as a follow-up once this has been looked at in-game.

a blended no-depth-test draw that comes after the last transparent run (a full-screen fade, say) will also light the mask.

Added a line to the doc saying so.

Also

  • The test declares RUN_ALL_ARGS = ["--cxx", "{cxx}"] itself, since the central ARGS table is retired on main.

Checked

@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 for the quick turnaround. All the points from my last review are covered:

  • dlss_jitter_size is gone: reactive_scene_draw() uses temporal_jitter_size for the frame chain and keeps fsr3_inputs::jitter_size for FSR 3.1. Fixed.
  • The DLSS mask is now made in evaluate(cmd, const TemporalFrame&) from the frame's colour view and sizes. Fixed.
  • FSR 4 gets no mask and no scene copy while it draws (is_fsr4(temporal_mode()), and that enum matches upscaler_rows). The doc says DLSS + FSR 3.1 only. Answered; a follow-up for FSR 4 sounds right.
  • The doc now mentions the full-screen-fade case. Done.
  • The test declares RUN_ALL_ARGS itself, which matches main since #1822.

Still blocking: it conflicts with main again (mergeable_state: dirty). A lot landed after your 18:15 merge, and the overlaps with this PR are gpu/vk/vk_dlss.cpp, gpu/vk/vk_draw.cpp, gpu/vk/vk_fsr3.cpp and docs/development.md. One of them is more than a textual conflict:

  • The recorder (#1473, RX_VK_RECORDER) put ::mkwvk::rec::boundary(::mkwvk::rec::B_DIRECT); in evaluate() right before NGX_VULKAN_EVALUATE_DLSS_EXT, at exactly the line where your mask block goes. When you resolve it, the reactive::mask(...) call has to come before that boundary. mask() records its barrier, bind, push and dispatch through vk_work.h, so with RX_VK_RECORDER=defer/thread those packets are only flushed into cmd by the boundary. If the mask ends up after it, NGX would read the mask before the dispatch that writes it reaches the command buffer. See the inline comment.
  • vk_reactive.cpp already includes vk_work.h last, so its own commands go through the recorder correctly. Nothing to change there.
  • docs/development.md: main added the RX_VK_COMPACT row between RX_VK_VRAM_MB and RX_VK_NO_WRITEBACK, right where your row goes. Keep both.
  • vk_draw.cpp / vk_fsr3.cpp: everything your hunks use (barrier_every, clear_after_draws, default_reactive in P_PREPARE_REACTIVITY, ai.reset = f.reset) is still on main, so those should resolve mechanically.

CI: still no checks reported on the branch. A clean merge should give it a run.

Once it's merged and the mask sits before the B_DIRECT boundary, I'm happy with this as an opt-in switch.

Comment thread gpu/vk/vk_dlss.cpp
// DLSS's mask of where to lean on this frame's colour, as large as the chain's inputs. Without one the parameter stays
// null, as before)
NVSDK_NGX_Resource_VK bias{};
if (reactive::Mask m; reactive::mask(cmd, f.render_w, f.render_h, f.color.width, f.color.height, f.color.view, m)) {

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.

Heads-up for the merge: main now has ::mkwvk::rec::boundary(::mkwvk::rec::B_DIRECT); just before NGX_VULKAN_EVALUATE_DLSS_EXT here (the recorder, #1473). This block needs to stay above that line. reactive::mask records through vk_work.h, and under RX_VK_RECORDER=defer/thread it only reaches cmd at the boundary, so NGX has to come after it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks. Main is merged in again and the conflicts are resolved.

  • The mask block in evaluate() now sits directly above rec::boundary(B_DIRECT), so its dispatch reaches cmd before NGX reads the mask.
  • docs/development.md keeps both the RX_VK_COMPACT row and this one.
  • vk_draw.cpp and vk_fsr3.cpp merged without a conflict, as you expected.

tests/run_all.py fails only where main does on my machine. I couldn't compile vk_dlss.cpp this time (no NGX headers here); the resolution there only keeps the existing block above main's new line. Still not run in-game.

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.

Confirmed: the mask block is right above the B_DIRECT boundary on the new head. Thanks!

Conflicts: docs/development.md (main's RX_VK_COMPACT row and this branch's
RX_VK_REACTIVE row both kept) and gpu/vk/vk_dlss.cpp, where the mask is made
before the recorder's B_DIRECT boundary (NotRexed#1473): reactive::mask records
through vk_work.h, so under RX_VK_RECORDER=defer/thread its dispatch only
reaches cmd at that boundary, and NGX has to read the mask after it.

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

@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, the merge looks right. I compared the PR's patches before and after it file by file: apart from the merge context nothing in your hunks changed.

  • The mask block in evaluate() now sits directly above ::mkwvk::rec::boundary(::mkwvk::rec::B_DIRECT), so under RX_VK_RECORDER=defer/thread the mask's dispatch reaches cmd before NGX reads it. Fixed.
  • docs/development.md keeps both the RX_VK_COMPACT row and yours. Fixed.
  • vk_draw.cpp / vk_fsr3.cpp: unchanged hunks, and fsr3_upscale on main still builds ai from the same fsr3_inputs::Frame (f.color, f.width/height) after #2147. With the new Upscaler Off choice (#2097) both jitter sizes are 0, so reactive_scene_draw() copies nothing there. Fine.
  • It merges cleanly now.

Heads-up for later, not for this PR: #2305 moves FSR 3.1 onto the temporal chain (chain_evaluate in vk_fsr3.cpp). If it lands, reactive_scene_draw() will see temporal_jitter_size != 0 with a non-FSR 4 mode and make the copy, but the chain's FSR 3.1 path doesn't call reactive::mask, so the copy goes unused. Whichever of the two merges second should add the reactive::mask(...) → ai.reactive line to chain_evaluate. #1654 touches the same files but you already checked that the two merge cleanly together.

CI: the build and test workflows on this head are waiting for a maintainer to approve them (action_required), so nothing has actually built yet, vk_dlss.cpp included. Only the PR title check ran. It's off by default and the change since my last review is a placement-only move, so I'm approving. Someone with access should still approve the runs and look at them before merging.

@hckrman101
hckrman101 merged commit 5cbfead into NotRexed:main Nov 11, 2026
1 check passed
unixman69 added a commit to unixman69/Hookshot that referenced this pull request Nov 11, 2026
… 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>
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