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 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-07 13:09:21 -07:00
parent 3c64a13347
commit 0bebface12
6 changed files with 124 additions and 9 deletions
+3
View File
@@ -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`. */
+1
View File
@@ -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
+61 -2
View File
@@ -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;
}
}
}
+38 -6
View File
@@ -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 ------------------------------------------------ */
+3 -1
View File
@@ -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
+18
View File
@@ -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();