mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 19:07:47 -04:00
dde7b34f95
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
13 KiB
Markdown
222 lines
13 KiB
Markdown
# 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.
|
|
|
|
```toml
|
|
[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.
|