simvx.graphics.renderer.render_packet

Field provenance

Every field mirrors a per-frame attribute the GPU frame reads, each cleared in Renderer.begin_frame (forward.py ~605-616) and rebuilt by the scene adapter:

instances Renderer._instances (forward.py ~64): list of (MeshHandle, transform, material_id, viewport_id). Consumed by _upload_transforms, the shadow pass, occlusion cull, and the forward draw. The 4x4 transform arrays are copied so the main thread may rebuild next frame without touching the in-flight packet. skinned_instances Renderer._skinned_instances (forward.py ~176): (MeshHandle, transform, material_id, joint_matrices). Both numpy arrays per entry are copied. shader_material_submissions Renderer._shader_material_submissions (forward.py ~120): (MeshHandle, transform, material_id, shader_material). The transform is copied; shader_material is a shared, frame-stable object referenced. particle_submissions Renderer._particle_submissions (forward.py ~109): (data, count). data is copied. gpu_particle_submissions Renderer._gpu_particle_submissions (forward.py ~113): (emitter_id, emitter_config). emitter_config is a per-frame dict; shallow-copied so a downstream mutation of the dict object is isolated. materials Renderer._materials (forward.py ~105, set via set_materials). Copied. lights Renderer._lights (forward.py ~106, set via set_lights). Copied. viewports Snapshot of Renderer.viewport_manager.viewports (scene_adapter ~785). Each entry is a :class:ViewportSnapshot carrying the viewport id plus an owned copy of the camera view/proj matrices, rect, and the (shared, not copied) render-target handle. structure_version tree._structure_version at extract time. The velocity and occlusion passes guard prev<->cur pairing on this; the render thread must see the value that matched the snapshotted instance ordering, not a later one. draw2d_ops Snapshot of Draw2D._ops (draw2d.py ~170): the ordered immediate-mode 2D op list a HUD/overlay scene rebuilds every frame on the MAIN thread (Draw2D._reset(); tree.render(Draw2D)). Op is an immutable NamedTuple whose verts/indices lists Draw2D builds fresh per emit and never mutates after appending, so a shallow list(...) copy is a faithful owned snapshot. install_packet binds it as the op source the render thread’s Draw2DPass reads, so the render thread never touches the live global the main thread is concurrently clearing + rebuilding. frame_index Monotonic producer frame counter, for ordering / telemetry / debugging.

Subsystem submission buffers (packetised this wave)

These mirror per-frame submission lists that live inside lazily-created subsystem passes. Each is rebuilt every frame on the MAIN thread (during adapter.submit_scene) and read during recording, so the render thread must read an OWNED copy rather than the live list the main thread is clearing.

tilemap_layers Owned snapshot of Renderer._tilemap_pass._submissions (tilemap_pass.py ~42, populated by submit_layer from SceneAdapter._submit_tilemaps). Each entry is (tile_data: ndarray, tileset_texture_id: int, tile_size: tuple); the tile_data structured array is copied (the main thread reuses TileMap layer buffers across frames). light2d_lights / light2d_occluders Owned snapshots of Renderer._light2d_pass._lights (list of per-light dicts, light2d_pass.py ~65) and ._occluders (list of polygon vertex-lists, ~66). Each dict / polygon is shallow-copied so a downstream mutation of the per-frame entry is isolated from the packet. text_vertices / text_indices Owned copies of Renderer._text_renderer.vertices / .indices (text_renderer.py ~316-326): the CPU MSDF geometry the shared TextRenderer builds each frame from draw_text calls and that OverlayRenderer.render_text uploads + draws via TextPass. The arrays are copied because the TextRenderer reuses its vertex/index buffers next frame (begin_frame resets _char_count and overwrites in place).

Scene-render-unit (SRU) plans

subviewport_srus Ordered list of :class:SubViewportSRU plans, one per live SubViewport, captured on the MAIN thread by :meth:SubViewportManager.build_srus (which reuses the P1 topological order_subviewports so producers precede consumers). Each plan owns the SubViewport’s submitted opaque/skinned instances (transform copies), its camera view/proj matrices, its isolated Draw2D op list, target identity, and the update-mode decision, so the render thread can record the offscreen SRU WITHOUT walking the live tree. Empty when no SubViewport is present or in the synchronous path (which still walks the tree live, byte-identically).

