Repository navigation
nvn/fg: frame generation, Ocarina of Time at 60 on an M1 Max docked - #2530
Open
RobertMrowiec wants to merge 4 commits into
Open
RobertMrowiec wants to merge 4 commits into
RobertMrowiec wants to merge 4 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withoutDM_UP_SHARPENon the MetalFX path, the host behaves exactly asmaindoes.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
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.nvn/fg: frame generation (nvn_fg.inc,fg_core.h, display-link changes innvn_mtl.m,main.c).host/hid:DM_CTL burst <n> <path%d>captures n consecutive presented frames, used for testing.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
34608c72held 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):Without frame generation, the same Mac runs 25-36 fps docked.
This branch was then rebased onto
8d44aaf8. The only conflict was inmain.c, next to the newDM_PSO_PREBUILDcheck. The rebased host builds, but it hasn't been played, becausemain's new shader chain (a5e7fb6e4fa4) needs the game's shaders rebuilt.How to use it (M1 Max, docked)
env.txt:
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
DynamicResolutionParamlevel to 0.8, loaded withDM_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)main.csetsDM_FPS_TARGET=30underDM_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.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.DM_FG_GUARD,DM_FG_GT0,DM_FG_GT1).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).DM_FG_MVBAILpermille 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=0shows 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)DM_FG_EMPTYHOLD=0restores the old order. The change only affects frame generation.RCAS sharpening after MetalFX (
copy.metalfsr,nvn_mtl.mup_sharpen)DM_UP_SHARPEN0 to 1, default 0.5).main, Ocarina of Time docked keeps its own upscaler, so this has no effect on the game yet.Not in this PR
e7750541onmaindoes the same thing better.DM_NVN_METERFAKEand shadow copies) isn't ported.mainhad rewritten that code, and the 30 fps base holds without it.Known / not tested
9ed48d1011f1b48b's select-form uniform reads as plain reads, compiled at-O1, brought them back at the same GPU time (26.7 ms p50). That fix was a loose metallib in the test app and isn't in this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01DpDXMw11frENUzoe17nZN8