Repository navigation
feat(hdr): PsychoV35 scene tone mapper for Native HDR (RenoDX) - #2481
Conversation
With HDR set to Native, f_80672260's R11G11B10F choice for the scene buffer the game tone maps into is RGBA16F, as RenoDX's zelda-oot-oz mod patches main+0x65234C (github.com/clshortfuse/renodx, MIT). A runtime switch (hos_scene_rgba16f, set from the HDR row at each present), so the game's own pick stays with HDR off; the game makes the buffer again when the format it wants changes. Co-authored-by: Cursor <cursoragent@cursor.com>
With HDR set to Native, the HDR tone mapper row (Vanilla, PsychoV35) replaces the game's Narkowicz fit in its scene mapping programs with PsychoV35 from ShortFuse's RenoDX zelda-oot-oz mod (MIT), at the mod's defaults: midgray matched to the game's, its tone slope, hue clip emulation at 50%. ir_opt/scene_tonemap_pass.h finds the fit in the IR (three FMAs of 2.51 and 0.03 on the scene times a constant buffer exposure, each channel named by its luminance weight, target 0 under the peak of that bank) and writes target 0's colour from psychov35.h, the mod's math reduced to the path its defaults take. Programs whose fragment code holds the fit's constants get their own key while the row is on, so a change translates only those; the per-thread picks are dropped when it changes. Tests: test_psychov35.py checks the header against RenoDX's HLSL path ported line by line (3009 colours, 2e-6); test_scene_tonemap_pass.py runs the pass on synthetic IR and interprets the result. Co-authored-by: Cursor <cursoragent@cursor.com>
hckrman101
left a comment
There was a problem hiding this comment.
Thanks, this is a careful piece of work. The pass matches on structure rather than a hash: three FPFma(x, 2.51, 0.03), the luminance weights, a single cbuf exposure, and a peak from the same bank. Anything else is left alone, with the reason logged. The math header is shared between the IR emitter and the float test, which is a nice way to keep the two honest. With HDR off I see nothing that changes for players. scene_tonemap_on() is false, so the key isn't salted, and hos_scene_rgba16f stays 0, so the lifted pick is the original 0x1a. Apart from the translator stamp's one-time rebuild, that path is identical to main.
Licence. RenoDX is MIT, which is fine inside a GPL-3.0 repo. psychov35.h keeps Carlos Lopez's copyright line and the MIT SPDX tag, and PROVENANCE.md credits both source files. Nit: tests/test_psychov35.py is a line-by-line port too, so it should carry the same copyright/SPDX line. It would also be worth putting the MIT permission text in the tree, as third_party/fidelityfx/LICENSE.md does for AMD's headers.
The scene buffer format is the one thing I'd hold this for (inline on nn_vi.c). hos_scene_rgba16f follows Native HDR alone, not the new row. So every Native HDR player gets an RGBA16F scene buffer even with the tone mapper on Vanilla, and the "Nothing changes unless HDR is set to Native and the new row is set to PsychoV35" in the description isn't quite right. That matters because the temporal upscalers recognise the scene by its format:
temporal_note_scene_draw(vk_temporal.cpp) andskin_check(vk_draw.cpp) require the scene target to beB10G11R11.temporal_upscalerequires that format for an unknown network's input and output.- FSR 3.1 (
vk_fsr3_inputs.cpp,vk_fsr3.cpp) and the TAAU replacement (vk_draw.cpp) gate on it too.
If the buffer f_80672260 makes is the scene target or the network's input, DLSS, FSR 4, FSR 3.1 and TAAU will quietly fall back to the game's network under Native HDR. The PR's in-game numbers don't say which upscaler was on. Could you run Native HDR with DLSS (or FSR 4) and check that the log still shows the upscaler taking the gameplay network? If it doesn't, there are two ways out: tie the format to the PsychoV35 row and say upscalers don't combine with it, or teach those gates RGBA16F. Either way, please state the Vanilla/Native change in the description. #2243 (DLSS for Native HDR, open) works in the same area, so the two will need to agree.
IR pass.
- One robustness point, inline.
- NaN/inf look handled. Zero cones give -inf logs that resolve to response 0 / deficit 1, every division by zero is behind a
Select, and the pass runs beforeGuardFragmentOutputs, so anything left is guarded (the wiring test checks that order).
Keys/caches. The salted graphics_key keeps the PsychoV35 variants in their own disk-cache entries, so toggling the row can't pick a stale pipeline. Clearing the thread-local picks/prog_fast on a change is right too. Settings use the ini key hdr_tone_mapper with no new numeric option id, so nothing collides with other PRs' ids.
Tests. The three new tests are declared with RUN_ALL_ARGS, need no game, and the scene-format fixture uses made-up lift text. CI is still queued (no runs have finished yet).
Leaving this as a comment for now. Once the upscaler question is answered and the description matches, I'd be happy with it.
| hos_text_scale = text_scale(env, rx_option(5, 0)); | ||
| } | ||
| hos_camera_scale = camera_scale(rx_option(HOS_OPT_CAMERA_SPEED, 100)); | ||
| hos_scene_rgba16f = rx_option(HOS_OPT_HDR_NATIVE, 0) != 0; |
There was a problem hiding this comment.
This follows HOS_OPT_HDR_NATIVE only, so Native HDR with the row on Vanilla also gets the RGBA16F scene buffer (see the main comment about the upscalers' B10G11R11 gates). If the format is only needed for PsychoV35, HOS_OPT_HDR_NATIVE && hdr_tone_mapper == 1 would keep Vanilla Native exactly as on main. That would need a host-visible option for the row. If it's meant for every Native HDR player (less banding), please say so in the description and confirm the upscalers still engage.
There was a problem hiding this comment.
Done in b485da0: it's rx_option(HOS_OPT_HDR_TONEMAP, 0) now (option 47, answered by scene_tonemap_on()), so Native HDR on Vanilla keeps R11G11B10F.
| first = &inst; | ||
| break; | ||
| } | ||
| IR::IREmitter ir{*block, block->Instructions().iterator_to(*first)}; |
There was a problem hiding this comment.
Minor robustness point: this inserts PsychoV35 before the first write, but it uses scene[2]/gain from the other channels' fits. MadeOf only checks that each write comes from its own channel's fit, so a program that writes R before computing the B fit would get a use-before-def. The game's shape is safe because each write goes through the luminance of all three fits. Inserting before the last of the three writes (all in one block, already checked) would be safe for any order, since every write's operands dominate it.
There was a problem hiding this comment.
Done in b485da0: PsychoV35 is emitted before the last of the three writes, and the other two move down to it. The new red-first shape in scene_tonemap_ir.cpp checks every operand is defined before its use.
|
Tested PR #2481 at commit c46f8bc on a real HDR display. Windows 11, i7-13700F, RTX 4070 12GB, Gigabyte G34WQC A at 3440×1440. Native HDR, 200-nit paper white, 400-nit peak. PsychoV35 looked great during gameplay. Tested with DLSS, frame generation, and switching between Vanilla and PsychoV35 during play. Everything looked good in each case, with no observed flickering or black screens. This resolves the Native HDR flickering I experienced on v2.0.14 in the scenarios I tested. |
hckrman101
left a comment
There was a problem hiding this comment.
I merged main into the branch (2df79c0) so this is mergeable again. The only conflict was nx2/oot_patches.py: main moved the per-file patching into patch_file() / report() (#2442), so the scene-format hook now sits in patch_file() right after the free camera one, counting into counts["scene format"], and report() starts done["scene format"] with the others. Nothing else of the PR changed (same 16 files, +1098/-7 against main). The game-free suite passes locally apart from two Tk-only checks that fail the same way on main here.
Thanks @futondestroyer for testing on a real HDR screen. Your log actually answers the upscaler question from my last review, and not in the PR's favour yet:
- With HDR on Native, DLAA never takes the frame. Every few seconds it logs
NVIDIA DLAA: the game's upscale runs (unexpected output)(earlier(no scene camera this frame)), and the DLSS-G summaries all say0 made by DLSS-G ... 600 without new inputs (no DLSS/DLAA this frame). - The game's upscale network's input and output show up as format 97 (
R16G16B16A16_SFLOAT) under Native, where the upscalers expect the B10G11R11 scene. - The only time DLSS made its feature was after HDR was switched to 1 (SDR expanded to HDR10), where the scene buffer stays at the old format.
So what looked right in play was the game's own upscale plus the renderer's own frame generation, not DLSS/DLAA or DLSS-G. That's the format gate I described on nn_vi.c: hos_scene_rgba16f follows Native alone, so this also affects Native players who keep the tone mapper on Vanilla.
Still COMMENT for now. Either of the two ways out from before works: tie the RGBA16F pick to the PsychoV35 row and say upscalers don't combine with it yet, or teach the temporal/FSR/TAAU gates RGBA16F (and check with #2243). Then a run with DLSS on under Native should show a feature ... made line and DLSS-G frames. The licence nits and the small IR ordering point from the first review are still open.
…safe for any write order Native HDR on Vanilla gave the game an RGBA16F scene buffer, which the temporal upscalers' B10G11R11 gates refuse: DLSS/DLAA, FSR and TAAU fell back to the game's own upscale. The host now picks the buffer from a new option, HOS_OPT_HDR_TONEMAP (47), answered by scene_tonemap_on(), so Native HDR on Vanilla is as on main. The scene tone map pass emits PsychoV35 before the last of target 0's three writes and moves the other two there, so a program writing a channel before another's fit is made gets no use before its value; the IR test covers that order and checks every operand is made before use. RenoDX's MIT licence is beside psychov35.h, whose copyright line now matches it, and tests/test_psychov35.py's port carries the same notice.
|
@hckrman101 b485da0 addresses all three open points. Scene buffer format. I took your first option. IR ordering. The pass now emits PsychoV35 before the last of the three writes. Inserting there alone wasn't enough: the earlier two writes would then read values made after them, so they're moved down to just before the last write (same Licence. RenoDX's MIT text is in
|
…and NotRexed#2496) NotRexed#2119 renamed the Screen section's Ultrawide row to Aspect ratio and changed Ultrawide menus' help; NotRexed#2481 added HDR tone mapper after HDR contrast. Both changed rows inside the block this PR moves into sections: the sections keep main's definitions word for word (every one of main's 81 rows matches), with HDR tone mapper last in HDR. The readme's table takes main's Aspect ratio and HDR tone mapper lines in the section order. test_menu_sections.py also pins Profile, Aspect ratio, Ultrawide menus and HDR tone mapper in their sections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Graphics tab is one list of 24 settings, with the window, HDR, upscaling, frame generation and GPU options mixed together, and about ten more are on their way in open PRs. This puts its rows under section titles, so the upscaler, frame generation or HDR can be found without scrolling the whole list. It is the first step of a menu organization laid out below for discussion. **What changed** - The Graphics rows in six sections, under heading rows like the Debug tab's Developer (a gold title, never selected, nothing to click): - Quality preset (first: it sets rows in every section) - **Resolution and upscaling:** Output resolution, Profile, Upscaler, DLSS model, Render resolution, Sharpness - **Frame generation and latency:** Frame generation, Frame pacing, Game frame rate cap, NVIDIA Reflex, AMD Anti-Lag 2 - **Screen:** Fullscreen, V-Sync, Window scaling filter, Aspect ratio (#2119), Ultrawide menus - **HDR:** HDR, HDR paper white, HDR peak brightness, HDR contrast (#2342), HDR tone mapper (#2481) - **Detail:** Anisotropic filtering - **GPU and memory:** GPU device, VRAM budget (#1859), Mesh shaders (Robust buffers is on Debug ▸ Developer since #2042) - Moves only: every row's definition is unchanged, word for word; no row is renamed and no value or saved key changes, so options files carry over, and #1864's tables (keyed by the English text) still find every row. They need the six headings. - No new drawing or input code: the menu already draws heading rows on any tab, and the keys, the pad and the mouse step over them. - `readme.md`: a sentence on the sections, and the option table in the new order. (Ultrawide menus and Mesh shaders have no line yet; #2105 adds them.) The **Frame generation by** row from #2118 gets its line in the table here too: main's readme has none yet. So do #1859's **VRAM budget** (under GPU and memory) and #2342's **HDR contrast** (under HDR). - `tests/test_upscaler_rows.py`: its order list follows the picture's path (output, upscaler, render size, sharpening, then the frame rows, then the window). New `tests/test_menu_sections.py`: the Graphics sections and which rows they hold, and on every tab no section that `row_shown` could leave empty (a title over nothing on a GPU without Reflex, say). **The plan this is step 1 of (for discussion)** Each tab answers one question; a section groups the rows you set together; a row that depends on another follows it; vendor rows sit beside their counterpart and hide where they can't run (no vendor sections, so nothing is left empty on AMD); rows to touch only when something is wrong go last; moves only, no renames. | Tab | Holds | Sections | |---|---|---| | **Game** (was Gameplay; first, where the menu opens) | how the game behaves and reads, and Restart and Quit, as a pause menu | flat; Restart and Quit last | | Graphics | how the picture is made, and what it costs | Quality preset · Resolution and upscaling · Frame generation and latency · Detail · GPU and memory | | **Display** (new) | how it gets to your screen, and what is drawn over it | Screen · HDR · Overlays (FPS counter, Menu size, from Game) | | Audio | sound out and in | + Microphone (from Game: it picks the input device) | | Controls | how you play | Camera · Controller · Buttons (optional) | | Debug | tools and developer switches | Robust buffers in Developer (#2042, merged) | Next PRs, one topic each: (2) a Display tab, taking the Screen and HDR sections over whole and adding Overlays; (3) the Gameplay tab titled Game, first in the strip, where the menu opens; (4) Microphone to Audio; (5) optional, sections on Controls. If a sixth tab is a no, this PR stands on its own. Open questions: 1. A sixth tab (Display)? 2. V-Sync on Display (the PC convention) or with frame generation (it changes with it)? 3. Sharpness with the upscaling rows (where players look for it) or with the window filter (where it runs)? 4. DLSS 5 (#1590) and #1834's Neural rows as a page opened from a Graphics row, like Motion, rather than a seventh tab? <details><summary>Where open PRs' rows would go</summary> - Resolution and upscaling: Sharpening method (#1979), DLSS 5 page (#1590), Native 3D resolution (#2017) - Frame generation and latency: Frame generation by (#2118, merged: in this section now), Frame generation method (#1834) - Detail: Shadow resolution (#1892), Screen filter and Filter strength (#1488) - GPU and memory: Texture streaming cache (#2017), Refresh memory (#888); VRAM budget (#1859, merged) is in this section now - Game (was Gameplay): Game language and Menu language (#1864), Skip title screen (#1560), Amiibo (#1521) - Controls: Camera speed (#1945) - #2042 (merged) moved Robust buffers to Debug ▸ Developer. - #2105: its readme check would read the heading rows as rows; it needs to skip `{TAB_..., "...", nullptr, 0, 0, 0, ..., true}` headings, or this PR adds that if it lands first. Whichever PR lands second moves one row on rebase; happy to do it for anyone. </details> **Testing** - `tests/run_all.py` on Windows 11 (llvm-mingw clang, 8 at once) after merging main at 2dfe901 (with #2119's Aspect ratio, #2481's HDR tone mapper, #2118's Frame generation by, #2342's HDR contrast and #1859's VRAM budget): 422 passed, 33 skipped, 2 failed, both outside this PR: `test_vram_budget_row.py` (fails the same on main's own sources here) and `test_portable_builder_startup.py` (its `dotnet publish` needs packages this PC does not have). A build of the branch compiles. All 81 of main's rows keep main's definitions word for word; the only additions are the six headings. `test_menu_sections.py` fails on main as it stands (no sections) and passes here; a layout with a section of Reflex and Anti-Lag 2 alone fails it. - A build of this branch (Windows 11, RTX 5090), headless with scripted menu keys at Small, Medium and Large: 12 Downs from the Quality preset land on V-Sync and 13 Ups come back past it to the last row, so every heading is stepped over both ways and Up from the first row wraps to the last; the help box always shows the selected row (at the first head, when Robust buffers was the last row). Again at this head (25feb16, after #2042, #1859 and #2119): Up from the Quality preset wraps to Mesh shaders, the last row now, under VRAM budget (the bottom screenshot). **Not tested**: in a window with a pad, the mouse wheel and a click just under a heading (the headless runs drive the keys); values kept across a restart (no row's saved key changes); AMD and Intel (their rows hide through `row_shown` as before; the new check makes sure no section can end up empty), Linux. **Screenshots** The Graphics tab at 1920x1080, Medium menu size: the top, then scrolled with the keys to V-Sync and to the last row (the selection steps over every heading; Up from the first row wraps to the last row). The bottom one is retaken at this head (25feb16, a 16:9 picture: `HOS_ASPECT=1.7778`, as on a 16:9 monitor); the others are from the first head (634ef65).    Small and Large:   _Taken from headless runs of this branch (`HOS_HEADLESS=1`, `RX_VK_SHOT_MENU=1`, the keys from `RX_VK_MENU_SCRIPT`), RTX 5090. Headless, the list never scrolls on main either: `menu_record_image` leaves `ease_step` at 0, so the build behind the scrolled shots had one more line there (`ease_step = 1.0f`, hence "-dirty" in the first head's build label), not part of this PR. The windowed menu eases as before._ 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Follow-up test on b485da0, same RTX 4070 / Windows 11 system. During one session I cycled through HDR Off, Expanded, and Native with both Vanilla and PsychoV35. DLAA and FG were set to On throughout. The log shows DLAA recreating its feature as Native HDR turns off/on, correctly reporting HDR linear-light input when Native resumes. It records a full 600-frame interval with DLAA processing every frame. Later The tone-mapper changes aren't explicitly marked in the log, so I can't associate every fallback/resumption with an exact switch. DLSS-G remains unsuccessful. Its feature initializes, but evaluation repeatedly returns |
|
Thanks @futondestroyer, that's what b485da0 was meant to do: DLAA makes its HDR feature each time Native turns on and comes back after the fallbacks. The DLSS-G |
…2119, NotRexed#2197, NotRexed#2481, NotRexed#2496) NotRexed#2177 landed (squashed), so this takes main's sections as they shipped and keeps this PR's change on top: the Screen, HDR and Overlays rows on the Display tab. Main's newer rows go with them, word for word: NotRexed#2119's Aspect ratio and Ultrawide menus text and NotRexed#2481's HDR tone mapper (last in Display's HDR). Every one of main's 88 rows keeps main's definition; the only changes are the moved rows' tab and the Overlays heading. draw_options keeps main's Save my pipeline list line (NotRexed#2447) with the restart line on Display too. The readme takes main's Menu shortcut text (NotRexed#2197) with six tabs, NotRexed#2177's VRAM budget line, and main's HDR tone mapper and Field of view lines. test_menu_sections.py pins Aspect ratio by name and HDR tone mapper in Display's HDR; test_scene_tonemap_pass.py (NotRexed#2481) finds the tone mapper row on Display. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NotRexed#2197, NotRexed#2481, NotRexed#2496) into feat/menu-game-tab Takes NotRexed#2178 as merged with main (cf90c14f) and keeps this PR's change on top: the Game tab first, where the menu opens. Main's new Gameplay row, Field of view, goes with the Game rows at the table's top, word for word, after Game speed. Every one of main's 88 rows keeps main's definition; the only changes are the moved rows' tab (Display) and the Overlays heading. The readme takes main's Menu shortcut text (NotRexed#2197) with this PR's tab sentence, and Field of view among Game's rows. The tab enum, the strip's titles and the control bridge's names follow the same Game-first order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native HDR currently keeps the game's own tone curve, which clips highlights at the same point the SDR picture does. This adds the scene tone mapper from ShortFuse's RenoDX mod for this game (PsychoV35, MIT) as an opt-in row, so highlights roll off into the display's peak instead.
What changes
f_80672260;nx2/oot_patches.pymakes that pick followhos_scene_rgba16f, whichhost/nn_vi.csets from a new option,HOS_OPT_HDR_TONEMAP(47,scene_tonemap_on(): Native HDR with the row on PsychoV35). That gives the curve unclamped values to work with, as the RenoDX mod does. Native HDR on Vanilla keeps R11G11B10F, as on main, because the temporal upscalers only take that scene format.hdr_tone_mapper.ir_opt/scene_tonemap_pass.hfinds the game's Narkowicz fit in the translated IR and replaces target 0's colour with PsychoV35. It matches on the full structure: threeFPFma(x, 2.51, 0.03)whose input is the scene times one constant-buffer exposure, each channel identified by the luminance weight (0.2989, 0.5866, 0.1144) it meets downstream, and an output clamped under a peak word from the same bank. It reuses the game's own exposure and peak reads; anything that doesn't match is left unchanged, with the reason logged.ir_opt/psychov35.his RenoDX's PsychoV35 reduced to the path the mod takes at its defaults: midgray matched to the game's, the game's tone slope, hue-clip emulation at 50%, neutral grading, the exact BT.2020 fit. Both anchors are grey, so the cone response reduces to one logistic. It is written once over a small set of operations, so the pass emits it as IR and the tests run it on floats.Testing
tests/test_psychov35.py: the header against RenoDX'sscene_tonemap.slangi+psychov/test35.hlslpath ported line by line (log-domain response, residual normalization, matrices from their XYZ definitions): 3009 colours, worst relative difference 2.1e-6 in float32. It also checks the baked matrices against their definitions.tests/test_scene_tonemap_pass.py: builds Eden's IR library, runs the pass on a synthetic scene-mapping program with the channels' fits emitted out of order (and one where red is written before the other fits are made, checking every operand is defined before its use), interprets the resulting IR, and compares it with the header. Programs with no luminance weights, or with different exposures per channel, are left untouched (instruction count and outputs unchanged). It also checks the wiring: key, translation, pick reset, and row visibility.tests/run_all.pyon Linux: same results asmainon this machine (its 27 failures are missing headers and tools here, and fail onmaintoo).seed_variant_cleanup_test.cpp's stubs were updated forgraphics_key's new argument.vk: program 672e593076ac390a: scene tone map PsychoV35). Frame rate is unchanged (about 105 fps in both, same scene). Decoding the PQ screenshots to nits at a 200-nit paper white and 1000-nit peak:The mid-tones and exposure match the game's (midgray matching works), and the highlights move into the HDR range. No pixel exceeded the 1000-nit peak.
Screenshots
Native HDR, the PQ shots decoded to nits and shown through one fixed SDR mapping (an SDR page cannot show the HDR range itself); Vanilla on the left, PsychoV35 on the right.
Reach and what is not here