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). It issues ZERO GPU calls.

  • 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 (see report): (a) The main thread issues no GPU calls: all vkCmd* / acquire / submit / present run on this thread. (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 single command pool / per-frame command buffers. Vulkan requires one pool per recording thread; that holds here because in pipelined mode the render thread is the ONLY thread that records (the main thread issues zero GPU work, invariant (a)). No second pool is created.

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)[source]

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. The render thread is the sole GPU caller. 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.

Initialization

start() None[source]

Spawn the render thread.

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

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[source]

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[source]
property frames_done: int[source]