Skip to content

feat(fsr4): FSR 4 for the game's native HDR, given linear light - #2342

Merged
hckrman101 merged 7 commits into
NotRexed:mainfrom
unixman69:feat/fsr4-native-hdr
Nov 11, 2026
Merged

hckrman101 merged 7 commits into
NotRexed:mainfrom
unixman69:feat/fsr4-native-hdr

Conversation

@unixman69

Copy link
Copy Markdown
Contributor

With HDR on Native and AMD FSR 4 as the upscaler, every highlight is cut at the paper white. #2243 gives DLSS the game's HDR colour as linear light, but FSR 4 keeps its LDR context and the chain's clamped colour ("FSR 4 has none and keeps the LDR pair"). This PR gives FSR 4 the same treatment, on #2243's mechanism.

Builds on #2243 (which builds on #1942 and #1941). This PR is the last commit, 6b55c5b. Review and merge it after those.

What changed

  • vk_fsr4.cpp: while present_native_pq says the game presents PQ, FSR 4's context is made with FFX_UPSCALE_ENABLE_HIGH_DYNAMIC_RANGE | FFX_UPSCALE_ENABLE_AUTO_EXPOSURE (linear colour, AMD's own exposure) instead of FFX_UPSCALE_ENABLE_NON_LINEAR_COLORSPACE.
  • RX_VK_FSR4_HDR=0 keeps the LDR context and the clamped colour with Native too, for comparisons (docs/development.md, the readme's FSR 4 section).
  • Tests: test_hdr_encode.py's new check_fsr4 checks:
  • docs/tested-configurations.md: the run below.

Testing

Not tested:

  • Windows with AMD's signed FSR SDK 2.4 DLLs (FSR 4.1.2). They get the same creation and dispatch flags; only skipping the exposure image is Linux-specific. A Windows RX 7000/9000 run would be welcome before merging.
  • FSR 4's upscaling modes (render size below the output) in HDR.
  • Turning HDR on Native on or off mid-run, which makes the context again.
  • Scenes other than Kokiri Forest.

🤖 Generated with Claude Code

j64esatxzdrhfg and others added 4 commits November 11, 2026 11:12
Players asked for a contrast setting for HDR, as the RenoDX HDR mods have.

- shaders/hdr_encode.comp: after its SDR expansion (mode 0, Expanded, as
  before) or the game's own PQ picture decoded (mode 1, Native), the light
  is graded as RenoDX's Contrast does it: on the luminance relative to the
  paper white, pow(Y * exposure / 0.18, contrast) * 0.18, the hue kept, and
  it is PQ coded again. Exposure is pushed as 1 (none) for now. Push
  constants: 48 bytes (crop, paper white, peak, knee, mode, contrast,
  exposure, two pads).
- A shoulder eases the graded light back onto the picture's top, the
  peak / paper white (3.5 at 800 and 230 nits): above a knee (min(1, half
  the top)) with Reinhard's rational shape, meeting the graded curve at the
  knee with its slope and reaching the peak at the graded top. A contrast
  above 1 lifted the top far past the peak (about 3500 nits at 150%), and a
  plain limit there cut every highlight flat. With the top its own target
  the shoulder is the identity at 100%, so the grade is continuous with the
  ungraded picture.
  - Expanded maps the SDR white to the peak; after the shoulder the
    display's peak still limits it by luminance.
  - Native is fitted under the peak the game is told: the HDR peak row,
    which Hookshot passes to the game as its display's peak, and which the
    game's own light stays under (about 700 nits at most with an 800-nit
    row). Light the game puts above it is not limited by the pass, as at
    100%: past the top the shoulder goes on rising, and there is no peak
    limit for Native. At 800 nits peak and 330 paper white, a 700-nit
    highlight goes to about 720 nits at 105% and 780 at 150%.
  At 100% the grading does not run, and the picture is the one
  hdr_encode.comp made before.
