Skip to content

nvn/upscaler: MetalFX scales straight between the game's images in any shared format (OoT −0.6 to −1.0 ms a frame on an M1) - #2559

Closed
AdyaGMD wants to merge 1 commit into
seedds:mainfrom
AdyaGMD:metalfx-direct-any-format
Closed

AdyaGMD wants to merge 1 commit into
seedds:mainfrom
AdyaGMD:metalfx-direct-any-format

Conversation

@AdyaGMD

@AdyaGMD AdyaGMD commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

What

With DM_UPSCALER=metalfx, the spatial scaler drew straight from the game's colour into its output image only when both were RGBA16F. Otherwise every frame:

  • copied the colour into the scaler's own RGBA16F input (a render pass);
  • scaled into the scaler's RGBA16F output;
  • drew that output back into the game's image (another render pass).

With DM_NVN_MFX_DIRECT (on by default; =0 keeps the copies, ab for the DM_AB phases), when the game's colour and output image share any one format, a second scaler is made in that format. It scales straight between the game's images, with the same conditions as the RGBA16F straight path:

  • the whole input texture and the whole output image;
  • a private output;
  • texture usages the scaler needs.

If MetalFX refuses the format (no scaler made), or the usages don't match, the copies stay, with one log line saying why.

Why

Ocarina of Time's upscaler chain reads and writes R11G11B10F (1440x810 → 1920x1080). MetalFX takes that format, so the two copy passes were pure overhead every frame.

Performance

Ocarina of Time (0400486024DEC000), Kokiri Forest, MacBook M1 (8-core GPU), fullscreen, DM_UPSCALER=metalfx. Measured in a tree with other local changes. Frame p50 per phase:

Run With Without Difference
DM_NVN_MFX_DIRECT=ab, 300-frame phases 33.70 / 33.67 / 33.75 / 33.75 ms 34.26 / 34.40 / 34.36 / 34.43 ms −0.64 ms
=ab:0/2 (polarity swapped), 450-frame phases 33.40 / 33.60 / 33.31 ms 34.35 / 34.57 / 34.39 ms −0.97 ms

Lower in every adjacent pair, in both runs. Screenshots with it on and off differ only by animation (fog, colour and UI match).

The log line: upscaler: MetalFX spatial scaler (HDR) straight in format 92, 1440x810 -> 1920x1080.

Switch (in docs/env_switches.tsv)

  • DM_NVN_MFX_DIRECT=0|1|ab: on by default.

Checked

  • The host compiles on this branch.

Not run

  • This branch's host in game (it was measured in another tree).
  • Any test suite.
  • iPhone.
  • Other games.

🤖 Generated with Claude Code

…y format they share, not only RGBA16F: Ocarina of Time -0.6 to -1.0 ms a frame on an M1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation labels Nov 13, 2026
@dpadr

dpadr commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

One interaction to know about, not a problem with this change: the frame-generation draft #2279 hooks up_mfx_encode only on the path that goes through the scaler's own RGBA16F images (if (!direct) fg_frame(...)). With this on by default, Ocarina of Time takes the straight path on main, so #2279 would generate no frames there (it logs a [RED] line rather than failing silently). Nothing to change here; on our side #2279 will either keep the copy path while DM_FRAMEGEN=1 or hook the straight path. DM_NVN_MFX_DIRECT=0 is the workaround meanwhile. Thanks for the ab numbers with the polarity swapped, they make the −0.6 to −1.0 ms easy to trust.

seedds added a commit that referenced this pull request Nov 14, 2026
… format, scratch textures only for a side that copies (PR #2559)

With DM_UPSCALER=metalfx, the upscaler took the game's textures straight only when both
were RGBA16F; any other format cost a copy into the scaler's RGBA16F input and a draw back
from its output, a render pass each (Ocarina of Time: R11G11B10F 1440x810 -> 1920x1080).

- One scaler cache keyed by sizes, colour mode and both formats (8 slots; a format MetalFX
  refuses is remembered and RGBA16F stands in). The scaler's own input and output textures
  are made on first use by a side that copies, so straight paths no longer hold them.
- The input is the game's texture in its own format when the rectangle is the whole
  texture, the format is not sRGB and the usages fit (DM_NVN_MFX_DIRECT=0|1|ab, on by
  default; =0 keeps the input in RGBA16F unless it already is).
- The output stays straight only in RGBA16F: MetalFX's own R11G11B10F writes come out
  about two mantissa steps low (2.0 as 1.94, 0.5 as 0.484, against 1.98 and 0.496 drawn
  back), 2.4 % darker everywhere, so Ocarina keeps its draw back.

On an M6, a harness of 1,200 dispatches at Ocarina's sizes (four alternating runs,
medians): 340 us each with both copies, 316 with the input straight, 271 with both.
PR #2559 measured both going in game on an M1 at 0.64 and 0.97 ms a frame. Not run in
game on this change (no NDSR game built on disk).

Checks: check_t_upscaler_mfx (5 runs, new R11G11B10F, mixed-format, offset and
DM_NVN_MFX_DIRECT=0 cases; the R11G11B10F mean check fails on straight packed writes),
test_docs, test_env_switches. No regress run: regress uses the game's own upscaler.

Co-authored-by: Adya <114347520+AdyaGMD@users.noreply.github.com>
@seedds

seedds commented Nov 14, 2026

Copy link
Copy Markdown
Owner

Thanks, landed as f31d607 in a reworked form, with you credited as co-author. The diagnosis and your M1 A/B numbers carried it.

What changed from the PR:

  • One scaler cache, keyed by sizes, colour mode and both formats, instead of a second cache next to it. The scaler's own RGBA16F textures are now made only for a side that copies, so the straight paths no longer keep about 26 MB of unused scratch.
  • Each side decides on its own. The input is the game's whole texture in its own format (any non-sRGB format MetalFX takes, falling back to RGBA16F when it refuses or a usage is missing). DM_NVN_MFX_DIRECT=0|1|ab stays and now governs the input side.
  • The output stays straight only in RGBA16F. MetalFX's own R11G11B10F writes come out about two mantissa steps low: a 2.0 highlight as 1.94 and 0.5 as 0.484, against 1.98 and 0.496 when its RGBA16F output is drawn back. That makes the whole picture about 2.4 % darker, which a screenshot comparison doesn't show. t_upscaler_mfx now checks the mean colour of an R11G11B10F output, and that check fails on the straight write. So on Ocarina of Time the input copy goes and the draw back stays.
  • The env_switches.tsv row is in sorted order (env_switches.py --check failed on the PR).

Timing on an M6, from a harness of 1,200 dispatches at Ocarina's sizes (four alternating runs, medians): 340 µs a dispatch with both copies, 316 µs with the input straight, 271 µs with both straight. I couldn't run it in game here, because no NDSR game is built on this machine right now. An in-game A/B on your M1 (DM_NVN_MFX_DIRECT=ab) would be welcome if you have the setup.

@dpadr, for #2279: the straight path is now input-only on Ocarina. fg_frame hooked on !direct will need the new per-side flags (sin / sout in up_mfx_encode).

@seedds seedds closed this Nov 14, 2026
Kayyluhh added a commit to Kayyluhh/nxnative that referenced this pull request Nov 14, 2026
…y the Metal texture (seedds#2542) in place of seedds#2260's; the game's upscaler takes the game's textures straight (seedds#2559) except square-root input and a blended output (seedds#2366)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants