Skip to content

nvn/fg: frame generation, Ocarina of Time at 60 on an M1 Max docked - #2530

Open
RobertMrowiec wants to merge 4 commits into
seedds:mainfrom
RobertMrowiec:feat/oot-m1max-60fps-upstream
Open

RobertMrowiec wants to merge 4 commits into
seedds:mainfrom
RobertMrowiec:feat/oot-m1max-60fps-upstream

Conversation

@RobertMrowiec

Copy link
Copy Markdown

What it does

With DM_FRAMEGEN=1, Zelda: Ocarina of Time (Switch 2, 0400486024DEC000) docked on an M1 Max shows 60 fps at a fixed 2048x1152 render size.

On the M1 Max the docked frame costs about 22-28 ms of GPU time. That barely changes with render size, so native 60 is out of reach. With frame generation, the game renders 30 real frames a second and the host shows 60 by generating a picture between each pair.

Everything is opt-in. Without DM_FRAMEGEN, and without DM_UP_SHARPEN on the MetalFX path, the host behaves exactly as main does.

I'd welcome review and testing from other M1/M2 owners, in particular on the motion estimation and on fast camera turns (see "Known").

Commits

  1. nvn/upscaler: FSR1 RCAS sharpening after MetalFX's spatial scaler (DM_UP_SHARPEN), plus a log line with a histogram of the game's render size.
  2. nvn/fg: frame generation (nvn_fg.inc, fg_core.h, display-link changes in nvn_mtl.m, main.c).
  3. host/hid: DM_CTL burst <n> <path%d> captures n consecutive presented frames, used for testing.
  4. game: a section in Ocarina of Time's note.

Results