- HDR contrast row (Graphics tab, with the other HDR rows, shown with HDR
  on, in row_shown's paper white / peak clause): 50 to 200% in steps of 5,
  100% by default, kept as hdr_contrast. With Native the game's picture
  goes through the pass only when the row is not 100%; at 100% it is shown
  as it is, as before.
- shaders/hdr_encode_comp_spv.h: glslangValidator 16.6.0 -V --target-env
  vulkan1.2 --vn HDR_ENCODE_COMP_SPV, with the source's hash on its first
  line as before.
- tests: test_hdr_encode.py runs the shader's own grade() and shoulder() in
  Python. Expanded: nothing at 100%; 50-200% rising from black, the top
  exactly on the peak, at most 3% of the picture at the peak. Native: the
  identity at 100%; every PQ code from 0 to 1 rising strictly at 50-200%,
  above the peak row too (not cut flat); the peak landing on itself; light
  at or under the peak row staying at or under it; at 95% and 105% the
  paper white and 0.9 of the peak within a fifth of the 100% picture. It
  also checks the push constants against framegen_hdr's and the row. The
  present fixtures take framegen_hdr's pq argument, the 48 bytes and the
  graded Native case; test_upscaler_rows.py checks the row is hidden with
  HDR off.

With Sharpness, the Window scaling filter or ultrawide's menu sides on,
Native's picture still reaches this pass through framegen_scale's RGBA8
image, 8 bits of PQ, until the next commit (fix/hdr-native-format) scales
it in 10 bits: the two are meant to land together.

Tested in game on the local v2.0.13 build, whose HLSL pass has this math
and this shoulder: RTX 5080, 3840x2160 HDR10, 800 nits peak and 230 nits
paper white, Expanded at 50/100/150%. Native graded under the peak row
has not been run in the game in this form; this GLSL port on main compiles
with the build's clang and passes the game-free checks.

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at 100% contrast

With the Insert menu's HDR on Native, the game's HDR10 picture lost two
bits whenever Sharpness, the Window scaling filter or ultrawide's menu
sides were on: framegen_scale wrote its PQ codes into an RGBA8 image.
Native was also told by the cached image's format, which said RGBA8 for
the game's RGB10A2 picture while the two shared an image by format class;
main's 0a28ff2 (NotRexed#2038) since gives RGB10A2 a class of its own.

- do_present: Native is told by vk_format(t->d.format) alone, the format
  the game presents and the one its view is made in, not by the image's
  (which would take an SDR picture for PQ in an image first made as
  RGB10A2). One log line when the presented format, its image's format or
  the HDR path changes, for HDR reports.
- present_image: Native always goes through the HDR pass, which reads the
  picture through that view. At 100% contrast and exposure 1 the pass
  writes the game's PQ codes as they are (hdr_encode.comp: no decode and
  encode in between), so the game's highlights reach the display as it
  made them. Graded off 100% (the previous commit), Native is fitted under
  the peak the game is told, is not limited by the pass above it, and
  moves on smoothly from this picture. When the pass cannot be made,
  Native shows its scaled picture, else black (said once), never its image
  blitted, which converts from the image's own format.
- framegen_scale(pq): with the Window scaling filter, Sharpness or
  ultrawide's menu sides, the game's picture is scaled into RGB10A2 images
  (the scaled one, the crop and the 1/8 picture of the blurred sides) by
  fg_scale_pq, made at its first use, not into RGBA8 ones. Its crop is
  copied as bits, not blitted. The RX_VK_SHOT_FX shot passes
  present_native_pq too, so a shot neither scales Native as SDR nor makes
  the images again in the other format.
- shaders/fg_scale.comp: FG_SCALE_PQ makes its destination rgb10_a2 and
  its dither a 10-bit step. fg_scale_pq_comp_spv.h is fg_scale.comp
  compiled with -DFG_SCALE_PQ; both headers come from
  tests/test_fg_scale_sharpen.py --write (glslangValidator 16.6.0), and
  the RGBA8 one's code is unchanged. hdr_encode_comp_spv.h: glslangValidator
  16.6.0 -V --target-env vulkan1.2, with its source's hash line.
- tests: test_fg_scale_sharpen.py checks both headers against the shader
  and what FG_SCALE_PQ changes; upscale_independent_test.cpp runs
  framegen_scale's 10-bit path (its pipeline, RGB10A2 images, the crop
  copied, back to RGBA8 after it, a pipeline that cannot be made);
  test_hdr_encode.py checks do_present's format test (the presented
  format alone), the pass-through at 100%, that present_image never blits
  Native's image and the shot's pq; test_present_aspect.py takes Native
  through the HDR pass, and without it to its scaled picture or black;
  present_aspect_vulkan.cpp's stubs follow present_image (framegen_scale's
  pq, framegen_hdr, dev.hdr), so it compiles and runs again.

Tested in game on the local v2.0.13 build with the format detection and
the 10-bit scaling: RTX 5080, 3840x2160 HDR10, 800 nits peak and 230 nits
paper white; Native looks right. The pass-through at 100% and the black
fallback are new and have not been run in the game; the port on main
compiles with the build's clang and passes the game-free checks.

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the game presents its own HDR10 picture (the Insert menu's HDR on
Native), its scene colour goes past 1. DLSS's feature was made for LDR
colour and the chain's inputs pass clamped the scene to 1, so every
highlight above the paper white was cut before DLSS saw it.

