"""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).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from .item_list import _COLUMNS # the canonical SoA column spec (name, dtype)
if TYPE_CHECKING:
from .clip_scope import ClipScopeTable
from .item_builder import CollectResult, Geometry
__all__ = ["ItemPublisher", "PublishedItemView", "freeze_collect_result"]
# The affine row shape draw2d uses (x' = a*x + b*y + tx); a published transform
# column is an (N, 6) float32 array of these rows.
_TRANSFORM_COLS = 6
[docs]
@dataclass(frozen=True, slots=True)
class PublishedItemView:
"""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
count: int
columns: dict[str, np.ndarray]
order: np.ndarray
transforms: np.ndarray
geometry: tuple[Geometry, ...]
clips: ClipScopeTable
[docs]
def column(self, name: str) -> np.ndarray:
"""Return the frozen (read-only) column ``name``."""
return self.columns[name]
[docs]
@property
def nbytes(self) -> int:
"""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).
"""
total = int(self.order.nbytes) + int(self.transforms.nbytes)
total += sum(int(col.nbytes) for col in self.columns.values())
return total
[docs]
def freeze_collect_result(result: CollectResult, *, version: int) -> PublishedItemView:
"""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.
"""
items = result.items
count = len(items)
columns: dict[str, np.ndarray] = {}
for name, _dt in _COLUMNS:
# ``ItemList.column`` returns a VIEW into the live (mutating) backing
# array; copy it so the published snapshot is owned + isolated, then lock.
frozen = np.array(items.column(name), copy=True)
frozen.flags.writeable = False
columns[name] = frozen
order = items.sorted_order() # already a fresh array (argsort allocates)
order.flags.writeable = False
rows = result.transforms
if rows:
transforms = np.asarray(rows, dtype=np.float32)
else:
transforms = np.empty((0, _TRANSFORM_COLS), dtype=np.float32)
transforms = np.ascontiguousarray(transforms)
transforms.flags.writeable = False
geometry = tuple(result.geometry.get(h) for h in range(len(result.geometry)))
return PublishedItemView(
version=version,
count=count,
columns=columns,
order=order,
transforms=transforms,
geometry=geometry,
clips=result.clips,
)
[docs]
class ItemPublisher:
"""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.
"""
__slots__ = ("_published", "_source_result", "_source_epoch", "_version", "_publish_count", "_reuse_count")
def __init__(self) -> None:
self._published: PublishedItemView | None = None
# The CollectResult the current published view was frozen from, kept by
# identity so a clean-frame republish (same object) is recognised O(1).
self._source_result: CollectResult | None = None
# The cache epoch the current view was frozen at. An in-place P2 patch
# mutates the SAME CollectResult object (identity unchanged) but advances
# the cache epoch, so the epoch -- not identity alone -- is the change
# signal that forces a re-freeze (design §2.7 / §4).
self._source_epoch: int = -1
self._version = 0
self._publish_count = 0
self._reuse_count = 0
[docs]
def publish(self, result: CollectResult, *, epoch: int | None = None) -> PublishedItemView:
"""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.
"""
same_object = self._published is not None and result is self._source_result
same_epoch = epoch is None or epoch == self._source_epoch
if same_object and same_epoch:
# Clean frame: same retained store, no patch since the last freeze ->
# reuse the frozen view verbatim (zero copy, zero new bytes, version
# unchanged). The double-buffer's "republish the same view" branch.
self._reuse_count += 1
return self._published
# Dirty frame: freeze a new immutable view (the previous one stays valid
# for any reader still on it). Bump the version so a consumer can tell the
# buffers changed.
self._version += 1
view = freeze_collect_result(result, version=self._version)
self._published = view
self._source_result = result
if epoch is not None:
self._source_epoch = epoch
self._publish_count += 1
return view
[docs]
@property
def published(self) -> PublishedItemView | None:
"""The currently published view (``None`` before the first :meth:`publish`)."""
return self._published
[docs]
@property
def version(self) -> int:
"""The current publish version (incremented only on a dirty publish)."""
return self._version
[docs]
@property
def publish_count(self) -> int:
"""How many frames froze a new view (dirty publishes). Test/perf introspection."""
return self._publish_count
[docs]
@property
def reuse_count(self) -> int:
"""How many frames reused the existing view (clean, zero-byte). Introspection."""
return self._reuse_count