State intentionally NOT snapshotted (and why)

  • _main_base / arena slice info: reservation is a GPU-SSBO concern owned entirely by the render thread (reserve_main_slice + _upload_transforms run there from the packet’s instances). The main thread never reserves.

  • Per-frame HDR / post flags (_hdr_rendered): recomputed by the render thread inside pre_render/render from the snapshotted lists; they are outputs of recording, not inputs to it.

  • Reflection-probe capture (ReflectionProbePass.update_probes): DEFERRED. The probe path is deeply GPU-stateful (per-probe source cubemaps, six face renders, an IBL compute convolution with record-time descriptor mutation, and cube-array copies with many intra-cmd barriers). Packetising it faithfully would require snapshotting six face instance-lists per probe plus replaying the convolution/copy state machine on the render thread, which is out of scope for this wave. Probe-bearing scenes keep the safe skip + one-time warning in pipelined mode (app.py _warn_pipelined_unpacketised / pre_render_fn).

Note: Draw2D._ops (immediate-mode 2D HUD/overlay) was previously in the “not snapshotted” set and raced; it is now draw2d_ops (installed via install_packet). The subsystem buffers (tilemap / 2D-light / 3D-overlay text) and SubViewport SRUs documented above are now packet-owned AND consumed by the render thread: Renderer.install_packet binds them onto the per-frame override attributes the pass read-sites consult, and the pipelined pre_render replays the SubViewport SRUs from the plan via SceneAdapter.render_sru_from_plan (no live-tree walk). Only reflection-probe capture remains deferred in pipelined mode (see above). The synchronous path installs no packet, so every read-site falls back to the live state and stays byte-identical.

CPU-side per-frame render snapshot for the pipelined render thread.

A :class:RenderPacket is an owned, immutable-by-convention snapshot of the renderer’s per-frame submission state, taken on the MAIN thread right after SceneAdapter.submit_scene populates that state. In pipelined render mode the packet is handed to the render thread, which reconstructs the renderer’s per-frame lists from it and then records + submits the GPU frame, while the main thread is free to simulate the next frame and rebuild the renderer’s (now empty) lists without mutating the in-flight packet.

This is the FOUNDATION wave: the packet, its :func:extract_render_packet builder, and the :class:RenderPacketRing double-buffer are defined and unit tested, but no render thread is spawned and the synchronous frame loop is unchanged. The default (synchronous) path never constructs a packet, so it stays byte-identical.

Module Contents

Classes

ViewportSnapshot

Owned snapshot of one viewport’s camera + rect for a single frame.

SubViewportSRU

Owned plan to record one SubViewport offscreen without walking the tree.

RenderPacket

Owned snapshot of the renderer’s per-frame submission state.

RenderPacketRing

Bounded double-buffered handoff between the main and render threads.

Functions

extract_render_packet

Snapshot the renderer’s current per-frame state into an owned RenderPacket.

Data

API

simvx.graphics.renderer.render_packet.__all__

[‘RenderPacket’, ‘RenderPacketRing’, ‘SubViewportSRU’, ‘ViewportSnapshot’, ‘extract_render_packet’]

class simvx.graphics.renderer.render_packet.ViewportSnapshot[source]

Owned snapshot of one viewport’s camera + rect for a single frame.

Matrices are copied so the render thread reads a stable view/proj even after the main thread rebuilds the live Viewport next frame (TAA jitter and the motion-blur matrix update both mutate camera_proj in place during recording, which must happen on the render thread’s owned copy).

vp_id: int

None

x: int

None

y: int

None

width: int

None

height: int

None

camera_view: numpy.ndarray

None

camera_proj: numpy.ndarray

None

render_target: Any | None

None

class simvx.graphics.renderer.render_packet.SubViewportSRU[source]

Owned plan to record one SubViewport offscreen without walking the tree.

Captured on the MAIN thread by :meth:SubViewportManager.build_srus from a SubViewport’s already-submitted offscreen scene. The render thread (next wave) replays it: reserve a transform-SSBO slice for instances + skinned_instances, write those transforms, record the draws into the SubViewport’s renderer target with camera_view / camera_proj, then overlay draw2d_ops. The producer-before-consumer ordering is encoded by this list’s position in :attr:RenderPacket.subviewport_srus (P1 topo sort), so a consumer SRU appears after the producer it samples.

Fields

sru_id Stable identity (id(node)) keying the frustum-visibility cache so SRUs never collide. Mirrors render_to_target(sru_id=...). renderer The SubViewport’s :class:SubViewportRenderer (shared GPU target, not copied: it owns the offscreen image whose bindless slot the main scene samples). The render thread records into it; the main thread does not mutate it concurrently (create/resize happen during extract, before the plan is built). width / height The SRU’s offscreen extent at capture time (drives viewport + the SSBO transform write). clear_colour Per-frame clear colour (transparent_bg decides RGBA), copied. camera_view / camera_proj Owned copies of the SRU camera’s view + projection matrices (None for a 2D-only SubViewport, which uses the screen-size path). screen_size (width, height) float screen size override the 2D path needs. instances / skinned_instances Owned snapshots of the offscreen scene’s submitted opaque / skinned instances (same shape as :attr:RenderPacket.instances / skinned_instances; transform + joint arrays copied). draw2d_ops Owned copy of the SubViewport subtree’s isolated Draw2D op list (the 2D overlay drawn on top of its 3D content via render_draw2d).

