Files
psxrecomp/docs/RENDER_THREAD.md
2026-10-07 14:25:57 -07:00

13 KiB

Render thread ([video] render_thread)

Opt-in, OpenGL only, off by default. With it off nothing in this document runs: gl_backend_get() returns the existing table and the GL renderer is the synchronous path every title already uses.

[video]
render_thread = true   # game.toml; PSX_RENDER_THREAD=0/1 overrides

PSX_RENDER_THREAD_FRAMES=N (default 2) bounds the closed frames in flight.

Why

Before this change one thread ran the recompiled guest, turned every GP0 command into GL calls (gpu_gl_renderer.c), presented, and paid every Apple GL-on-Metal stall: command-buffer waits, uploads, readbacks, the swap. A frame cost emu + GL. With the render thread a frame costs max(emu, GL), and the guest waits for rendering only at the sync points below.

Model

emulation thread                         render thread (owns the GL context)
----------------                         -----------------------------------
guest + gpu.c (GP0/GP1, VRAM array,      replays records in order through the
status, timing)                          unchanged glb_* functions; presents
  gr_* -> GL_RT_BACKEND (record) ---->   ring (32 MiB, SPSC) ---->  exec()
  present  (record + overlay copy)
  vblank: frame boundary --------------> frame marker; <= N closed frames
  sync point: drain, take context <----  release context, park
  ... direct GL calls for the rest of the frame ...
  frame boundary: hand context back ---> make current, continue

Pieces, with file:line on feat/render-thread:

  • Core — runtime/src/render_thread.c, runtime/include/render_thread.h. Single-producer/single-consumer byte ring with monotonic 64-bit positions. A record never splits across the ring end (a pad record fills the tail); payloads are copied, and space is freed only after exec() returns. No lock is held while a record executes; a mutex and two condition variables guard sleeping only (each side raises its *_sleeping flag under the lock before re-checking, the waker publishes its position before reading the flag). The consumer spins briefly before sleeping; the producer wakes a sleeping consumer every 128 KiB / 256 records and always at a frame end, drain or sync point. On macOS the thread is created with QOS_CLASS_USER_INTERACTIVE: with the default QoS a busy host scheduled it onto efficiency cores and it fell behind the emulation thread (R4 4K race on a host at load average ~40: 40 Hz with the default class, 60 Hz with this one).
  • Frame bound — rt_frame_end() blocks while more than N closed frames are unconsumed (backpressure). rt_frames_ahead() tells the replaying thread how many complete frames are queued behind the current one.
  • Sync point — rt_acquire(): drain, have the render thread release the context (glFlush, SDL_GL_MakeCurrent(win, NULL)), make it current on the emulation thread. The emulation thread then holds the context and calls the backend directly, exactly as with no render thread, until the next frame boundary hands it back (rt_release()). So a sync point costs at most one hand-off per frame, and code that is not converted to recording stays correct.
  • Recording table — GL_RT_BACKEND (gpu_gl_renderer.c:8469), selected by gl_backend_get() while the thread runs (gr_refresh_backend() in gpu_render.c). Every state call, primitive, fill, copy, CPU->VRAM upload, single-pixel write and native-wide call is recorded by value; queries are answered from emulation-side mirrors or are sync points (table below).
  • Hand-off rules — gl_renderer_render_thread_frame_boundary() (:8653), called from sdl_vblank_present() after the present and before wall-clock pacing (main.cpp:8650). It releases a held context when the frame may run asynchronously (gl_rth_eligible, :8173), takes it when it may not, then closes the frame.

Guest-visible VRAM stays on the emulation thread

The GL backend is GPU-authoritative: s_hr_fbo holds VRAM, gpu.c's array is refreshed only by readbacks (ensure_cpu, :2395), and pending uploads stage from that same array (flush_cpu_upload, :838). gpu.c writes the array directly while a GP0(A0) payload streams in (gpu.c:6912) and hands the facade a copy at commit (gpu.c:3156). If the render thread staged from the live array it would upload pixels of a later transfer (and its sw_vram_transfer_in would write stale payloads back over the guest's array).

