Repository navigation
feat(hdr): HDR contrast row, also grading the game's native HDR - #1941
j64esatxzdrhfg wants to merge 1 commit into
Conversation
ghost
left a comment
There was a problem hiding this comment.
Thanks, the Expanded side of this looks right to me. I went through the shoulder: it meets the graded curve at the knee with slope 1, it rises for s above and below 1 (b(1+b) > 0 in both cases), and it lands exactly on the peak at grade(top). At 100% the contrast != 1.0 gate is exact, since 100 / 100.0f is exactly 1. Running the shader's own grade()/shoulder() in Python is a nice way to test this. The push constants (48 bytes, same order on both sides), the hdr_contrast ini key (clamped on load), the row's visibility and the stubbed fixtures all match, and the header's hash line matches the shader at this head.
The Native half is where I have questions:
- It only works with #1942. #1942 says Native is never actually taken on main (the cache's RGBA8/RGB10A2 aliasing means
present_native_pqstays false), so on its own this PR can't grade the game's HDR, despite the title and the row's help text. Also, with Sharpness, the scaling filter or ultrawide's sides on,framegen_scalestill writes the game's PQ codes into an RGBA8scale.scaled, so the grade works on 8-bit PQ. Contrast above 100% will make that banding worse. #1942 fixes both. I'd land #1941 and #1942 together (or fold the Native part into #1942), and say so in the description. - Native highlights above the HDR peak row get cut flat once contrast isn't 100% (inline). Details are on the line.
Nits: row_shown could fold hdr_contrast into the existing paper white / peak clause instead of adding a second one (the test string would follow). Exposure is pushed as a constant 1, which is fine, but it's dead weight until a row uses it.
CI hasn't run on this branch yet (no checks reported). It's 63 commits behind main but GitHub reports it mergeable. No other discussion on the PR so far. The Expanded path looks ready to me.
| // (the contrast row: the luminance graded, its highlights eased onto the peak, the colour scaled with it) | ||
| const float y = dot(nits, Y2020) / pc.white; | ||
| if (y > 1e-6 && (pc.contrast != 1.0 || pc.exposure != 1.0)) { | ||
| const float top = pc.peak / pc.white; |
There was a problem hiding this comment.
For Native, top is the HDR peak row, but the game's own picture isn't bounded by it. If the game puts light above pc.peak / pc.white, shoulder() takes the t >= 1 branch and the luminance limit below then clips it at the peak. With the row at 1000 nits and the game's highlights reaching 1500, everything from about 1000 to 1500 ends up flat at the peak as soon as contrast moves off 100%. At 100% the pass is skipped and those highlights show. That's the flat-cut highlights the shoulder was added to avoid, just on the Native side. Could Native use a top that covers what the game can output (the PQ ceiling, or the game's own peak setting if you can read it), or should the row's text at least say the peak row has to match the game's?
There was a problem hiding this comment.
Changed. Native's grade is now 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, in game).
- For mode 1 the shoulder's top and its target are both
pc.peak / pc.white, the same as Expanded. - There is no luminance peak limit after grading for Native. Light the game puts above the peak isn't limited by the pass, as at 100%: past the top the shoulder keeps rising, so it isn't cut flat. Only our grade is fitted under the peak.
- At 100% the shoulder is the identity, so the grade is continuous with the ungraded picture. At 800 nits peak and 330 nits paper white, a 700-nit highlight goes to about 720 nits at 105% and about 780 at 150%.
- Expanded is unchanged, with its peak limit after the shoulder.
test_hdr_encode.py runs the shader's grade()/shoulder() over every PQ code:
- the identity at 100%;
- strict rising at 50–200%, above the peak too;
- the peak landing exactly 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.
There was a problem hiding this comment.
Thanks, fitting Native under the peak row and dropping the peak limit for it answers this; nothing gets cut flat by the pass any more.
One small thing left on the "as at 100%" part: past the top the shoulder continues with slope 1 in graded light, and in the game's light that's a slope of about contrast * grade(top) / top. At 800 nits peak and 330 paper white I get, for game light of 790 / 800 / 810 / 900 / 1000 nits: 798 / 800 / 855 / 1367 / 1967 at 150%, and 749 / 800 / 801 / 813 / 826 at 50%. So above the row the game's light is stretched about 5x at 150% (the display will clip it) and squashed at 50%, with a kink right at the peak. Since you saw the game stay well under the row, that's a nit, not a blocker. Continuing in ungraded light instead (top + (y - top) in the game's light once y > top) would make it really "as at 100%" there. This code is on main now (via #2342), so it would be a follow-up.
| (v != &opts.dlss || ur::upscaler_shown(upscalers_available())) && | ||
| (v != &opts.dlss_model || ur::model_shown(dlss_why() == DLSS_OK)) && | ||
| ((v != &opts.hdr_white && v != &opts.hdr_peak) || opts.hdr != 0); | ||
| ((v != &opts.hdr_white && v != &opts.hdr_peak) || opts.hdr != 0) && (v != &opts.hdr_contrast || opts.hdr != 0); |
There was a problem hiding this comment.
nit: (v != &opts.hdr_white && v != &opts.hdr_peak && v != &opts.hdr_contrast) || opts.hdr != 0 reads as one rule (update the string in test_upscaler_rows.py with it).
There was a problem hiding this comment.
Done: (v != &opts.hdr_white && v != &opts.hdr_peak && v != &opts.hdr_contrast) || opts.hdr != 0. The string in test_upscaler_rows.py follows.
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>
69f7d3e to
35f8125
Compare
|
Thanks. This is now rebased directly onto main d7a7306; #1940's commit is gone, since its fix landed with #2038.
|
ghost
left a comment
There was a problem hiding this comment.
Re-review at 35f8125. Both of my earlier points are resolved:
- Native highlights above the peak row cut flat: fixed. Native's grade now uses the peak row as both its top and its target, and there's no peak limit after it for Native, so the pass no longer flattens anything. I left one nit in the thread: past the top, the game's light is stretched (150%) or squashed (50%) instead of left as it is.
- Only works together with #1942: answered. The description now says it lands with #1942, and why.
- Nits:
row_shownis folded into the existing clause (fixed). Exposure is kept as the constant 1 (answered, fine by me).
I checked the rest again. The shader hash in the header matches the shader at this head. Push constants are 48 bytes on both sides. Expanded at 100% is unchanged: the gate skips the grade, and the new luminance limit can't fire there because the expansion already tops out at the peak. With HDR off nothing here runs, so SDR output is unchanged. No new option id (the row is saved under its hdr_contrast ini key), so nothing collides with main's or the open PRs' ids. Native at 100% still skips the pass in present_image.
However, this PR's whole change is already on main. #2342 was squash-merged today as a0dfd28, and it carried the whole stack (#1941, #1942, #2243). Every line this PR adds is on main, apart from the ones #1942/#2243 later changed. That's also why GitHub shows every file conflicting. So there's nothing left to merge here, and I think this PR (and probably #1942 and #2243 too, once you've checked) can be closed as landed via #2342. I'd have approved it otherwise. CI only ran the title check (passed).
Joey-Magnus
left a comment
There was a problem hiding this comment.
I think this can be closed: it reached main inside #2342 (a0dfd28, "FSR 4 for the game's native HDR"), which was stacked on #1941, #1942 and #2243 and squash-merged. I checked file by file against origin/main:
- This PR's vk_menu.cpp, vk_menu.h, present_source_crop_test.cpp and test_upscaler_rows.py changes are on main exactly. The
HDR contrastrow is at vk_menu.cpp:781, andhdr_contrastis inrow_shown. - The other files (hdr_encode.comp and its header, vk_framegen, vk_replay, the tests) don't reverse-apply only because #1942 changed them again on top. #1942's final versions of those files are on main exactly.
The conflicts GitHub shows are that history and nothing else: there's nothing left here to merge. Thanks for the work, it shipped.
|
Closing as landed: this reached main inside #2342's squash merge (a0dfd28), which was stacked on #1941, #1942 and #2243. Thanks @Joey-Magnus for checking it file by file, and @hckrman101 for the reviews. The last nit (Native light above the peak row continuing in ungraded light, |
Players asked for a contrast control for HDR, as the RenoDX HDR mods have. The Expanded picture is otherwise fixed at the game's SDR contrast.
What changed
shaders/hdr_encode.comp: after the SDR expansion (mode 0, Expanded, unchanged) or the game's own PQ picture decoded (mode 1, Native), the light is graded as RenoDX's Contrast does it: on luminance relative to the paper white,pow(Y * exposure / 0.18, contrast) * 0.18, hue kept. Exposure is pushed as 1 for now. Push constants grow to 48 bytes.row_showntakes it in the paper white / peak clause): 50–200% in steps of 5, default 100%, saved ashdr_contrast. Its help says the highlights ease into the peak brightness, and that Native is graded under the peak brightness the game is told.shaders/hdr_encode_comp_spv.h: glslang 16.6.0, with the source hash line as before.test_hdr_encode.pyruns the shader's owngrade()/shoulder()in Python, and also checks the push constants and the row.test_upscaler_rows.pychecks the row's visibility.Testing: RTX 5080, 3840x2160 HDR10, 800 nits peak, 300 nits paper white, Expanded at 100% and 150% in a build of main (8c92fe3) with this series (screenshots, tone-mapped to SDR for this page). Before the shoulder, 150% clipped the highlights; that was found and fixed in game on the v2.0.13 build. Rebased directly on main d7a7306: it compiles with the build's clang.
tests/run_all.py hdr_encode upscaler_rows present_aspect present_geometry present_source_crop upscale_independent menupass (19), exceptpresent_aspect_vulkan. That one fails to compile the same way on main (its stubs predate the HDR pass), and #1942 fixes them.In game after the review changes (a build of main d7a7306 with this series, Native, 800 nits peak row, 330 nits paper white): at 100% the game's own picture topped out at about 700 nits; at 150% the graded picture's brightest light was 784 nits, under the peak row, with the light shaft still graded rather than cut flat (screenshots). Expanded's math is unchanged.
Not tested: AMD/Intel, Linux, displays other than 800 nits.
Screenshots (Expanded, 800 nits peak, 300 nits paper white)
Native (800 nits peak, 330 nits paper white)
HDR10 captures, tone-mapped to SDR for this page (paper white to white, highlights rolled off up to 800 nits).
🤖 Generated with Claude Code