simvx.graphics.render2d.publish

Render-thread publish/freeze + double-buffer for the item store (design §4).

Increment P1.5 of the build-once 2D pipeline. The retained item store (:class:~simvx.graphics.render2d.cache.RenderItemCache over

class:

~simvx.graphics.render2d.item_list.ItemList) is mutable and lives on the game thread. The pipelined render thread, however, snapshots the legacy Draw2D._ops into an owned per-frame copy precisely because the game thread mutates it (render_packet.py docstring: the op list “previously raced”). A naively game-thread-mutated retained store would reintroduce exactly that race.

This module supplies the design §4 contract that makes the retained store safe to hand to the render thread:

  • The game thread mutates the working :class:ItemList columns (via the cache).

  • At the frame sync point (inside _frame_state_lock where

    func:

    extract_render_packet runs) the store is published: an immutable

    class:

    PublishedItemView snapshot the render thread reads, never the live game-thread columns.

  • A clean frame republishes the SAME view object (zero copy, zero bytes): the cache returned the identical retained :class:CollectResult, so the publisher recognises it and hands back last frame’s frozen view unchanged.

  • A dirty frame freezes a new immutable view from the fresh columns. This is the double-buffer: the previous view stays valid for any render thread still reading it while the next view is built.

Why this is correct (design §4):

  • “clean frame uploads nothing” is consistent with “render thread reads an owned copy” because the clean-frame snapshot is a version stamp + buffer reuse, not a re-copy (the published view is the same object, identity-equal).

  • The frozen columns are contiguous numpy arrays with writeable=False set, so the render thread can never observe a torn / half-written column while the game thread rebuilds the working :class:ItemList for the next frame. They are also GPU-uploadable as-is – forward-compatible with the P1.6 GPU submit and the later N-buffered GPU resources (design §4: “Keep the columns GPU-uploadable”).

Render-target-agnostic (design §2.6): a :class:PublishedItemView is just frozen columns + the tables they reference; the same publish path serves the main framebuffer and a SubViewport target (the SubViewport SRU snapshots the published view for its target).

Module Contents

Classes

PublishedItemView

An immutable, render-thread-readable snapshot of the item store (design §4).

ItemPublisher

Double-buffered publish/freeze of the item store across the thread seam (design §4).

Functions

freeze_collect_result

Freeze a game-thread :class:CollectResult into a :class:PublishedItemView.

Data

API

simvx.graphics.render2d.publish.__all__

[‘ItemPublisher’, ‘PublishedItemView’, ‘freeze_collect_result’]

class simvx.graphics.render2d.publish.PublishedItemView[source]

An immutable, render-thread-readable snapshot of the item store (design §4).

Frozen at the frame sync point from a game-thread :class:CollectResult. The render thread reads this (never the live, mutating columns). Every numpy array is contiguous and has writeable=False set, so a torn / half-written read is impossible and the columns are GPU-uploadable as-is (forward- compatible with P1.6’s GPU buffers and N-buffered resources).

The double-buffer is realised by the publisher handing back the same view object on a clean frame (identity-equal, zero-copy) and a new one on a dirty frame – so a render thread still reading the previous view is never disturbed while the next is built.

Attributes

version Monotonic publish version. A clean frame reuses the previous view (and therefore its version); a dirty frame increments it. The render thread can compare versions to decide “same buffers as last frame -> zero-byte upload” vs “re-upload changed columns” (the P1.6 hook). count Number of live items (the logical length of every column). columns Frozen SoA columns by name (layer, z, seq, pipeline, clip_scope, blend, transform, geometry, texture, flags, item_id), each a read-only length-count array matching the :data:item_list._COLUMNS dtypes. GPU-uploadable directly. order Physical row indices in back-to-front draw order (the :meth:ItemList.sorted_order result), frozen read-only. transforms Frozen (count_transforms, 6) float32 array of the per-item LOCAL affine rows the transform column indexes (design §2.3). Empty (0, 6) when there are no transforms. geometry The captured :class:Geometry tuples the geometry column resolves against (verts/indices). Tuples of Python lists today (the bridge geometry, P1.2); P1.6/P3a replace these with arena slices. They are frozen by convention (the publisher does not alias the live store’s mutable list – it takes its own tuple). clips The :class:ClipScopeTable the clip_scope column indexes. Frozen by convention (the render thread only reads scissor rects).

