Each in-between frame warps the newer real frame's finished image by the
in-between camera instead of drawing the scene again:
- per game frame: a snapshot of the list's finished image (taken when the
list closes; R4 flips to a buffer before drawing into it, so the live
surface is not a stable source) and a 2x native image of camera 1/z,
pixel kind and view rasterized from the recorded triangles in painter
order (world / car-or-dome / 2D-HUD-small-view);
- per in-between frame: one grid draw (a vertex per native pixel) that
unprojects world pixels with their depth, moves them by the view's
in-between camera (fg_view_affine) and samples the snapshot at full
resolution, nearest wins; cells over depth edges or non-world pixels are
left to the snapshot underneath; 2D/HUD pixels are put back; cars are
drawn again at their own in-between position.
Measured: ~0.3-0.8 ms CPU and ~0.3-2 ms GPU per in-between frame (was 2-4
ms each), A/G/B max out-of-range 0.3-3 %, worst ratio 1.1-1.5.
[video] frame_generation_redraw / PSX_FRAME_GEN_REDRAW=1 keeps the old path.
Planner/holds: dynres 'over budget' no longer stops cheap reprojected
frames; guest-late trips ignored while they are cheap; allocation and
shader compilation are kept out of the cost estimate. frame_gen JSON:
rp_ms, rp_zn, reprojected, np (why a flip planned none); debug buffer 4 KiB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fg_plan drew the first in-between frame (to measure its cost) only while the
real frame left half the budget. On Windows with the display ring, R4's 30 Hz
real frame costs 2 x 7.2 ms of 33.4: the first frame was never drawn, its cost
never measured, and Smooth motion generated nothing (0 frames/s; macOS, at
2 x 5.7 ms, was under the line). A third of the budget is enough.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- settings.toml parses [video] vsync = "vrr" and a launcher save writes it
back as "vrr" (was dropped / saved as "on"); every mode round-trips in
video_enhancement_settings_test.
- fg_slots counts the display intervals the clock (fg_clock_phase) fills,
with the same 0.985 phase limit. fg_plan_hz and the dynamic resolution
share use it: 59.94 Hz content on a 60 Hz panel (2.002 intervals) plans
and budgets one in-between frame, as shown, not two.
- The real share read on the emulation thread uses atomics: s_fg_flip_vb,
s_fg_swap_ema and a published copy of the generated-frame cost estimate.
- fg_hud_lerp accepts a match at the 8th 2D triangle of the window.
- docs/FRAME_GENERATION.md: the plan's slots and the vrr mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
In-between frames go on one global grid at the display refresh (or the
VRR panel maximum), never faster, across game frames; the interpolation
phase comes from the host clock, so 90/100/144/165/240 Hz and other rates
that are no multiple of 30 work. Up to 15 generated frames per game frame.
Fix: a display buffer that changed size at the same origin (e.g. the first
frame after a save-state load) left a stale entry that every list closed
with, so no two frames ever matched and Smooth motion stayed off for the
rest of the session. The entry is replaced now. frame_gen JSON reports
why the last flip planned nothing (noplan_why).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pairing triangles of consecutive draw lists by key and draw order paired
the wrong ones whenever the game reordered, culled or switched LODs (R4:
a grandstand of one repeated texture turned into a staircase; on a
synthetic version 225 of 226 pairs were wrong). No primitive is paired by
draw order any more.
gte.cpp notes each RTPS/RTPT output (only while generation is on) with an
identity (issuing ra + model-space vertex), its camera-space position and
H, indexed by screen word; gpu.c records each polygon vertex's source
ahead of the triangle (RTH_FG_SRC, never drawn). Per view the camera
motion between the two frames is fitted from the paired camera-space
vertices (RANSAC + least squares); one real frame is redrawn from the
in-between camera: world vertices re-projected, vertices moving against
the world (cars) lerped in camera space, CPU-built vertices moved with
their triangle, HUD/2D unchanged. Moving forward the older frame is
redrawn; uncovered areas show the newer real frame. A verdict rejects
the generated frame (the real one shows) without projections, with few
pairs/inliers, or with a motion no camera makes. Real frames are
unchanged (gl_frame_gen_test).
PSX_PRESENT_SHOT_BURST=N captures the next N composed frames, real and
generated, for checking in-between frames against their neighbours.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A render thread a frame behind lowers the plan's ceiling by one (recovering
one per 2 s) instead of tripping the breaker; the guest waiting on the queue
trips it only when the wait was a stall (a quarter of the game frame). The
real-frame cost plans with CPU time; the GPU span is reported only. The
generated-frame blit copies colour only and marks the stencil stale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[video] frame_generation (opt-in, with render_thread): the render thread keeps
each game frame's replayed draw list, matches the triangles of the last two
(frame_gen.c), and presents in-between frames at the display refresh by
redrawing the newer list into separate surfaces with matched vertices
interpolated. It never runs guest code. Real frames are composed at their
flip and presented after the generated ones, with the same pixels and
order. Generation uses only measured surplus: a plan from render-thread CPU,
generated-frame cost and swap time, a breaker after backpressure or late
guest frames, and a pause while dynamic resolution is over budget. The
render-thread core gains a timed tick hook. Frame-cost queries pause around
generation, so dynamic resolution measures real frames only.
Tests: frame_gen_test, render_thread_test (tick), gl_frame_gen_test (real
frames identical off/on; phase 0/1 equal the endpoints). Docs:
docs/FRAME_GENERATION.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>