M1 Max MacBook Pro (32-core GPU, 64 GB, on AC, macOS 27.2), docked, Kokiri Forest, the packed Mac app, warm pipeline cache.

  • Played: the change on 34608c72 held 60 fps on screen in play, as reported by the player. Walking and character movement feel like 60. Fast camera turns are better than with MetalFX alone but are still not fully smooth.

  • Game base rate (render side, 20 s windows): 29.7 28.8 29.8 27.7 30.0 30.0 30.0 30.0 29.2 30.0 30.0. The lower windows are loading at the start of the run. Frame generation needs a steady 30.

  • Pictures shown per second, same changes on an older base (ed97f31d, feat(oot): M1 Max 60fps docked — frame generation, held-tick fix, RCAS sharpening RobertMrowiec/nxnative#1):

    Render size Walk: mean / worst 20 s segment Camera spin: mean / worst
    1707x960 59.9-60.0 / 59.5 (3 runs)
    1906x1072 59.9 / 58.8
    2048x1152 59.9 / 59.0 (25 segments) 60.0 / 59.8
    2276x1280 59.1 / 56.6
    2439x1372 58.1 / 52.9
  • Without frame generation, the same Mac runs 25-36 fps docked.

  • This branch was then rebased onto 8d44aaf8. The only conflict was in main.c, next to the new DM_PSO_PREBUILD check. The rebased host builds, but it hasn't been played, because main's new shader chain (a5e7fb6e4fa4) needs the game's shaders rebuilt.

How to use it (M1 Max, docked)

env.txt:

DM_FRAMEGEN=1
DM_FG_SCALE=0.5

The tests used a fixed render size of 2048x1152. The game's dynamic resolution doesn't settle on this Mac: its load threshold is measured against a 60 fps budget, so at a 30 fps target it sits at its floor (1067x600). The fixed size comes from a RomFS overlay that sets every docked DynamicResolutionParam level to 0.8, loaded with DM_ROMFS_OVERLAY=<folder holding Pack/>. The generator (scripts/oot_res_overlay.py <RomFS.img> <dir> 0.8 - flat) is a 48-line script on the branch of RobertMrowiec#1 and isn't part of this PR.

How it works

Frame generation (DM_FRAMEGEN=1)

  • Frame rate and display link: main.c sets DM_FPS_TARGET=30 under DM_FRAMEGEN. The display link stays at 60 Hz, and the 30 Hz link and 120 Hz VRR pacing are off while frame generation is on.
  • Order of pictures: on the tick a new frame arrives, the host shows a generated picture between the previous frame and the new one. On the next tick it shows the real frame. This adds one tick of latency (16.7 ms).
  • Interpolation and motion: the picture comes from MetalFX MTL4FXFrameInterpolator. The game gives no motion vectors (its upscaler is spatial), so the host estimates motion itself: block matching on a quarter-resolution luma pyramid with 5 levels.
  • Guard pass: where the motion residual is high, for example Link running against the background, the real frame's pixels are used instead. The thresholds are relative to local contrast (DM_FG_GUARD, DM_FG_GT0, DM_FG_GT1).
  • Detail pass: at DM_FG_SCALE=0.5, interpolation costs about 2.3 ms of GPU time. The real frame's high frequencies, shifted by half the motion, are added back, which scores +4 dB against the true middle frame (DM_FG_DETAIL).
  • Fast camera turns: with block-matched motion, MetalFX's picture breaks into blocks when the camera turns quickly (13-49 permille of the width per real frame in a recording), and the HUD hearts break up. Above DM_FG_MVBAIL permille of mean motion (default 12, 0 = off), the host builds the picture itself. For each pixel it picks the motion that best matches both frames (3x3 luma) out of none, the mean motion and the local estimate, then blends the two frames halfway along it. On those ticks this costs 0.85 ms more. DM_FG_WARP=0 shows the real frame instead. Blocks next to still blocks, such as the HUD, keep zero motion (DM_FG_STILL).

Held-tick fix (nvn_mtl.m, display-link callback)

  • Bug: the callback returned on an empty present queue before checking for a pending held frame. When the game queued its frame just in time, every held tick was dropped and only generated pictures were shown, 30 a second. This showed up as random collapses to 9-17 real frames a second.
  • Fix: the held real frame is now shown on an empty tick. DM_FG_EMPTYHOLD=0 restores the old order. The change only affects frame generation.
  • Measured: before the fix, 17-segment walks at 1707x960 averaged 59.7, 56.3, 59.3 and 60.0, with collapses. After it, 3 of 3 runs averaged 59.9-60.0 with no segment under 59.5.

RCAS sharpening after MetalFX (copy.metal fsr, nvn_mtl.m up_sharpen)

  • FSR1 RCAS with noise damping, run on reversibly tone-mapped HDR. It replaces the existing copy out of the MetalFX spatial scaler, so it adds no pass (DM_UP_SHARPEN 0 to 1, default 0.5).
  • On main, Ocarina of Time docked keeps its own upscaler, so this has no effect on the game yet.

Not in this PR

  • The fork's draw-barrier change is dropped, because e7750541 on main does the same thing better.
  • The fork's CPU/GPU-overlap work (DM_NVN_METERFAKE and shadow copies) isn't ported. main had rewritten that code, and the 30 fps base holds without it.

Known / not tested

🤖 Generated with Claude Code

https://claude.ai/code/session_01DpDXMw11frENUzoe17nZN8

RobertMrowiec and others added 4 commits November 13, 2026 00:36
When MetalFX's spatial scaler takes a game's upscaler (DM_UPSCALER=metalfx), its output was copied to the game's image as is and looked soft. The copy out (copy_t2t_draw) now has a same-size sharpening variant, copy.metal fsr: AMD FidelityFX FSR 1 RCAS (robust contrast-adaptive sharpening, MIT licence) with its noise damping, run on colour tonemapped reversibly (c / (1 + max channel)) so HDR scene colour sharpens like display colour. It replaces the copy, so it adds no pass. DM_UP_SHARPEN sets the sharpness, 0 to 1 (FSR's exp2(-stops)), default 0.5, 0 is the plain copy. The copy's argument block grows from 32 to 48 bytes for the sharpness word.

Also a log line every 600 upscales: a histogram of the game's render size as the upscaler takes it, which shows where a dynamic resolution sits (Ocarina of Time docked on an M1 Max at a 30 fps target sat at its floor, 1067x600).