sru_id: int

None

renderer: Any

None

width: int

None

height: int

None

clear_colour: tuple[float, float, float, float]

None

camera_view: numpy.ndarray | None

None

camera_proj: numpy.ndarray | None

None

screen_size: tuple[float, float]

None

instances: list[tuple[simvx.graphics.types.MeshHandle, numpy.ndarray, int, int]]

None

skinned_instances: list[tuple[simvx.graphics.types.MeshHandle, numpy.ndarray, int, numpy.ndarray]]

None

draw2d_ops: list[Any]

‘field(…)’

class simvx.graphics.renderer.render_packet.RenderPacket[source]

Owned snapshot of the renderer’s per-frame submission state.

Construct via :func:extract_render_packet. All mutable numpy data is copied at extract time, so the main thread may rebuild the renderer’s per-frame lists for the next frame while this packet is in flight on the render thread.

frame_index: int

None

structure_version: int

None

instances: list[tuple[simvx.graphics.types.MeshHandle, numpy.ndarray, int, int]]

None

skinned_instances: list[tuple[simvx.graphics.types.MeshHandle, numpy.ndarray, int, numpy.ndarray]]

None

shader_material_submissions: list[tuple[simvx.graphics.types.MeshHandle, numpy.ndarray, int, Any]]

None

particle_submissions: list[tuple[numpy.ndarray, int]]

None

gpu_particle_submissions: list[tuple[int, dict]]

None

materials: numpy.ndarray

None

lights: numpy.ndarray

None

viewports: list[simvx.graphics.renderer.render_packet.ViewportSnapshot]

‘field(…)’

draw2d_ops: list[Any]

‘field(…)’

tilemap_layers: list[tuple[numpy.ndarray, int, tuple[float, float]]]

‘field(…)’

light2d_lights: list[dict]

‘field(…)’

light2d_occluders: list[list[tuple[float, float]]]

‘field(…)’

text_vertices: numpy.ndarray | None

None

text_indices: numpy.ndarray | None

None

subviewport_srus: list[simvx.graphics.renderer.render_packet.SubViewportSRU]

‘field(…)’

simvx.graphics.renderer.render_packet.extract_render_packet(renderer: simvx.graphics.renderer.forward.Renderer, tree: simvx.core.SceneTree, *, frame_index: int = 0, sub_viewports: Any = None) simvx.graphics.renderer.render_packet.RenderPacket[source]

Snapshot the renderer’s current per-frame state into an owned RenderPacket.

Call on the MAIN thread immediately after adapter.submit_scene(tree), before Renderer.begin_frame clears the lists for the next frame. Numpy arrays are copied (ownership transferred to the packet) so the producer can safely rebuild the live lists while this packet is consumed by the render thread.

Args: renderer: The forward renderer whose per-frame lists to snapshot. tree: The scene tree (read for _structure_version and SubViewport discovery when sub_viewports is supplied). frame_index: Monotonic producer frame counter stamped on the packet. sub_viewports: Optional :class:SubViewportManager. When supplied the packet captures ordered SubViewport SRU plans (so the render thread can record them without walking the tree). None (the default and the unit-test path) yields an empty subviewport_srus.

class simvx.graphics.renderer.render_packet.RenderPacketRing(capacity: int = 2)[source]

Bounded double-buffered handoff between the main and render threads.

The ring IS the double-buffer: a fixed capacity (default 2) of packet slots with backpressure. submit blocks the producer (main thread) once it is one frame ahead, bounding latency to +1 frame (D6). acquire blocks the consumer (render thread) until a packet is available. The render thread calls release after it has finished the GPU frame for a packet, freeing the slot so the producer may run ahead again.

Shutdown is cooperative: close wakes any blocked thread. After close, submit raises and acquire drains remaining packets then returns None so the consumer loop exits cleanly.

Initialization

property capacity: int[source]
property closed: bool[source]
submit(packet: simvx.graphics.renderer.render_packet.RenderPacket) None[source]

Producer: enqueue a packet, blocking while the ring is full.

Raises RuntimeError if the ring has been closed.

acquire(timeout: float | None = None) simvx.graphics.renderer.render_packet.RenderPacket | None[source]

Consumer: dequeue the next packet, blocking until one is available.

Returns None if the ring is closed and drained (consumer should exit), or if timeout elapses with no packet.

release() None[source]

Consumer: signal that the most recently acquired packet’s GPU frame is done.

Frees one in-flight slot so the producer may run ahead again.

close() None[source]

Mark the ring closed and wake all blocked threads for clean shutdown.

pending() int[source]

Number of packets queued but not yet acquired (diagnostics/tests).