From 0bebface129e3e11d25d4e63b7ebe3e9d7abfb2f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:09:21 -0700 Subject: [PATCH] Smooth motion: budget dynres for the in-between frames, time them from the game frame's arrival, keep small views put, move HUD needles - Dynamic resolution budgets the real frames with the share of the game frame Smooth motion leaves them (gl_renderer_frame_gen_real_share), so the scale drops until the in-between frames fit the display refresh. - In-between frames are timed from the game frame's arrival, not from the end of its composition; the last one per game frame is no longer dropped by a quarter-interval margin. - Views under 15 % of the largest (a rear-view mirror) stay as the real frame drew them: a wrong mirror camera fit threw its lane marks across the screen as a dashed line. - HUD: 2D triangles both frames draw with the same key that moved a little (tachometer needle) are interpolated; changed digits stay as drawn. - frame_gen JSON: end_ahead / end_bp / end_phase counters. Co-Authored-By: Claude Opus 5.5 --- runtime/include/frame_gen.h | 3 ++ runtime/include/gpu_gl_renderer.h | 1 + runtime/src/frame_gen.c | 63 ++++++++++++++++++++++++++++++- runtime/src/gpu_gl_renderer.c | 44 ++++++++++++++++++--- runtime/src/main.cpp | 4 +- runtime/tests/test_frame_gen.c | 18 +++++++++ 6 files changed, 124 insertions(+), 9 deletions(-) diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index adb0bd86..98c582d8 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -145,6 +145,9 @@ void fg_cam_place(const FgPrimList *newer, FgCamFit *fit, const FgVert *verts, * 0 when it would land on the next real frame. */ double fg_step_s(double flip_s, double refresh_hz, int n); double fg_clock_phase(uint64_t since_real_ns, uint64_t step_ns, uint64_t flip_ns); +/* HUD motion between two frames' 2D triangles (frame_gen.c). */ +void fg_hud_lerp(const FgPrimList *L, const FgPrimList *O, double u, float *x, float *y, + float max_px); /* The refresh fg_plan sees: rounded up to whole slots per game frame. */ double fg_plan_hz(double flip_s, double refresh_hz); /* The next present time on the global grid after presenting at `now`. */ diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 0ce21b8c..bf2a1c14 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -342,6 +342,7 @@ void gl_renderer_fg_source(const uint32_t id[3], const int32_t pc[9], const int3 const int32_t x[3], const int32_t y[3]); int gl_renderer_frame_generation(void); void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz); +double gl_renderer_frame_gen_real_share(void); void gl_renderer_frame_gen_hold(const char *reason, double secs); int gl_renderer_frame_gen_json(char *out, int cap); /* Host time the renderer spent, as running totals in performance-counter diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 6da684e6..3148efb3 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -604,6 +604,24 @@ void fg_cam_place(const FgPrimList *newer, FgCamFit *fit, const FgVert *verts, free(done); fit->clamped = clamped; fit->guessed = guessed; + /* Small views (a rear-view mirror inside the main view) stay as the + * real frame drew them: their camera is fitted from a few dozen + * vertices, and a wrong fit threw their lane marks across the screen as + * a dashed line. At this size their 30 Hz motion does not show. */ + { + float big = 0.0f; + for (uint32_t vi = 0; vi < fit->nviews; vi++) { + const float *ar = fit->v[vi].area; + const float a = (ar[2] - ar[0]) * (ar[3] - ar[1]); + if (a > big) big = a; + } + for (uint32_t i = 0; i < n * 3u; i++) { + const int vi = verts[i].view; + if (vi < 0 || (uint32_t)vi >= fit->nviews) continue; + const float *ar = fit->v[vi].area; + if ((ar[2] - ar[0]) * (ar[3] - ar[1]) < 0.15f * big) { dxs[i] = 0.0f; dys[i] = 0.0f; } + } + } for (uint32_t i = 0; i < n * 3u; i++) { x[i] += dxs[i]; y[i] += dys[i]; } /* Margins: vertices on or past a view edge that moved inward. */ if (margin) @@ -661,8 +679,12 @@ uint64_t fg_next_due(uint64_t due, uint64_t now, uint64_t step_ns) { double fg_clock_phase(uint64_t since_real_ns, uint64_t step_ns, uint64_t flip_ns) { if (flip_ns == 0u) return 0.0; double t = ((double)since_real_ns + (double)step_ns) / (double)flip_ns; - /* Within a quarter interval of the next real frame it is the real one. */ - if (t >= 1.0 - 0.25 * (double)step_ns / (double)flip_ns) return 0.0; + /* At (or past) the next real frame's time it is the real one. The + * phase is where the camera is when this frame is seen, one interval + * from now, so the last in-between frame of a game frame lands close + * to 1 (a quarter-interval margin here dropped it at every refresh). */ + (void)step_ns; + if (t >= 0.985) return 0.0; return t; } @@ -774,3 +796,40 @@ int fg_ceiling_get(FgCeiling *c, double now) { } return c->cap; } + +/* HUD motion: 2D triangles (no GTE projection: gauges, needles) of the + * redrawn list L that the other frame O draws too (same key: op, texture, + * colour, UVs, in the same order among the 2D triangles) and that moved a + * little (at most max_px per corner: a tachometer needle, a sliding panel) + * are placed at fraction u of the way from L to O. Anything that changed + * what it draws (digits) or jumped stays as L drew it. */ +void fg_hud_lerp(const FgPrimList *L, const FgPrimList *O, double u, float *x, float *y, + float max_px) { + if (!L || !O || u <= 0.0 || u >= 1.0) return; + uint32_t o = 0; + for (uint32_t j = 0; j < L->n; j++) { + const FgPrim *a = &L->v[j]; + if (a->vid[0] || a->vid[1] || a->vid[2]) continue; + uint32_t m = o, seen = 0; + for (; m < O->n && seen < 8u; m++) { + const FgPrim *b = &O->v[m]; + if (b->vid[0] || b->vid[1] || b->vid[2]) continue; + seen++; + if (b->key == a->key && b->view == a->view) break; + } + if (m >= O->n || seen >= 8u) continue; + const FgPrim *b = &O->v[m]; + o = m + 1; + int ok = 1, moved = 0; + for (int k = 0; k < 3; k++) { + const float dx = b->x[k] - a->x[k], dy = b->y[k] - a->y[k]; + if (fabsf(dx) > max_px || fabsf(dy) > max_px) ok = 0; + if (dx != 0.0f || dy != 0.0f) moved = 1; + } + if (!ok || !moved) continue; + for (int k = 0; k < 3; k++) { + x[3 * j + k] = a->x[k] + (b->x[k] - a->x[k]) * (float)u; + y[3 * j + k] = a->y[k] + (b->y[k] - a->y[k]) * (float)u; + } + } +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 155946bb..215c8abd 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -10220,7 +10220,8 @@ static uint64_t s_fg_q_cpu[4]; static uint64_t s_fg_generated = 0, s_fg_real_presents = 0, s_fg_flips = 0, s_fg_flushed = 0, s_fg_skipped_plan = 0, s_fg_dups = 0; static int s_fg_last_n = 0, s_fg_last_slots = 0; -static const char *s_fg_noplan_why = ""; /* why the last flip planned nothing */ +static const char *s_fg_noplan_why = ""; +static uint64_t s_fg_end_ahead = 0, s_fg_end_bp = 0, s_fg_end_phase = 0; /* why a game frame's in-betweens stopped early */ /* why the last flip planned nothing */ static double s_fg_last_match_ms = 0.0; static double fg_now_s(void) { return (double)rt_now_ns() * 1e-9; } @@ -10692,7 +10693,7 @@ static int fg_rect_meets(int x, int y, int w, int h, const int *disp) { /* Redraw list b at phase t (0 = a's positions, 1 = b's) into the surfaces * currently bound as hr / the displayed wide surface. */ static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wide) { - (void)a; /* in-between frames redraw the newer frame only */ + /* b: the redrawn frame; a: the other one (its HUD motion only). */ const int *disp = b->disp; /* Every vertex's position at this phase (the newer frame from an * in-between camera, frame_gen.h). */ @@ -10708,6 +10709,7 @@ static void fg_replay(const FgList *a, const FgList *b, double t, GLuint gen_wid } if (s_fg_pos_cap >= b->prims.n) { fg_cam_place(&b->prims, &s_fg_fit, s_fg_verts, t, s_fg_px, s_fg_py, s_fg_margin); + if (a) fg_hud_lerp(&b->prims, &a->prims, 1.0 - t, s_fg_px, s_fg_py, 24.0f); placed = 1; } } @@ -10914,7 +10916,7 @@ static int fg_generate(double t, int swap) { rect_add(&s_stencil_stale, L->disp[0], ly, L->disp[0] + L->disp[2] - 1, ly + lh - 1); if (iw >= 0) wst_add(iw, 0, ly, g_wide_w, ly + lh); s_fg_drawing = 1; - fg_replay(NULL, L, tl, iw >= 0 ? s_fg_w_fbo : 0); + fg_replay(s_fg_src_older ? b : a, L, tl, iw >= 0 ? s_fg_w_fbo : 0); flush_line_batch(); flush_flat_batch(); flush_tex_batch(); @@ -11134,6 +11136,7 @@ static uint64_t fg_tick(void *user, uint64_t now) { /* Backpressure itself is judged at the flip, by how long the guest * waited (fg_on_present); here it only shows the real frame now. */ if (bp == s_fg_bp_seen && ahead >= 2 && fg_recently_generated()) fg_overload(); + if (s_fg_k <= s_fg_n) { if (bp != s_fg_bp_seen) s_fg_end_bp++; else s_fg_end_ahead++; } s_fg_bp_seen = bp; fg_flush(); return 0; @@ -11154,6 +11157,7 @@ static uint64_t fg_tick(void *user, uint64_t now) { return s_fg_due; } s_fg_flushed++; + s_fg_end_phase++; } fg_present_real(); return 0; @@ -11162,6 +11166,12 @@ static uint64_t fg_tick(void *user, uint64_t now) { /* A present record replayed with frame generation on. Returns 1 when it was * handled (scheduled or dropped), 0 to present it as usual. */ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stale) { + /* The game frame's arrival: its in-between frames are timed from here, + * not from when its composition finished, because the next game frame + * arrives one game frame after this one whatever the composition cost + * (timed from the end, the last in-between frame of every game frame + * collided with the next one and was dropped). */ + const uint64_t arrive_ns = rt_now_ns(); RthOvHdr hd; memcpy(&hd, p, sizeof hd); const int32_t *a = hd.args; @@ -11360,7 +11370,7 @@ static int fg_on_present(uint16_t op, const uint8_t *p, uint32_t bytes, int stal s_fg_capture_real = 0; s_fg_pa = s_fg_older; s_fg_pb = s_fg_newer; s_fg_n = n; s_fg_k = 1; - s_fg_t0 = rt_now_ns(); + s_fg_t0 = arrive_ns; s_fg_flip_ns = (uint64_t)(flip_s * 1e9); s_fg_step_ns = (uint64_t)(fg_step_s(flip_s, rhz, n) * 1e9); if (s_fg_due < s_fg_t0 || s_fg_due > s_fg_t0 + s_fg_step_ns) s_fg_due = s_fg_t0; @@ -11394,6 +11404,26 @@ void gl_renderer_fg_source(const uint32_t id[3], const int32_t pc[9], const int3 pc[0], pc[1], pc[2], pc[3], pc[4], pc[5], pc[6], pc[7], pc[8], h[0], h[1], h[2]); } +/* The share of a game frame the real frame may use so Smooth motion still + * fills every display interval: (0.85 flip - slots-1 generated frames and + * their swaps) / flip, never below 0.35. 1 while generation is off or its + * cost is unknown. Dynamic resolution budgets the real frames with it, so + * the scale drops until the in-between frames fit (they draw at the same + * scale, so they get cheaper too). */ +double gl_renderer_frame_gen_real_share(void) { + if (!s_fg_on || !rth_record_mode()) return 1.0; + const double ghz = atomic_load(&s_fg_guest_hz), rhz = atomic_load(&s_fg_refresh_hz); + const double flip_s = (double)s_fg_flip_vb / (ghz > 1.0 ? ghz : 59.94); + if (flip_s <= 0.0 || rhz <= 0.0 || s_fg_flip_vb < 2) return 1.0; + const int slots = (int)ceil(flip_s * rhz - 1e-6); + const double g = s_fg_cost.ema > 0.0 ? s_fg_cost.ema + s_fg_swap_ema : 0.0; + if (slots < 2 || g <= 0.0) return 1.0; + double share = (0.85 * flip_s - (double)(slots - 1) * g) / flip_s; + if (share < 0.35) share = 0.35; + if (share > 1.0) share = 1.0; + return share; +} + void gl_renderer_frame_gen_configure(double refresh_hz, double guest_hz) { atomic_store(&s_fg_refresh_hz, refresh_hz); if (guest_hz > 1.0) atomic_store(&s_fg_guest_hz, guest_hz); @@ -11438,7 +11468,7 @@ int gl_renderer_frame_gen_json(char *out, int cap) { "\"trips_late\":%u,\"trips_backed_up\":%u,\"trips_behind\":%u," "\"place_camera\":%u,\"place_object\":%u,\"place_neighbour\":%u," "\"place_unchanged\":%u,\"cam_angle_deg\":%.3f,\"cam_shift\":%.1f," - "\"clamped\":%u,\"guessed\":%u,\"verdict_ok\":%d,\"rejected\":%llu,\"reject_why\":\"%s\",\"noplan_why\":\"%s\"", + "\"clamped\":%u,\"guessed\":%u,\"verdict_ok\":%d,\"rejected\":%llu,\"reject_why\":\"%s\",\"noplan_why\":\"%s\",\"end_ahead\":%llu,\"end_bp\":%llu,\"end_phase\":%llu", s_fg_on, s_fg_on && s_rth_on && !s_hd_native_authority && open, s_fg_force, (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, (unsigned long long)s_fg_flips, (unsigned long long)s_fg_dups, @@ -11463,7 +11493,9 @@ int gl_renderer_frame_gen_json(char *out, int cap) { s_fg_fit.v[0].t[2] * s_fg_fit.v[0].t[2]), s_fg_fit.clamped, s_fg_fit.guessed, s_fg_fit.ok, (unsigned long long)s_fg_rejected, s_fg_reject_why ? s_fg_reject_why : "", - s_fg_noplan_why ? s_fg_noplan_why : ""); + s_fg_noplan_why ? s_fg_noplan_why : "", + (unsigned long long)s_fg_end_ahead, (unsigned long long)s_fg_end_bp, + (unsigned long long)s_fg_end_phase); } /* ---- the recording vtable ------------------------------------------------ */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c86e95f7..8017b254 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9421,7 +9421,9 @@ static void dynres_tick_rt(double now_s, double wall, double period, int held, g_dynres.rt_acc_gpu_frames += co.gpu_frames - c0.gpu_frames; g_dynres.last_costs = co; g_dynres.last_bp_ns = bp_ns; - DynrtSample smp{ period, wall, frames, cost, bp, held }; + /* Smooth motion's in-between frames share the game frame with it. */ + const double share = gl_renderer_frame_gen_real_share(); + DynrtSample smp{ period * share, wall, frames, cost, bp, held }; const int prev_level = c.level; const int level = dynrt_sample(&c, now_s, &smp); /* Frame generation only spends surplus: not while the real frames are diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c index f391996d..5352eed2 100644 --- a/runtime/tests/test_frame_gen.c +++ b/runtime/tests/test_frame_gen.c @@ -360,7 +360,25 @@ static void test_any_rate(void) { } } +static void test_hud_lerp(void) { + FgPrimList L = {0}, O = {0}; + FgPrim p; memset(&p, 0, sizeof p); + p.key = 7; p.view = 1; + p.x[0] = 10; p.y[0] = 10; p.x[1] = 20; p.y[1] = 10; p.x[2] = 15; p.y[2] = 30; + fg_prims_add(&L, &p); /* needle */ + p.key = 9; fg_prims_add(&L, &p); /* a digit */ + FgPrim q = L.v[0]; q.x[2] = 25; fg_prims_add(&O, &q); /* needle tip moved 10 px */ + q = L.v[1]; q.key = 10; fg_prims_add(&O, &q); /* the digit changed */ + float x[6], y[6]; + for (int i = 0; i < 6; i++) { x[i] = L.v[i / 3].x[i % 3]; y[i] = L.v[i / 3].y[i % 3]; } + fg_hud_lerp(&L, &O, 0.5, x, y, 24.0f); + check(x[2] == 20.0f && x[0] == 10.0f, "HUD needle halfway between the frames"); + check(x[5] == 15.0f, "a changed digit stays as drawn"); + fg_prims_free(&L); fg_prims_free(&O); +} + int main(void) { + test_hud_lerp(); test_any_rate(); test_ceiling(); test_cost();