version: int

None

count: int

None

columns: dict[str, numpy.ndarray]

None

order: numpy.ndarray

None

transforms: numpy.ndarray

None

geometry: tuple[simvx.graphics.render2d.item_builder.Geometry, ...]

None

clips: simvx.graphics.render2d.clip_scope.ClipScopeTable

None

column(name: str) numpy.ndarray[source]

Return the frozen (read-only) column name.

property nbytes: int[source]

Total bytes of the frozen SoA columns + order + transforms.

Used by the concurrency probe to assert a clean frame is zero-extra-byte (the same view object is reused, so no new allocation happens at all).

simvx.graphics.render2d.publish.freeze_collect_result(result: simvx.graphics.render2d.item_builder.CollectResult, *, version: int) simvx.graphics.render2d.publish.PublishedItemView[source]

Freeze a game-thread :class:CollectResult into a :class:PublishedItemView.

Copies each live SoA column into an owned, contiguous, read-only array (the render thread must not observe the game thread rebuilding the working

Class:

ItemList), materialises the draw order once, and packs the per-item LOCAL transform rows into a single (N, 6) float32 array (GPU-uploadable). Geometry tuples are taken into an owned tuple so the published view never aliases the live store’s mutable backing list.

The arrays are made read-only (writeable=False) so an accidental render- thread write is caught rather than silently corrupting the next frame’s working store.

class simvx.graphics.render2d.publish.ItemPublisher[source]

Double-buffered publish/freeze of the item store across the thread seam (design §4).

Lives on the game thread alongside one

Class:

~simvx.graphics.render2d.cache.RenderItemCache. Each frame, after the cache has produced the frame’s :class:CollectResult, the publisher is asked to :meth:publish it at the sync point. It returns the immutable

Class:

PublishedItemView the render thread reads:

  • Clean frame – the cache returned the same retained CollectResult object (identity-equal to the last one published). The publisher recognises this and returns the same :class:PublishedItemView unchanged: zero copy, zero new bytes, version unchanged (the §4 “clean frame uploads nothing” / “version stamp + buffer reuse” path).

  • Dirty frame – a different CollectResult (re-collected). The publisher freezes a new immutable view (incrementing the version) while the previous view stays valid for any render thread still reading it (the double-buffer).

The publisher does NOT lock: ordering is provided by the caller publishing inside _frame_state_lock at the extract_render_packet sync point (design §4 P1 note). The publisher only owns the freeze + identity bookkeeping.

A single fence-gated published view is race-free because the render thread is the sole reader and the game thread only ever replaces the published handle (never mutates a published view in place). N-buffering for multiple frames in flight is OPTIONAL and layers on top (design §4: a single fence-gated SSBO is already race-free; follow the existing reserve_main_slice discipline) – P1.6’s GPU resources add it if needed.

Initialization

__slots__

(‘_published’, ‘_source_result’, ‘_source_epoch’, ‘_version’, ‘_publish_count’, ‘_reuse_count’)

publish(result: simvx.graphics.render2d.item_builder.CollectResult, *, epoch: int | None = None) simvx.graphics.render2d.publish.PublishedItemView[source]

Publish result as an immutable view; reuse the same view if unchanged.

Call at the frame sync point (inside _frame_state_lock). Returns the

Class:

PublishedItemView the render thread should read this frame.

Reuse requires BOTH the same CollectResult object AND (when supplied) the same cache epoch: the

Class:

~simvx.graphics.render2d.cache.RenderItemCache returns the exact same retained object on a clean (skipped) frame and on an in-place patched frame – but a patch advances the cache’s epoch, so passing it here re-freezes after a patch (the patched columns must reach the render thread) while a truly clean frame (same object, same epoch) still reuses the frozen view verbatim. Omit epoch (the P1.5 frame-level-only path) to fall back to pure identity reuse.

property published: simvx.graphics.render2d.publish.PublishedItemView | None[source]

The currently published view (None before the first :meth:publish).

property version: int[source]

The current publish version (incremented only on a dirty publish).

property publish_count: int[source]

How many frames froze a new view (dirty publishes). Test/perf introspection.

property reuse_count: int[source]

How many frames reused the existing view (clean, zero-byte). Introspection.