simvx.graphics.renderer.render_thread

Dedicated render thread driving the pipelined GPU frame (opt-in, default OFF).

In pipelined mode (App(render_thread=True) or WorldEnvironment render_mode='pipelined') the MAIN thread simulates frame N+1 while this thread records + submits the GPU work for frame N. The split:

  • MAIN THREAD: glfwPollEvents (window events must stay on main), physics + tick, Draw2D, adapter.submit_scene (building the renderer’s per-frame submission lists), then extract_render_packet into a CPU

    class:

    ~.render_packet.RenderPacket, then ring.submit(packet). The FRAME is entirely this thread’s; the main thread issues no part of it. It does issue GPU calls of its own, and the invariants below say which.

  • RENDER THREAD (this driver): ring.acquire a packet, install_packet it onto the renderer’s per-frame attributes (under the renderer’s _frame_state_lock), then run the engine’s existing GPU frame body (wait_and_reset fence, vkAcquireNextImageKHR, record pre_render + the render pass, vkQueueSubmit, vkQueuePresentKHR, sync.advance), then ring.release.

Invariants enforced here (the design is design/render_parallelism_and_multi_gpu.md): (a) The frame command pool and its per-frame command buffers are touched by this thread, or by the main thread while it holds renderer._frame_state_lock, and by nothing else. That is mutual exclusion, which is what a VkCommandPool’s external-synchronisation rule asks for; it is NOT “one thread only”, and the difference is load bearing. Installing a skybox reaches IBLPass.process_cubemap from renderer.sync_render_state(), which the main thread runs inside that lock, so an animated sky_mode="colour" scene allocates from this pool on the main thread once a frame and is correct in doing so. (b) This thread is the ONLY writer of the GPU SSBOs (_upload_transforms / reserve_main_slice run here from the packet) and wait_and_reset gates reuse, so a single GPU SSBO is safe (no per-frame GPU ring). (c) The pre_render / render closures read the renderer’s per-frame attributes, which install_packet has just bound to the PACKET’s owned snapshot; the _frame_state_lock makes the install + record region mutually exclusive with the main thread’s begin_frame + submit_scene, so the main thread never tears those attributes mid-record. (d) +1 frame latency is bounded by the 2-slot ring’s backpressure. (e) No deadlock on quit: stop closes the ring (waking a producer blocked on backpressure and this consumer blocked on acquire) and joins.

Command pool: this thread reuses the engine’s frame command pool (engine._cmd_ctx) and its per-frame command buffers, under invariant (a). Allocating from a pool another thread is recording into is undefined behaviour independently of any queue synchronisation, so a caller that reaches the frame pool from outside the frame-state lock is a defect however well its submit is serialised.

That is a property of the frame pool alone, not of the process. The engine has other pools, and they exist so that the tick-time work which is NOT under the frame-state lock has somewhere else to go: the one-shot uploads reachable from application code allocate from engine._upload_ctx (a lock-held pool of its own, see :class:~simvx.graphics.gpu.commands.UploadContext), the pick pass owns one, and each multi-device staging path owns one. A LUT registered from a node’s on_update is the shape that pool exists for, and it runs on the main thread with no frame-state lock held at all. Submits from those paths are serialised against this thread’s frame submit by the queue’s own lock, which

class:

~simvx.graphics.gpu.queue.SubmitQueue holds.

Checking any of this by reading lock._owner does not work: threading.RLock exposes no owner in CPython 3.13+, so the read reports “not held” for every caller and manufactures a violation everywhere. Count the depth in a wrapper.

Module Contents

Classes

RenderThread

Consumer thread: installs render packets and records + submits GPU frames.

Data

API

simvx.graphics.renderer.render_thread.log

‘getLogger(…)’

simvx.graphics.renderer.render_thread.__all__

[‘RenderThread’]

class simvx.graphics.renderer.render_thread.RenderThread(engine: simvx.graphics.engine.Engine, renderer: simvx.graphics.renderer.forward.Renderer, ring: simvx.graphics.renderer.render_packet.RenderPacketRing, *, draw_frame: collections.abc.Callable[[], None] | None = None, capture: collections.abc.Callable[[int, Any], None] | None = None, wants_capture: collections.abc.Callable[[int], bool] | None = None)

Consumer thread: installs render packets and records + submits GPU frames.

Args: engine: The :class:Engine owning the swapchain, queues, sync, and the per-frame command buffers. This thread issues the whole GPU frame; the main thread’s own GPU work is bounded by invariant (a). renderer: The forward :class:Renderer whose per-frame attributes a packet is installed onto before recording. ring: The :class:RenderPacketRing the main thread submits packets to. draw_frame: The engine’s GPU-frame body to invoke per packet. Defaults to engine._draw_frame. It records pre_render + the render pass (via the engine’s pre_render / render callbacks), submits, and presents. capture: Called ON THIS THREAD with (frame_index, rgba) for a frame the sink asked for. None for a sink that never reads frames back. wants_capture: Asked once per packet, before the frame is recorded, whether the sink wants THIS frame. Defaults to wanting every frame, which is what a caller passing capture without it means.

Initialization

start() None

Spawn the render thread.

wait_for_frame(frame_index: int, timeout: float | None = None) bool

Block until the GPU frame for frame_index has been submitted + presented.

Used by the headless capture path to sequence capture_frame after the render thread has finished drawing the frame. Returns True once _frames_done has passed frame_index, False on timeout.

Raises the render thread’s captured exception if it crashed before reaching frame_index: the awaited frame will never arrive, so the caller must learn promptly rather than block forever or silently proceed on a stale frame. The crash handler’s notify_all wakes a waiter that is blocked with timeout=None so this raise happens at once.

stop(timeout: float | None = 5.0) None

Close the ring and join the thread; re-raise any thread exception.

close wakes a producer blocked on backpressure AND this consumer blocked on acquire (invariant (e)). The thread drains remaining packets, then exits. Re-raises any exception the thread captured so a render-thread crash surfaces on the main thread rather than vanishing.

property alive: bool
property frames_done: int