Checked: the Mac host build; on the user's fork (ed97f31, where Ocarina of Time docked used MetalFX) a played session on an M1 Max with the default 0.5, no added GPU time measured. Not run: on this main, Ocarina of Time docked keeps its own upscaler, so no game here was checked with it; the iPhone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpDXMw11frENUzoe17nZN8
Ocarina of Time (0400486024DEC000) docked needs 22-28 ms of GPU work a frame on an M1 Max (32-core GPU) at any render size: the mesh passes, the compute post-processing and the upscaler barely scale with resolution, so it cannot render 60 there and ran 25-36 fps. With DM_FRAMEGEN=1 the game renders 30 and the host shows 60, a generated picture between each two frames.

- main.c sets DM_FPS_TARGET=30 under DM_FRAMEGEN. The display link stays at 60 Hz: the 30 Hz power-saving link and the 120 Hz variable-refresh pacing are off while frame generation is on.
- On the tick a new frame arrives, the host shows a picture between the previous frame and the new one; on the next tick, the frame itself. One tick (16.7 ms) of latency.
- The picture comes from MetalFX's frame interpolator (MTL4FXFrameInterpolator). The game gives no motion vectors (its upscaler is spatial), so the host estimates motion: block matching on a quarter-resolution luma pyramid, 5 levels (fg_core.h).
- A guard pass puts the real frame's pixels back where the motion does not fit (Link running against a still background), with thresholds relative to local contrast (DM_FG_GUARD, DM_FG_GT0 0.6, DM_FG_GT1 1.2). A detail pass carries the real frame's high frequencies into a picture made at DM_FG_SCALE < 1 (0.5: 2.3 ms of GPU, +4 dB against the true middle frame; DM_FG_DETAIL).
- Fast camera turns: MetalFX's output with block-matched motion breaks into blocks (seen in a recording at 13-49 permille of the width per real frame). When the mean motion is above DM_FG_MVBAIL permille of the width (default 12, 0 off), the host makes the picture itself: per pixel, the motion among {none, the mean, the local estimate} whose 3x3 luma matches both frames best, blending the two frames halfway along it (DM_FG_WARP=0: show the real frame instead). +0.85 ms on those ticks. Blocks next to still ones (the HUD) keep zero motion (DM_FG_STILL).
- The held tick shows the real frame even when the game's next frame is not queued yet. The display-link callback returned on an empty queue before it looked at the held frame, so a game that queued just in time lost every held tick and only generated pictures were shown, 30 a second, with drops to 9-17 real frames a second. DM_FG_EMPTYHOLD=0 restores the old order.
- DM_PRESENT_TLOG=<file>: every picture the screen showed (ticket, kind, presented time), for measuring.

Everything is off unless DM_FRAMEGEN is set.

Checked, M1 Max MacBook Pro (64 GB, on AC), docked, Kokiri Forest, the packed Mac app, fixed 2048x1152 render size: the game's base rate 30.0 in steady play on this branch; pictures shown per second over 20 s segments on the older base ed97f31, walk 59.9 mean / 59.0 worst of 25, camera spin 60.0 / 59.8; played sessions held 60 on screen (the player's verdict: character movement feels like 60, fast camera turns still not fully smooth). Before the held-tick fix, 17-segment walks averaged 59.7, 56.3, 59.3 and 60.0 with collapses; after, 3 of 3 runs 59.9-60.0, no segment under 59.5. Not run: Hyrule Field and other heavy areas, other Macs, the iPhone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpDXMw11frENUzoe17nZN8
"burst <n> <path with %d>" saves the next n presented frames (at most 8), one file each, for checking generated pictures against the frames around them (frame generation, nvn_fg.inc).

Checked: the Mac host build, bursts of 4 and 8 frames from Ocarina of Time docked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpDXMw11frENUzoe17nZN8
The note's new section: why an M1 Max cannot render this game's docked frame at 60 (22-28 ms of GPU at any render size), how DM_FRAMEGEN shows 60 from the game's 30 (nvn_fg.inc), its knobs, the fixed 2048x1152 render size through a RomFS overlay (the dynamic resolution sits at its floor at a 30 fps target), and the measured rates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpDXMw11frENUzoe17nZN8
@github-actions github-actions Bot added host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation game: 0400486024DEC000 A game labels Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400486024DEC000 A game host The Horizon host: SVCs, services, threads, memory (dm/host) 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.

1 participant