Source code for 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).
"""

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