# Conflicts: # runtime/include/frame_gen.h # runtime/src/gpu_gl_renderer.c # runtime/src/main.cpp
15 KiB
Smooth motion (frame generation, [video] frame_generation)
Opt-in, OpenGL with the render thread only, off by default
(PSX_FRAME_GEN=0/1 overrides for one run). Off, nothing in this document
runs and the renderer is the render thread of docs/RENDER_THREAD.md.
[video]
render_thread = true
frame_generation = true
# How in-between frames are made: "redraw" (default) or "reprojection".
frame_generation_method = "redraw"
frame_generation_method (PSX_FRAME_GEN_METHOD=redraw|reprojection
overrides it for one run):
redraw(the default for every title): each in-between frame draws the recorded list again from an in-between camera (How, below).reprojection(opt-in per title, in itsgame.toml): each in-between frame warps the newer real frame's finished image (Reprojection, below). It is cheaper (about a millisecond of GPU per frame) but its object rules were tuned on R4 and are assumptions about how a game draws; on other titles it has shown flicker (THPS2), so no title gets it unless it opts in.
With Smooth motion off, or in redraw, nothing of reprojection runs or is
allocated: no snapshots, depth image, warp programs, HUD matching, per-frame
getenv or uniform lookups. Switching to redraw (or Smooth motion off)
frees what reprojection held.
Rule
Real frames at the guest's rate come first and resolution is the only quality lever for them (dynamic resolution). Frames above that are a bonus, drawn by the render thread from recorded data only: no guest code runs again, nothing is snapshotted or rolled back, and only when the render thread has measured time to spare.
How
-
Lists. The render thread keeps a copy of every replayed state and drawing record (not transfers, copies, presents, steps). Display buffers are learned from presents; a list is one game frame's drawing into one buffer and closes when drawing moves to another buffer, or when the game flips to the buffer being drawn. (R4 starts drawing the next frame a VBlank before it flips, so a flip alone is not a frame boundary.)
-
Cadence. A flip (a present of a different display address) is a new game frame; a present of the same buffer is the same frame again. R4 updates every other VBlank, so generation interpolates between flips (30 Hz game frames), not between VBlanks.
-
Sources. While generation is on, every RTPS/RTPT output is noted (gte.cpp,
gte_fg_source_*) with an identity (the issuing functionraand the model-space vertex), the camera-space position the GTE divided and H, indexed by the packed screen word. The same point projected again in a frame (a corner shared by separate meshes, another function) is one vertex (the smaller identity); up to two different points on one pixel are kept, and gpu.c picks the one projected by the same function as the triangle's other vertices, else the one nearest their depth. gpu.c looks each polygon vertex up by its packet word and records the sources ahead of the triangle (RTH_FG_SRC, never drawn). CPU-built vertices have none. Guest-visible GTE results are untouched. -
Camera (
frame_gen.c,fg_cam_fit). No primitive is paired by draw order. Per view (draw area), vertices of the two frames with the same identity are paired in camera space (instances: the clearly nearest) and a rigid motion is fitted (RANSAC + least squares, inliers within 1 screen px at their depth). The static world agrees with it; paired vertices that disagree are objects (cars, the followed car). Verdict: no projections, a view with few pairs or under half inliers, or a turn/travel no camera makes between two frames rejects the generated frame (the real frame shows). -
Drawing (
fg_cam_place). One real frame is redrawn from an in-between camera: world vertices re-projected through the fraction of the fitted motion, object vertices lerped in camera space, CPU-built vertices of placed triangles moved with their neighbours, everything else (HUD, 2D, sprites, the mirror's own view if unprojected) unchanged. Moving forward the older frame is redrawn (seen from further on it spreads past the screen edges); otherwise the newer. Drawing starts on the newer real frame's image and skips the clears before the first draw, and strips along a view's edges the picture moved away from are copied from that image, so what the in-between camera uncovers shows the newer frame instead of a hole. Every placed vertex at one screen position moves alike (a weld: the same corner reaches the GPU from several projections), and a vertex without a projection lying on an edge between placed vertices moves as that point of the edge (closes the T-junctions the game's polygon splits leave). A game frame whose in-between camera would pass through geometry (a vertex behind it: tunnel ceilings, overhead bridges) is not generated: without near-plane clipping its image would be wrong. The raw texture mirror is not packed from the generated surfaces; all bookkeeping the draws touch is restored, so the real stream is unaffected. -
Schedule. With
nin-between frames per game frame, at the flip the real frame is composed and kept (its buffer may be drawn over before it is shown), framest = k/(n+1)are presentedflip/(n+1)apart, then the kept real frame. Timed work runs from the render-thread core'stickhook between records and while idle. -
Latency. Interpolation shows a real frame after the in-between frames that lead to it: up to
n/(n+1)of a game frame later than without generation (R4 at 30 Hz on 120 Hz: 25 ms at n = 3, 17 ms at n = 1). Extrapolation (no delay, guessed motion) is not offered. -
Plan.
n = fg_plan(...): slots =fg_slots, the display intervals the clock actually fills (in-between frames at phase k / (flip × refresh) below 0.985, plus the real one: R4 on 120 Hz: 4; 59.94 Hz content on a 60 Hz panel, 2.002 intervals: 2, one in-between frame), limited to what fits in 85 % of the interval after the real frames' render-thread CPU time and one swap, at the measured cost of a generated frame (CPU, GPU viaGL_TIME_ELAPSED, plus a swap). On macOS a real frame'sTIME_ELAPSEDspan reads close to the whole interval (it counts the GPU waiting for records), so it is not used here; GPU overload shows as backpressure. The real frames' measured cost excludes generation (its query is paused, segments summed); dynamic resolution adds the generated frames actually drawn, each at the mean measured cost of the timed ones, to its load. So it steps up while the render thread has room for both, and the in-between frames count against the budget once they crowd the real ones (no reserve assumed for frames not drawn). With the present thread ([video] present_thread, docs/RENDER_THREAD.md) a swap here is the hand-off to it, and its cost is the time spent waiting for a free slot, not the compositor's round trip. -
Breaker. Off for 3 s (doubling to 24 s on repeats within 10 s) after the queue backed up (the guest waited), the render thread fell two frames behind, or a guest frame took over 1.5 intervals. While dynamic resolution is over budget on the real frames alone (the last window's load less the generated frames' part) or stepping down, generation pauses (no escalation). One frame behind just shows the waiting real frame at once.
-
Sync points show the waiting real frame and restart the lists at the next flip.
-
Variable refresh.
[video] vsync = "vrr"(game.toml, or the player's settings.toml, which a launcher save writes back as"vrr"): for G-Sync / FreeSync / ProMotion panels. Each frame is presented when it is ready (swap interval 0); Smooth motion targets the panel's maximum refresh at the current size and never exceeds it. The other modes are"on"(default),"immediate"("off") and"adaptive".PSX_HOST_REFRESH_HZ/PSX_HOST_REFRESH_MAX_HZsimulate a display in headless tests.
Reprojection (frame_generation_method = "reprojection")
- Source. At each flip the newer list's finished image is copied
(
rp_snapshot_list): the game draws on into its buffers while the in-between frames are shown. The camera is fitted for the newer frame (fg_cam_fit, a view that fails leaves its vertices unchanged; a third of the pairs is enough, since whatever moves on its own is redrawn). - Depth image (once per game frame, 2 texels per native pixel): the newer list's recorded triangles in painter order, each pixel one of world (every corner placed by the camera: its 1/z and view), object, keep, or HUD needle.
- Warp (per in-between frame, one draw): a grid with a vertex per native
pixel; a world vertex is unprojected with its depth, moved by its view's
in-between camera (
fg_view_affine) and projected again (nearest wins); cells over a depth edge, a view boundary or a non-world pixel are not drawn, so the snapshot underneath shows there (disocclusion is filled with the newer frame). Keep pixels (2D, HUD, the mirror) are then put back from the snapshot; needle pixels from the older snapshot. The warp's depth is cleared before anything else is drawn (with PGXP depth the cars would otherwise testLEQUALagainst it). - Objects. Cars are drawn again from the recorded list at their own
in-between position; HUD needles are moved as
fg_hud_lerpmoves them. - Breaker. Each method has its own breaker settings, set again when the method changes: reprojection holds 0.1 s (at most 1 s; redraw 0.5 s to 8 s). Dynamic resolution over budget does not pause reprojection (its frames cost little; a step down still pauses it 0.05 s), and a late guest frame trips it only when its in-between frames cost over 3 ms of CPU. The generated-frame cost estimate moves at most 4x per sample.
- Dynamic resolution budgets the real frames as their cost against their share of the frame (redraw: the share of the period).
Object rules (R4-tuned assumptions)
These rules decide what the warp moves and what is drawn again. They were tuned on R4; a title that opts in gets them as they are:
- Cars are triangles the fit calls objects (
FG_PLACE_OBJECT) whose corners really moved against the world (more than 4 % of their depth from where the camera alone puts them; near road subdivided past the fit's tolerance is world), textured (an untextured gradient is backdrop), and small (at most 96 x 64 native px). Anything else the fit moves on its own (a sky dome) is backdrop the warp keeps where it is. - Backdrop. A view's triangles drawn before its first world triangle are backdrop and are not drawn again.
- HUD needles are 2D triangles at most 48 px on a side that moved at most
24 px per corner between the two frames and drew the same thing
(
fg_hud_match); they are drawn at their in-between position over the older frame's dial. - HUD protection. 2D pieces up to 160 x 120 px (rects and triangles) of the newer and the older list are keep pixels: the newer frame's pixels are shown there, never warped. Big 2D (a backdrop drawn first) is not.
- Small views (a view whose area is under 15 % of the biggest, R4's mirror) are keep: shown as the newer frame drew them.
- Split screen. Each view is fitted on its own; one view's failed fit leaves the others reprojected.
Diagnostics (read once when the programs are built): PSX_RP_DEBUG=1 shows
the warp grid, =2 the pixel kinds; PSX_RP_NOPREV=1 draws needles over the
newer frame's dial. frame_gen reports reproject, reprojected,
reproject_fallback, rp_ms and rp_zn.
Limits
- Only GTE-projected triangles move; sprites, lines and fills are the
redrawn frame's. Areas uncovered by the in-between camera show the newer
frame. Vertices without a projection that are neither on a placed edge nor
shared take their triangle's mean motion (
guessedcounts those over a 1 px spread; 0 in R4 races once shared corners resolve). - A texture the frame drew earlier into its own displayed buffer is sampled as the raw mirror holds it when the in-between frame is drawn.
- Single-buffered games never flip, so nothing is generated.
- Windowed high-resolution mode and depth24 frames are not generated.
- HD texture replacements and dumping: generated frames draw with the same replacements as real frames (HUD/2D included); they are presentation only, so native VRAM and dumps see only real frames (generated draws suppress dump queries).
Debug
{"cmd":"frame_gen"} (not a sync point): generated / real presents, flips,
dups, plan (last_n, slots), costs (real_ms, gen_ms, swap_ms), last
camera fit (views, pairs, inliers, place_*, cam_angle_deg,
cam_shift), verdict (verdict_ok, rejected, reject_why), breaker,
ceiling, last hold, total swaps.
PSX_PRESENT_SHOT_GENERATED=1 (diagnostic): a staged present_shot is
taken at the next generated frame instead of the next present, to inspect
in-between images in game. PSX_PRESENT_SHOT_BURST=N: a staged
present_shot takes the next N composed frames (written after the burst as
<path>_NN_{r|g}_f<flip>.png, in composition order: a real frame precedes
the in-between frames that lead up to it).
Tests
frame_gen_test: the camera fit on a synthetic grandstand of one repeated texture, redrawn in another order with a row culled and one added (the case the old draw-order matcher paired wrongly: 225 of 226 pairs) - phase 0 / 0.5 / 1 land where the older / halfway / newer camera sees each vertex; a followed car, a HUD triangle, a CPU-built vertex; the verdict (no projections, a cut, an impossible turn, inconsistent motion); the plan, breaker, cost, pace, ceiling.render_thread_test: the tick hook runs on the render thread with the context, never early, and wakes an idle thread.gl_frame_gen_test(real GL,gpu;opengl;hardware): a double-buffered 30 Hz scene, VRAM and native-wide presents, flipping right after drawing and a VBlank late: the distinct real presented images are identical with generation off and on, generated frames are presented, and an in-between frame composed at t = 1 / t = 0 equals the newer / older real frame. In redraw (the default) the fixture checks that nothing of reprojection ran or was allocated and prints the phase 0.5 image's digest (p05=), which matches master's. WithFG_METHOD=reprojectionthe warp's pixels are checked: with a parallax pan (a projected backdrop twice as far as the triangles) phase 0.5 matches the redraw of the same pair (under 5 % of the pixels differ, under half of what the unwarped newer frame differs), every pixel is a real frame's or the redraw's (no holes), the HUD is exactly the newer frame's, the real frames are those of generation off, and with PGXP depth the warp leaves no depth behind for the cars.