- vk_dlss.cpp, vk_temporal.cpp/.h: while present_native_pq says the game
  presents PQ (not merely the option and an HDR10 swapchain: with Native
  picked and an SDR picture shown, the scene is LDR), DLSS's feature is
  made with IsHDR | AutoExposure, and made again when that changes. The
  engine says so through TemporalEngine::hdr; FSR 4 has none and keeps the
  LDR pair. present_native_pq moves out of vk_replay.cpp's anonymous
  namespace for that.
- The chain then runs a second pair of its passes. dlss_prep.comp's
  kSquare (constant 1) squares the square-root encoded scene into linear
  light, which NVIDIA documents for IsHDR and from which AutoExposure works
  out its exposure, and its kColorMax (constant 0) is 16384 in place of 1,
  well inside the colour image's RGBA16F. dlss_out.comp's kSquare
  (constant 0) is off for it: the network's picture is linear already and
  is written as it is, the same output scale as the LDR pair, which still
  squares the square-root encoded picture.
- shaders/dlss_prep_comp_spv.h, dlss_out_comp_spv.h: glslangValidator
  16.6.0 -V --target-env vulkan1.3 (the version CI pins, the command
  tests/test_dlss_actor.py checks them with).
- tests: test_hdr_encode.py checks the two constants in each shader, the
  HDR pipelines and their specialization data, that temporal_upscale binds
  the HDR pair for an engine whose hdr() says so, and that prepare keys the
  feature on present_native_pq, not on the option.

RX_VK_DLSS_HDR=0 keeps the LDR feature and the clamped colour with Native
too, for comparisons (docs/development.md).

Tested in game: RTX 5080, 3840x2160 HDR10, 800 nits peak and 330 nits
paper white, Native at 100% contrast, DLSS Balanced, the Deku Tree's light
shaft. With RX_VK_DLSS_HDR=0 (as on main) the brightest light was 328 nits,
the paper white, and the hole of light a flat disc; with this change it
reached 749 nits, the 99.9th percentile 654 against 328, with the light's
structure back. The log says "HDR colour, linear (the game's native HDR)".

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With HDR on Native, AMD FSR 4 kept the LDR context and the chain's clamped
colour (NotRexed#2243 left it so), so every highlight was cut at the paper white, as
DLSS's were before NotRexed#2243.

- vk_fsr4.cpp: while present_native_pq says the game presents PQ, FSR 4's
  context is made with FFX_UPSCALE_ENABLE_HIGH_DYNAMIC_RANGE |
  FFX_UPSCALE_ENABLE_AUTO_EXPOSURE (linear colour, AMD's own exposure) in
  place of FFX_UPSCALE_ENABLE_NON_LINEAR_COLORSPACE, and made again when that
  changes, as DLSS's feature is. Its dispatch then drops
  FFX_UPSCALE_FLAG_NON_LINEAR_COLOR_SRGB, and on Linux the unit exposure the
  LDR context needs is not passed. The engine reports it through
  TemporalEngine::hdr, so the chain gives it NotRexed#2243's HDR pair: linear light
  in, unclamped, and its linear picture out as it is.
- RX_VK_FSR4_HDR=0 keeps the LDR context and the clamped colour with Native
  too, for comparisons (docs/development.md, readme's FSR 4 section).
- tests: test_hdr_encode.py checks that prepare keys the context on
  present_native_pq (not on the option) and makes it again when that
  changes, the HDR and LDR creation flags, the dispatch's colour flag, and
  that the engine reports hdr. It fails on NotRexed#2243 without this commit.
- docs/tested-configurations.md: the run below.

Tested in game: RX 9070 XT, Mesa RADV 26.2.6, Linux, fsr4-vk (FSR 4.1.1,
FP8), 2560x1440 HDR10 at 110 nits paper white and 400 nits peak, FSR 4
Native AA, Kokiri Forest. With RX_VK_FSR4_HDR=0 (as with NotRexed#2243 alone) the
brightest pixel was PQ code 132, the paper white; with this change 135-145
(two runs). The rest of the scene stays within one code value, and the
frame-to-frame brightness is steady. The log says "HDR colour, linear (the
game's native HDR)" when the context is made. SDR is unchanged. Builds with
and without HOOKSHOT_FSR.

Not tested: Windows with AMD's signed FSR SDK 2.4 DLLs (FSR 4.1.2), which get
the same creation and dispatch flags; FSR 4's upscaling modes (render size
below the output) in HDR; HDR on Native turned on or off mid-run (the context
made again).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hckrman101
hckrman101 merged commit a0dfd28 into NotRexed:main Nov 11, 2026
1 check passed
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