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), thenextract_render_packetinto a CPU- class:
~.render_packet.RenderPacket, thenring.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.acquirea packet,install_packetit 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_resetfence,vkAcquireNextImageKHR, record pre_render + the render pass,vkQueueSubmit,vkQueuePresentKHR,sync.advance), thenring.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.SubmitQueueholds.
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¶
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:
Engineowning 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:Rendererwhose per-frame attributes a packet is installed onto before recording. ring: The :class:RenderPacketRingthe main thread submits packets to. draw_frame: The engine’s GPU-frame body to invoke per packet. Defaults toengine._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.Nonefor 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 passingcapturewithout 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_indexhas been submitted + presented.Used by the headless capture path to sequence
capture_frameafter the render thread has finished drawing the frame. ReturnsTrueonce_frames_donehas passedframe_index,Falseon 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’snotify_allwakes a waiter that is blocked withtimeout=Noneso this raise happens at once.
- stop(timeout: float | None = 5.0) None¶
Close the ring and join the thread; re-raise any thread exception.
closewakes a producer blocked on backpressure AND this consumer blocked onacquire(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¶