Repository navigation
Conversation
… the scene before the HUD, its depth and the game's camera (DM_FRAMEGEN, off by default) A prototype for evaluation, not for merging as it is. Between two frames the game draws, the host shows one made by MetalFX's frame interpolator (Metal 4) from the 1440x810 scene before the HUD, the scene's depth and motion vectors reprojected from the game's camera block; the generated scene goes through the game's spatial upscale a second time and is composed under the real frame's HUD, which is never interpolated. The generated frame is presented ahead of its real frame on the present thread (DM_PRESENT_LINK=0), each for half the period; the pacing follows seedds#2241, the presented-time shift and the delayed first engagement an outside patch (framegen-stage1-v4) read as reference. Specific to Ocarina of Time: the camera block's layout and the far-plane test that picks the scene camera, read from one build and not keyed by build ID yet. Played on the Ocarina performance line (cut6) on an M2 MacBook Air: 45-53 shown frames a second from a 30 fps cap, the game's frame rising from about 30 ms to about 37 ms. On main: the host compiles for 0400486024DEC000; no game run and no regress run. With DM_FRAMEGEN unset the hooks return at once. Co-Authored-By: Claude <noreply@anthropic.com>
…the profile key upscaler_sqrt names it, its input is squared into MetalFX's (0400486024DEC000: 1.12 ms of GPU a frame to about 0.1) The upscale step of agl_ndsr_vr_light.pck samples colour the dispatch before it stored as square roots and writes v * |v|, so its code has no sRGB curve for the classifier and the option replaced nothing. DM_NVN_SKIP_CS (diagnostic) drops named compute dispatches, to measure one inside an encoder.
…rpness): MetalFX's picture blended with a bilinear one of the same input, 0.7 for 0400486024DEC000, whose eight square-root upscale variants are named MetalFX's spatial scaler has no strength setting and is crisper than agl's networks. The draw of its result into the game's image blends in the scaler's input, sampled bilinearly, by 1 - sharpness; 1.0 (every other game) is the picture as before. Front-ends: new: the settings list has DM_UPSCALER_SHARPNESS (range 0.0-1.0, shown while DM_UPSCALER is metalfx, default from the game profile)
…n in play, by input size, and drawn by MetalFX; no other variant dispatched in eight situations
…'s upscaler sharpness
|
I tried this on an M1 Pro, 16 GB (macOS 26.6.2), v131072, the diff applied onto The reason is that the hook is never reached:
So on For comparison, #2241 merged onto the same commit does generate on this Mac (numbers in a comment there). |
|
Correction to my earlier comment: I pointed at the wrong network. The Ndve one is the movie network. The upscaler OoT runs in play comes from I tried a host that also accepts the For scale: on this M1 Pro that one dispatch ( |
|
One more data point on this, in case it saves someone the experiment. I let MetalFX draw the
Two runs each, same route. The per-pass profile shows compute dropping about 4 ms a frame, yet the frame's GPU time goes up about 2 ms. I haven't found out why, so I'm leaving it there. |
|
Thank you for running it, and for tracing it to the hook: you are right, and the fault is in this draft, not in your setup. Cause. The hook is in The open pull request that brings this upscaler to Settings for 1440x810. What I'll change. Restack the draft on |
… roandegraaf) into main for the frame-generation restack Co-Authored-By: Claude <noreply@anthropic.com>
… the scene before the HUD, its depth and the game's camera (DM_FRAMEGEN, off by default) A prototype for evaluation, not for merging as it is. Between two frames the game draws, the host shows one made by MetalFX's frame interpolator (Metal 4) from the 1440x810 scene before the HUD, the scene's depth and motion vectors reprojected from the game's camera block; the generated scene goes through the game's spatial upscale a second time and is composed under the real frame's HUD, which is never interpolated. The generated frame is presented ahead of its real frame on the present thread (DM_PRESENT_LINK=0), each for half the period; the pacing follows seedds#2241, the presented-time shift and the delayed first engagement an outside patch (framegen-stage1-v4) read as reference. Specific to Ocarina of Time: the camera block's layout and the far-plane test that picks the scene camera, read from one build and not keyed by build ID yet. Played on the Ocarina performance line (cut6) on an M2 MacBook Air: 45-53 shown frames a second from a 30 fps cap, the game's frame rising from about 30 ms to about 37 ms. On main: the host compiles for 0400486024DEC000; no game run and no regress run. With DM_FRAMEGEN unset the hooks return at once. Co-Authored-By: Claude <noreply@anthropic.com>
…ale of the game's is drawn by MetalFX, or when MetalFX scales straight between the game's textures Without the upscale the hook (up_mfx_encode_from, seedds#1951's chained-upscaler path included) is never reached and nothing was generated, silently: devMlGUE's report on seedds#2279. Compile-checked on main ccd834e + seedds#1951; not run. Co-Authored-By: Claude <noreply@anthropic.com>
…pscaler sharpness blend (seedds#2366's upscaler_sharpness) The real frame's upscale blends a bilinear picture of the scaler's input into MetalFX's result; the generated scene was upscaled bare, so with a sharpness under 1 (Ocarina of Time: 0.7) generated frames would be crisper than real ones. fg_comp blends the generated scene before its upscale in by the same share. Compile-checked; not run. Co-Authored-By: Claude <noreply@anthropic.com>
… hook is its square-root upscaler's MetalFX draw, with [RED] lines when no upscale is drawn by MetalFX The draft generated no frame on main (devMlGUE on seedds#2279): its hook is the game's upscale drawn by MetalFX, and main does not draw Ocarina of Time's upscaler (agl_ndsr_vr_light, square-root colour) with MetalFX. seedds#2366 (roandegraaf) does; this merge carries it. The generated frame takes seedds#2366's sharpness blend as the real frame does. Compile-checked only (make -C dm/host GAME=0400486024DEC000 dmhost; test_game_profile, test_settings, test_dmsh_abi); untested on main: no game run. Co-Authored-By: Claude <noreply@anthropic.com>
|
@devMlGUE thank you again for the report. The branch is now restacked on It is untested on
One thing is new and unseen by us: #2366 softens MetalFX's picture for this game ( |
|
Thanks, and good to know about the square-root colour. That means my MetalFX numbers above shouldn't be trusted: my quick hack fed that network's input to MetalFX as if it were linear, so both the picture and the timing need redoing with #2366. I haven't run the re-stacked branch yet. Same problem as you, my shader library is for the older chain (2afd39090b76), so I need to rebuild it for main first. I'll do that and come back with the three log lines you listed, plus fps and GPU time with and without MetalFX on the M1 Pro at the same settings as before. |
|
Ran the re-stacked branch (f83131c) on the M1 Pro. Rebuilt the shader library for its chain (ded41f8d62ed, 16 minutes) and let the pipelines compile once before measuring. Same route as before (standing, then walking in the field), Your three lines:
Numbers:
(One of the three network runs came out at 43 fps with 13 ms of GPU, clearly not the same scene, so I left it out.) So with the colour done properly my earlier result still holds on this machine: MetalFX is about 1.5–2 ms slower than the network here, not faster. The M1 Pro sits right at the 33 ms line either way, which is why fps doesn't move. Frame generation adds roughly another 4.5 ms of GPU but holds the 30 real frames, with 0 dropped. One more thing from the frame generation log: game enqueue to screen for real frames is p50 ~139 ms, p95 ~141 ms. That seems like a lot of latency to me, is it expected with the encode thread off? I haven't looked at the picture with the 0.7 blend yet, these were unattended runs. I'll play a bit on this build and tell you whether generated frames look different from real ones. |
|
Played it for a few minutes and found something more useful than a picture opinion: with dynamic resolution on, frame generation produces nothing. My play session had I then repeated it on the benchmark route to rule out my playing: same settings as the run in my previous comment, only dynamic resolution switched on. Three windows, 0 generated in all of them (interpolations 0, no depth 0 this time, so it doesn't even get that far), while the game went through scalers for 1440x810 down to 960x540. With dynamic resolution off, the same route gives 252–300 generated per 600. So it's the changing input size that breaks it, not the scene. On the picture, with that caveat (what I was looking at was essentially MetalFX with the 0.7 blend at a dropped resolution, not generated frames): nearby objects look better than before, but distant ones look like modelling clay, soft and with slightly wrong shapes. I'd guess that's the spatial scaler stretching 1280x720 more than the blend, but I haven't separated the two. I'll look again with dynamic resolution off, where frames are actually generated, and compare sharpness 0.7 against 1.0. |
|
Follow-up with dynamic resolution off, same build and settings otherwise (handheld, 30 fps, sharpness left at 0.7). This is the best the game has looked in any of my tests. Distant objects are well defined now, so the modelling-clay look I described came from the dropped resolution, not from MetalFX or the 0.7 blend. I didn't notice generated frames looking sharper or softer than real ones. Frame generation was really running this time. After the first window (loading), the next six showed 36 to 55 frames a second with 211 to 281 generated per 600, no depth 0 throughout, 4 history restarts and 2 "no camera" in total. It doesn't hold 60 while playing on this machine the way it did on the benchmark route: 39 to 194 of every 600 intervals were over 25 ms, and "late" generated frames kept adding up (171 by the end). Latency for real frames jumped between about 59 and 140 ms from window to window. So on an M1 Pro: picture good, pacing uneven, and it needs dynamic resolution off, which is the one setting I'd normally want on for a GPU this size. |
|
A correction to my dynamic resolution report, after reading that benchmark log more carefully: the benchmark run doesn't prove what I said it did. With dynamic resolution on, that run started slower, the first MetalFX upscale only came after the second 600-present window, and the one window left was mostly inside the 300-frame warm-up. So "0 generated" there is just a run that was too short, not a reproduction. Sorry for the noise. The play session is still solid evidence though: several minutes in game, "no depth" going up by about 600 per window, and 64 generated frames in total. Looking at |
|
Thanks for all of the testing and insights, will roll all of this into the next phase of this project. As it stands it's really tailored to OOT but once this is solid the ideal would be to generalize this (per repo guidelines) so it can use DLSS inputs for games that support that and fallback for games that don't have camera targets like OOT (3 modes). |
|
Found why frame generation did nothing for me with dynamic resolution on, and I have a small patch that fixes it here. The scene depth is picked as "the last depth attachment at least 1024 wide". On this Mac dynamic resolution takes the scene down to 953x536 and 854x480, so the real scene depth is never a candidate. What's left is a square ~1712x1712 depth texture (the shadow map, I assume), which doesn't match the scene size, so every frame counts as "no depth". That's the 2,365 "no depth" and 64 generated frames in my earlier session. The patch keeps the last four depth attachments instead and uses the newest one that is exactly the scene's size. The log from a session with it:
That's one play session of about five minutes each side in Hyrule Field, same settings as before (30 fps target, present link off; docked, so the scene starts at 1920x1080 before dynamic resolution drops it), not a benchmark route. Latency stayed where it was without dynamic resolution, 100–150 ms. Two caveats. There is a second rule in the patch for a depth texture larger than the scene with the same shape (scene drawn into part of it); nothing here exercised it, the exact-size match always won, so feel free to drop it. And the extra log line is only there to show which texture was chosen. The picture itself at 854x480 is not something I'd play with, but that's dynamic resolution on this Mac, not frame generation. patch against f83131cdiff --git a/dm/gfx/nvn/nvn_framegen.inc b/dm/gfx/nvn/nvn_framegen.inc
index bae56a47..28617e87 100644
--- a/dm/gfx/nvn/nvn_framegen.inc
+++ b/dm/gfx/nvn/nvn_framegen.inc
@@ -25,7 +25,7 @@ static float g_fg_soft; // the real frame's upscale blends this share of a bil
static _Atomic int g_fg_hooked; // the hook was reached: an upscale of the game's was drawn by MetalFX (up_mfx_encode_from -> fg_frame)
static struct {
id<MTL4FXFrameInterpolator> fi; NSUInteger iw, ih, ow, oh; MTLPixelFormat cvf; int bad;
- id<MTLTexture> c[2], d[2], mv, gen, gen_up, cv0, cv, depth_last, win[FG_WIN];
+ id<MTLTexture> c[2], d[2], mv, gen, gen_up, cv0, cv, depth_seen[4], win[FG_WIN];
uint64_t win_serial[FG_WIN], win_ticket[FG_WIN], win_next;
id<MTL4CommandAllocator> walloc[FG_WIN]; id<MTL4CommandBuffer> wcb[FG_WIN]; id<MTLBuffer> wpar[FG_WIN];
id<MTL4ArgumentTable> margs, wargs; id<MTLRenderPipelineState> mv_pso, comp_pso; MTLPixelFormat comp_fmt; id<MTLFunction> vf, f_mv, f_comp;
@@ -51,11 +51,15 @@ static int fg_on(void) {
}
// The camera block as the game binds it (encode_args_): the scene camera's binding has far plane 25000; the environment probe's (far 10000, one cube face a frame) is not taken
static void fg_cam_note(const float *c) { if (!fg_on() || !(c[57] > 20000.0f)) return; memcpy(FG.cam, c, sizeof FG.cam); FG.cam_frame = (long)atomic_load(&g_cur_frame); }
-// Each render pass (td_note): the last large D32F depth attachment before the upscaler is the scene depth
+// Each render pass (td_note): the last few D32F depth attachments, newest first. The scene depth is the newest of them that holds the scene being upscaled
+// (fg_frame); with dynamic resolution the scene is narrower than a shadow map, so "the last large one" is not it
static void fg_depth_note(MTL4RenderPassDescriptor *pd) {
if (!g_fg_mode || !pd || !fg_on()) return;
id<MTLTexture> d = pd.depthAttachment.texture;
- if (d && d.pixelFormat == MTLPixelFormatDepth32Float && d.textureType == MTLTextureType2D && d.width >= 1024) FG.depth_last = d;
+ if (!d || d.pixelFormat != MTLPixelFormatDepth32Float || d.textureType != MTLTextureType2D || d == FG.depth_seen[0]) return;
+ int n = 3; for (int i = 1; i < 4; i++) if (FG.depth_seen[i] == d) { n = i; break; }
+ for (int i = n; i > 0; i--) FG.depth_seen[i] = FG.depth_seen[i - 1];
+ FG.depth_seen[0] = d;
}
static int fg_inv4(const double a_[16], double r[16]) {
double a[16]; memcpy(a, a_, sizeof a); for (int i = 0; i < 16; i++) r[i] = i % 5 == 0;
@@ -152,7 +156,7 @@ static void fg_barrier(void) { // as up_mfx_encode: MetalFX opens encoders of
[ce endEncoding];
}
// After the game's upscale of this frame (up_mfx_encode): color = the scene, linear RGBA16F (the scaler's input), out = the canvas the upscaled scene was just drawn into
-static void fg_frame(id<MTL4FXSpatialScaler> sc, id<MTLTexture> color, id<MTLTexture> out, const NVNcopyRegion *orr, NSUInteger cw, NSUInteger ch) {
+static void fg_frame(id<MTL4FXSpatialScaler> sc, id<MTLTexture> color, id<MTLTexture> out, const NVNcopyRegion *orr, NSUInteger cw, NSUInteger ch, NSUInteger ix, NSUInteger iy) {
if (!fg_on()) return;
atomic_store(&g_fg_hooked, 1);
long f = (long)atomic_load(&g_cur_frame); NSUInteger ow = (NSUInteger)orr->width, oh = (NSUInteger)orr->height;
@@ -165,13 +169,19 @@ static void fg_frame(id<MTL4FXSpatialScaler> sc, id<MTLTexture> color, id<MTLTex
{ static int seen; if (seen < 300) { seen++; return; } }
if (!fg_pipes() || !fg_setup(cw, ch, ow, oh, out.pixelFormat, sc)) return;
if (g_fg_upto < 1) return;
- id<MTLTexture> dep = FG.depth_last;
- int have_d = dep && dep.width == cw && dep.height == ch, have_cam = FG.cam_frame == f, cur = FG.cur, prev = cur ^ 1;
+ id<MTLTexture> dep = nil; // exactly the scene's size first; else one the scene is a part of that has the scene's shape (not a square shadow map)
+ for (int i = 0; i < 4 && !dep; i++) { id<MTLTexture> d = FG.depth_seen[i]; if (d && d.width == cw && d.height == ch) dep = d; }
+ for (int i = 0; i < 4 && !dep; i++) { id<MTLTexture> d = FG.depth_seen[i]; if (d && d.width >= ix + cw && d.height >= iy + ch && d.width * ch == d.height * cw) dep = d; }
+ int have_d = dep != nil, have_cam = FG.cam_frame == f, cur = FG.cur, prev = cur ^ 1;
if (!have_d) FG.nodepth++; if (!have_cam) FG.nocam++;
+ { static NSUInteger lw, lh, ldw; static int n; NSUInteger dw = dep ? dep.width : 0;
+ if ((lw != cw || lh != ch || ldw != dw) && n < 40) { n++; lw = cw; lh = ch; ldw = dw; char b[96] = ""; int k = 0;
+ for (int i = 0; i < 4; i++) if (FG.depth_seen[i]) k += snprintf(b + k, sizeof b - k, " %lux%lu", (unsigned long)FG.depth_seen[i].width, (unsigned long)FG.depth_seen[i].height);
+ MLOG("frame generation: scene %lux%lu at %lu,%lu, depth used %lux%lu, recent depth attachments:%s", (unsigned long)cw, (unsigned long)ch, (unsigned long)ix, (unsigned long)iy, (unsigned long)dw, (unsigned long)(dep ? dep.height : 0), b); } }
MTLOrigin o0 = MTLOriginMake(0, 0, 0);
id<MTL4ComputeCommandEncoder> b = blit();
enc_b_copy_texture(b, color, 0, 0, o0, MTLSizeMake(cw, ch, 1), FG.c[cur], 0, 0, o0);
- if (have_d) enc_b_copy_texture(b, dep, 0, 0, o0, MTLSizeMake(cw, ch, 1), FG.d[cur], 0, 0, o0);
+ if (have_d) enc_b_copy_texture(b, dep, 0, 0, MTLOriginMake(ix, iy, 0), MTLSizeMake(cw, ch, 1), FG.d[cur], 0, 0, o0);
enc_b_copy_texture(b, out, 0, 0, o0, MTLSizeMake(ow, oh, 1), FG.cv0, 0, 0, o0);
FG.cv = out; FG.gen_ready = 0;
int ok = have_d && have_cam && FG.prev_ok && FG.last_frame == f - 1; double m[16];
diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m
index 0d26722c..dc533217 100644
--- a/dm/gfx/nvn/nvn_mtl.m
+++ b/dm/gfx/nvn/nvn_mtl.m
@@ -16320,7 +16320,7 @@ static int up_mfx_encode(NvShader *cs) {
args_dirty_all(); g_up_mfx_kept++; return 0; // only the scratch image was scaled; the game's shader can still produce the output
}
args_dirty_all();
- if (!direct) { g_fg_soft = soft; fg_frame(sc, color, out, &orr, cw, ch); } // scratch (DM_FRAMEGEN)
+ if (!direct) { g_fg_soft = soft; fg_frame(sc, color, out, &orr, cw, ch, (NSUInteger)ir.xoffset, (NSUInteger)ir.yoffset); } // scratch (DM_FRAMEGEN)
else if (fg_on()) { static int once; if (!once++) MLOG("[RED] frame generation: MetalFX scales straight between the game's own textures here (%s), which the scratch build does not hook; frames are shown as the game draws them", cs->key); }
if (!g_up_mfx_n++) MLOG("upscaler: first dispatch of %s drawn by MetalFX, %dx%d at %d,%d of %lux%lu (format %lu) → %dx%d at %d,%d of %lux%lu (format %lu)%s", cs->key, ir.width, ir.height, ir.xoffset, ir.yoffset,
(unsigned long)in.width, (unsigned long)in.height, (unsigned long)in.pixelFormat, orr.width, orr.height, orr.xoffset, orr.yoffset, (unsigned long)out.width, (unsigned long)out.height, |
…attachment of exactly the scene's size, so it generates with dynamic resolution on The depth was the last D32F attachment at least 1024 wide. With dynamic resolution the scene drops below that (953x536, 854x480 on an M1 Pro), the last large one is then a shadow map, and every frame counted as no depth: 64 generated frames in a session of several minutes. Found, fixed and tested by devMlGUE (seedds#2279): the last four depth attachments are kept and the newest of the scene's size is used; a log line names the choice when it changes. Their figures with the change, dynamic resolution on, M1 Pro: 157-219 generated per 600 shown, no depth 0, 37-44 shown frames a second. Their second rule (a larger depth texture of the scene's shape, with an offset) is left out: nothing exercised it. Checked here: nvn_mtl.m passes a syntax-only compile. Not run here: the host build, tests and a game run. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
@devMlGUE thank you, that was the cause and your fix is in: It is compile-checked only on our side: we still have no shader library for |
|
Played ~42 min on 76e6843 with dynamic resolution on (M1 Pro, handheld, 30 fps target, MetalFX). The depth pick matched the scene size every time while the resolution moved between 854x480 and 1440x810, and "no depth" stayed at 0 for the whole session. Frame gen held a median of ~58 fps shown, and the only red line was the usual one at frame 600 before the first MetalFX upscale. One thing I noticed: when the resolution switches you can see it, mostly on trees and far away stuff. I think that's just the drop to 854x480 rather than frame gen, but I didn't compare with frame gen off. The fix itself looks good to me, thanks! |
A scratch prototype for evaluation, not for merging as it is. It is off unless
DM_FRAMEGEN=1is set, and parts of it are specific to Ocarina of Time (below). It is the same feature as #2241, built on different inputs; this draft exists so the two efforts can find each other.What was tested where. The code was written and played on our Ocarina of Time performance line (cut6, 31 commits ahead of
main); every number below is from that build. This branch is the frame-generation code restacked onmain(78671525) plus #2366, and it is untested onmain: compile-checked only (make -C dm/host GAME=0400486024DEC000 dmhost,test_game_profile,test_settings,test_dmsh_abi), no game run. The first version of this branch generated no frames onmain(found by @devMlGUE, see the comments). Until #2366 merges, this diff includes its changes.Prerequisites
up_mfx_encode. Ocarina of Time's upscaler isagl_ndsr_vr_light.pck's network, whose input is square-root encoded;maindoes not draw it with MetalFX, nvn/upscaler: MetalFX takes an upscaler with square-root colour #2366 does, and this branch is stacked on it (not on Ocarina of Time's chained upscaler (agl_ndsr_vr) through MetalFX #1951, the other open route to the same upscaler). The generated frame takes nvn/upscaler: MetalFX takes an upscaler with square-root colour #2366's sharpness blend as the real frame does. WithDM_FRAMEGEN=1and no MetalFX upscale, the log says so in a[RED]line after 600 presents.DM_FRAMEGEN=1 DM_UPSCALER=metalfx DM_OPMODE=0 DM_DYNAMIC_RESOLUTION=0 DM_FPS_TARGET=30 DM_PRESENT_LINK=0 DM_NVN_ENC_THREAD=0, graphics scale tier 0. Scene 1440x810, canvas 1920x1080.upscaler: first dispatch of ... drawn by MetalFXand a non-zerogeneratedcount in theframe generation on:lines.[RED]line. Until the hook covers that path, run withDM_NVN_MFX_DIRECT=0whenDM_FRAMEGEN=1.What it does
Between each two frames the game draws, the host shows one generated frame, to turn a 30 fps source into 60 shown frames a second.
up_mfx_encode): the 1440x810 scene colour, the scene's depth buffer, and the game's camera.How it differs from #2241
Offline, on the same real frames, a fast camera swing (about 100-150 px a frame) comes out sharp with these inputs and smeared with flow alone. #2241's pacing is used here nearly as it is. Two ideas come from an outside patch read as reference (
framegen-stage1-v4): the presented-time shift for the held real frame, and not engaging during boot.In play (M2 MacBook Air, 16 GB; the user, 2026-11-10)
Handheld, 1440x810 scene, dynamic resolution off, MetalFX upscale,
DM_FPS_TARGET=30,DM_PRESENT_LINK=0, encode thread off, fullscreen window.Known flaws
What is specific to Ocarina of Time
General parts: the interpolator set-up, the motion pass, the HUD mask, the pacing.
Not in this branch
Display-link presents (
DM_PRESENT_LINK=1), the encode thread, iOS, any game but this one, tests, docs, adocs/env_switches.tsvrow.regress.pywas not run: the change is behind a switch that is off by default.🤖 Generated with Claude Code