So the backend's s_vram is gpu.c's array whenever the emulation thread runs GL, and a private copy while the render thread does (gl_rth_acquire/gl_rth_release, :8181/:8188; the SW rasterizer's pointer moves with it, sw_renderer_rebind_vram). The private copy gets each upload payload in command order; a release first copies gpu.c's array into it, so it matches the guest at every hand-off. Every readback runs on the emulation thread under a sync point and writes gpu.c's array, as before.

Guest state the backend reads while drawing

State Read by Under the render thread
per-prim widescreen tags (psx_ws_prim_in_backdrop, psx_ws_prim_is_tagged) bd_prim_gate :2533 captured per primitive in the record flags (rth_prim_flags) when native-wide and the backdrop stretch are on; replay reads them via ctx_prim_* (:379)
native-wide fast-path latch (gpu_ws_background_requires_full_composite, gpu_ws_netplay_local_viewport_width), flat backdrop wide_fast_center_valid :2569 (also at batch flush and present) sent as an RTH_STATE record before the first call that would see a change
depth24 display (gpu_display_is_depth24) depth24_upload_policy :4096 and the depth24 helpers a depth24 display is a sync point (rth_record_mode, :8202); the render thread only replays 15-bit frames, so ctx_depth24() is 0 there
host overlays (OSD, volume, rewind strip, savestate menu) gl_swap_with_osd :7543 rasterized and copied at record time (rth_record_present, :8216); host_osd_present_done runs at record time
present-shot request gl_swap_with_osd already mutex-guarded (main.cpp present_shot_take)

Emulation-side answers without a sync point: gl_renderer_projective_supported (:3854) and gl_renderer_present_wide_fbo's return value (:7981) from a mirror of the wide-surface bookkeeping (rth_mirror_wide_for, :8121, the same lookup, limit check and slot allocation wide_fbo_for does), and gr_get_draw_area from the recorded draw area. gr_scale, gr_texture_filter and gl_renderer_fit_wide_aspect read values that only change while the emulation thread holds the context. The mirrors are re-read from the backend at every release.

Sync points found in the code

Guest-visible:

Where Why
GPUREAD gpu_read_gpuread (gpu.c:3743) -> gr_vram_read VRAM->CPU transfer reads the FBO (glb_vram_read -> ensure_cpu)
GP0(A0) with mask check (gpu.c:6912) per-pixel mask test reads VRAM
gpu_vram_peek (gpu.c:6889) debug/mod VRAM reads
savestate / rewind save (boot_state.c:148,159,189,356) gr_vram_transfer_out of all VRAM
savestate load (gl_renderer_restage_vram_after_savestate, :5112) full restage from gpu.c's array
depth24 (FMV) display the CPU mirror is presented; the emulation thread draws those frames

Not guest-visible but GL-owned (each is GL_RT_SYNC at the top of the entry point): gr_render_display*, gr_render_wide_display, gr_wide_dump_full (screenshots, CPU present fallback), every gl_renderer_pass_*/stereo_* (render passes), psx_mod_openxr_*, texture-bank selection, settings (scanlines, aspect, gamma, bezel, swap interval, interpolation), the CPU present / blank / hold-last presents (pause, rewind, netplay hold), and the debug-server diagnostics (gl_* rings, frame_perf, gl_fbo_peek, gl_vram_diff). Two per-frame debug captures are queued instead of being sync points: the display ring's readback (gl_renderer_fbo_peek_deferred) and the --headless-opengl presented-image capture (gl_renderer_ring_capture); their ring readers sync first (gl_renderer_render_thread_sync). ensure_cpu refuses to run on the render thread: a readback there would land in the private upload source and mark the CPU side current without gpu.c's array seeing the pixels.

What is not a sync point: a frame of primitives, fills, copies, uploads, native-wide draws and the 15-bit present (gl_renderer_present_vram / present_wide_fbo). A Ridge Racer Type 4 race loaded from a savestate takes one sync point (the load) and then none.

Frames, backpressure, stale presents

At most N closed frames wait in the ring; past that the emulation thread blocks in rt_frame_end. The render thread must replay every frame's drawing: later frames sample what earlier frames rendered, so drawing cannot be skipped. Only the present can. When two newer frames are already recorded (the emulation thread is at the bound, waiting), the present of the frame being replayed is skipped (rth_replay_present, :8250), never twice in a row, so a saturated render thread still shows at least every other frame.

Consequence: the render thread takes the GL cost off the guest's frame, but if GL alone exceeds the frame budget the guest is still held back by the bound. Choosing an internal resolution that fits is the job of the resolution controller (roadmap Phase 1.2), not of this layer.

Interoperation

Feature Behaviour
savestates / rewind load and save are sync points; the frame stays synchronous, the next frame boundary releases
netplay / rollback resimulation psx_netplay_active() or dual-raster makes every frame ineligible: the emulation thread holds the context (synchronous path). Not started when netplay is configured at boot
render passes first gl_renderer_pass_* call in a frame is a sync point; the rest of that frame is synchronous. Recommended: leave render_thread off with the frame-rate mod
frame interpolation ineligible (held) while enabled; not started when it is on at boot
OpenXR ineligible while a session is active
native-wide / widescreen recorded; tags, latch and wide-surface mirror as above
internal resolution changes gr_set_scale and every resolution entry point are sync points
screenshots / debug captures screenshot* sync; present_shot is fulfilled on the render thread
headless --headless (software) never starts it; --headless-opengl runs it on the hidden context (no present; the frame boundary still closes every frame), which is what fp_identity exercises
Vulkan / software never started (log line says why)

Out of scope

  • Vulkan (the same split would apply; its backend has its own readbacks).
  • Converting the remaining sync points to recorded commands (texture banks, render passes, CPU/hold presents). Each is correct as a sync point.
  • Making replay cheaper (batching across records, fewer GL state changes).
  • Diagnostic rings written by both threads (latency_ring, gpu_timeline) can interleave entries; they are diagnostics only.

Tests

  • render_thread_test (runtime/tests/test_render_thread.c): the core against a fake executor and context token — ordering and payload integrity over 400 frames of random-size records through ring wraps, oversize refusal, the in-flight bound under a slow consumer (and that the one- and two-frames-behind cases both occur), 300 randomized acquire/release rounds with the context never current on two threads, stop while held. Clean under ThreadSanitizer.
  • gl_render_thread_test (runtime/tests/run_gl_render_thread.py, test_gl_render_thread.c, label gpu;opengl;hardware): a real hidden GL context, a scripted stream through the facade, render thread off vs on at 1x and 4x; the values the guest read back, native VRAM, the frame at S and the native-wide surface must be identical. The stream has mask set/check, a mask-checked upload (per-pixel readback sync point), back-to-back uploads of one rect with a catch-up between gpu.c's writes and its commit, more than the ring's size recorded, readback frames, presents with a slow render-thread present, and widescreen tags that change per primitive and are poisoned after each call. It fails for each of these deliberate mistakes: replay reading the tags live, staging uploads from the guest array, dropping the stream state.
  • video_enhancement_settings_test: the key defaults off and parses.
  • Windows (MinGW): the runtime builds with the Win32 path of render_thread.c (SRW lock, condition variable, CreateThread), and render_thread_test and gl_render_thread_test run there (the GL test exits 77, a CTest skip, on a host with no GL 3.3 context).
  • In game (R4, see the PR): tools/fp_identity.py guest identity render thread off vs on under --headless-opengl, and A/B frame rates from a race savestate at Native, 4K and Match display, 4:3 and widescreen.

Debug server

{"cmd":"render_thread"} — active, held, max_frames, records, bytes, frames produced/consumed, presents and presents_stale (skipped), acquires (sync points) with the 16 most recent reasons and wait times, backpressure / ring-full waits and time, render busy/idle time, ring high water. Reading it is not a sync point.