Source code for simvx.graphics.render2d.cache

"""Retained render-item cache: frame + item + transform-only granularity (design §2.7).

The unified ``RenderItemCache`` (design §2.7 / Decision C / §7.6). It retains the
last collected+sorted item set for a scene and, riding the **automatic**
invalidation contract (P2), re-uploads only what changed:

- **frame level** (P1.4) -- whole-tree clean (no dirty drawable, structure +
  view + theme unchanged, not ``dynamic``) -> reuse the retained set verbatim,
  skipping walk + sort entirely;
- **item level** (P2) -- a render-dirty drawable (a colour / texture / text /
  visible Property change, via the blanket descriptor hook now that ``Node2D``
  has ``queue_redraw``) re-captures only that node's ``on_draw`` and overwrites
  its geometry + item columns in place -- one item re-uploaded, the rest retained;
- **transform-only level** (P2) -- a transform-dirty drawable (a position / scale
  / rotation change, via the observed-vec / ``_invalidate_transform`` path)
  rewrites that node's LOCAL transform row in place (the scroll-by-translation /
  moving-sprite fast path).

The auto-invalidation contract (design §2.7 "what marks dirty"), the can't-forget-
a-property property
--------------------------------------------------------------------------------
Nothing here hand-enumerates which Property dirties what. The cache reads the two
render-retention bits the core :class:`~simvx.core._drawable2d.Drawable2D` mixin
carries on every 2D drawable. Those bits are set by the EXISTING generic hooks:

- the **blanket** ``Property.__set__ -> obj.queue_redraw()`` hook
  (``descriptors.py``) on ANY changed Property of a drawable -> ``_render_dirty``
  (P0 gate 3's "no-op for Node2D" caveat is fixed: ``Node2D`` now defines
  ``queue_redraw`` via the mixin, so the hook fires for colour/text/visible/
  texture/size/anchor/margin/...);
- the ``_ObservedVec._notify -> _invalidate_transform`` path + the direct
  ``_invalidate_transform()`` on spatial replacement (``_spatial_property.py``)
  -> ``_transform_render_dirty`` (move, including in-place ``+=``);
- ``rotation``'s ``on_change="_invalidate_transform"`` -> ``_transform_render_dirty``;
- ``z_index``/``z_as_relative``'s ``on_change="_invalidate_z_cache"`` ->
  ``_render_dirty`` on self + descendants (z is structural, design §2.2);
- structural changes -> ``SceneTree._structure_version`` bump (compared here);
- theme generation -> a global counter compared here (the theme bridge, design
  §2.7: a ``set_theme`` forces one non-skipped frame that re-collects themed
  Controls -- they have no per-widget theme Property to fire the blanket hook);
- ``CanvasLayer.offset/rotation/scale_val/layer`` -> ``on_change`` marks the
  layer subtree render-dirty (CanvasLayer is a ``Node``, covered by the mixin).

So a property write on a node marks the right dirty bit with no per-property list:
adding a new Property cannot "forget" to invalidate, because the descriptor hook
is blanket. The render bits are SEPARATE from ``Node2D._transform_dirty`` (which a
mid-frame ``world_position`` read clears) and persist until this cache drains them
at the end of the patch step (design §2.7 / P0 gate 3b).

The ``dynamic`` opt-out (design §2.7 "time-driven draws", P0 gate 3c)
--------------------------------------------------------------------
A node whose ``on_draw`` reads non-Property state (``tree.now`` animations, an
``AnimatedSprite`` frame advance) writes no tracked Property, so the hooks never
fire. Such a node is marked ``dynamic`` (a ``_render_dynamic`` attribute or the
cache-level predicate) and is treated as render-dirty EVERY frame; a subtree
containing a dynamic node never frame-skips. ``queue_redraw()`` remains the manual
escape hatch.

Render-thread safety (design §4)
--------------------------------
The cache runs on the game thread and mutates the working store in place (item-
level / transform-only patches). The render thread reads only the *published*
frozen view (P1.5 publish/freeze), never the live store; an in-place patch is
visible to the render thread only after the next publish at the frame sync point.
The cache exposes :attr:`epoch` -- bumped on every collect OR patch -- so the
publisher freezes a new view after a patch (an in-place patch keeps the
``CollectResult`` object identity, so identity-based reuse alone would wrongly
reuse the stale frozen view; the epoch is the change signal).
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from typing import Any

from .item_builder import CollectResult, ItemBuilder, _bake_affine, _OpRecorder, affine_row

__all__ = ["RenderItemCache", "ViewState"]


[docs] class ViewState: """The view/UBO inputs a collection was built under (design §2.3 seam). A compact, equality-comparable bundle of the camera + viewport state that the frame-level skip compares frame to frame. Today (pre-P3b) any change forces a re-collect, because the per-frame view UBO that would let the view change *without* dirtying items is P3b work. The class exists so that seam is named: P3b relaxes "view changed -> re-collect" to "view changed -> rewrite one UBO row, items stay clean". ``None`` is a valid value (no camera / unknown view) and compares equal to another ``None`` view, so a scene with no Camera2D still skips when clean. """ __slots__ = ("offset", "zoom", "rotation", "viewport") def __init__( self, *, offset: tuple[float, float] = (0.0, 0.0), zoom: tuple[float, float] = (1.0, 1.0), rotation: float = 0.0, viewport: tuple[int, int] = (0, 0), ) -> None: self.offset = (float(offset[0]), float(offset[1])) self.zoom = (float(zoom[0]), float(zoom[1])) self.rotation = float(rotation) self.viewport = (int(viewport[0]), int(viewport[1])) def _key(self) -> tuple: return (self.offset, self.zoom, self.rotation, self.viewport)
[docs] def __eq__(self, other: object) -> bool: if not isinstance(other, ViewState): return NotImplemented return self._key() == other._key()
[docs] def __hash__(self) -> int: return hash(self._key())
[docs] def __repr__(self) -> str: return ( f"ViewState(offset={self.offset}, zoom={self.zoom}, " f"rotation={self.rotation}, viewport={self.viewport})" )
# Sentinel for "no view supplied this frame" that is still equality-stable across # frames (so view comparison never forces a spurious re-collect for view-less # scenes). Distinct from ``ViewState()`` only in intent; both compare by value. _NO_VIEW = ViewState() # Sentinel theme generation that never matches a real one (real counters start at # 0), so the first frame always treats the theme as changed (it re-collects # anyway, but this keeps the comparison honest before the first collect). _NO_THEME = -1
[docs] class RenderItemCache: """Retains a scene's collected items, re-uploading only what changed (design §2.7). One cache instance serves one scene root. Call :meth:`frame` once per render frame with the current structure-version and view state; it returns the :class:`CollectResult` to submit and applies the minimal update: - **clean** (nothing dirty, structure/view/theme unchanged, not dynamic) -> reuse the retained result verbatim, no walk, no patch (:attr:`epoch` unchanged); - **patchable** (structure/view/theme unchanged, only some drawables dirty) -> patch those drawables in place: a transform-dirty node rewrites its LOCAL transform row; a render-dirty node re-captures its ``on_draw`` and overwrites its geometry + item columns -- the rest of the retained result untouched (:attr:`epoch` bumped); - **full re-collect** (first frame, structure-version / view / theme change, a dirty node not in the retained index, or ``dynamic``) -> re-walk + re-sort + store (:attr:`epoch` bumped). Render-thread-safe with the P1.5 publish/freeze: the cache mutates the working store on the game thread; the render thread reads only the published frozen view. :attr:`epoch` lets the publisher freeze a fresh view after an in-place patch (which keeps the ``CollectResult`` object identity). """ __slots__ = ( "_builder_factory", "_dynamic", "_cached", "_cached_structure_version", "_cached_view", "_cached_theme_gen", "_explicit_dirty", "_collect_count", "_skip_count", "_patch_count", "_last_skipped", "_epoch", "_last_geo_uploads", "_last_transform_writes", "_theme_generation", ) def __init__( self, *, builder_factory: Callable[[], ItemBuilder] = ItemBuilder, dynamic: bool | Callable[[Any], bool] = False, theme_generation: Callable[[], int] | None = None, ) -> None: """Create a cache. ``builder_factory`` constructs the collector run on a full re-collect (default: a fresh :class:`ItemBuilder` per collection). A test spy can pass a counting factory to prove the walk is skipped/patched, not re-run. ``dynamic`` is the scene-level opt-out (design §2.7 "time-driven draws"): ``True`` (or a predicate returning truthy for the root) makes the cache re-collect every frame. The default ``False`` additionally honours per-node ``dynamic`` marking found during the dirty scan. ``theme_generation`` is the theme bridge (design §2.7): a zero-arg callable returning the global theme generation counter (``ui.theme. theme_generation``). When it changes, the cache forces one non-skipped re-collect so themed Controls (which carry no per-widget theme Property to fire the blanket hook) repaint under frame-skip. ``None`` disables the bridge (a scene with no themed Controls). """ self._builder_factory = builder_factory self._dynamic = dynamic self._theme_generation = theme_generation self._cached: CollectResult | None = None self._cached_structure_version: int = -1 self._cached_view: ViewState = _NO_VIEW self._cached_theme_gen: int = _NO_THEME self._explicit_dirty = True # nothing cached yet -> first frame is dirty self._collect_count = 0 self._skip_count = 0 self._patch_count = 0 self._last_skipped = False self._epoch = 0 self._last_geo_uploads = 0 self._last_transform_writes = 0 # -- the explicit dirty seam (manual escape hatch / tests) ---------------
[docs] def mark_dirty(self) -> None: """Force a full re-collect on the next :meth:`frame` (manual escape hatch). The automatic path (the descriptor / observed-vec / on_change hooks setting the per-drawable render bits) is the normal mechanism; this is the coarse override a test or a non-Property change can use. """ self._explicit_dirty = True
[docs] @property def dirty(self) -> bool: """Whether an explicit :meth:`mark_dirty` is pending (debug/introspection).""" return self._explicit_dirty
# -- the dynamic opt-out -------------------------------------------------
[docs] def is_dynamic(self, root: Any) -> bool: """Whether ``root``'s scene is time-driven at the SCENE level (design §2.7). Per-node ``dynamic`` marking (a ``_render_dynamic`` attribute on a node) is handled separately during the dirty scan; this is the whole-scene flag/ predicate the caller configured. """ d = self._dynamic return bool(d(root)) if callable(d) else bool(d)
[docs] def set_dynamic(self, dynamic: bool | Callable[[Any], bool]) -> None: """Reconfigure the scene-level dynamic opt-out (flag or per-root predicate).""" self._dynamic = dynamic
# -- the frame entry point ----------------------------------------------
[docs] def frame( self, root: Any, *, structure_version: int = 0, view: ViewState | None = None, layer: int = 0, ) -> CollectResult: """Return the item set to submit this frame, re-uploading only what changed. Decides between skip / in-place patch / full re-collect (see class doc), then drains the drawables' render bits it consumed so the next frame starts clean. The returned :class:`CollectResult` is the retained object (patched in place when patchable); read :attr:`epoch` to tell whether it changed. """ view = _NO_VIEW if view is None else view theme_gen = self._theme_generation() if self._theme_generation is not None else _NO_THEME self._last_geo_uploads = 0 self._last_transform_writes = 0 # A structural / view / theme / explicit change, the dynamic opt-out, or no # retained set -> full re-collect (the existing frame-level path + bridges). if self._needs_full_recollect(root, structure_version, view, theme_gen): return self._recollect(root, structure_version, view, theme_gen, layer) # Structure/view/theme stable: scan the tree for the dirty drawables. The # scan is cheap flag reads (NOT re-running on_draw). It is the auto- # invalidation read side -- it cannot "forget" a property because the bits # were set by the blanket hook. render_dirty, transform_dirty, has_dynamic = self._scan_dirty(root) if has_dynamic: # A time-driven node re-emits every frame (P0 gate 3c): conservative # full re-collect (a dynamic on_draw reads non-Property state, so its # geometry may have changed arbitrarily). return self._recollect(root, structure_version, view, theme_gen, layer) if not render_dirty and not transform_dirty: # Clean frame: reuse the retained set verbatim (frame-level skip). self._skip_count += 1 self._last_skipped = True assert self._cached is not None return self._cached # A dirty CanvasLayer means its offset/rotation/scale_val (or layer band) # changed: the affine baked into its WHOLE subtree's geometry is stale, and # an in-place per-node re-capture cannot recompute it (the cache has no # subtree handle). Force a full re-collect so ``_walk_canvas_layer`` re-bakes # the subtree with the new affine (design §5.5 / §2.7 CanvasLayer bullet). if any(getattr(n, "_is_canvas_layer", False) for n in render_dirty): return self._recollect(root, structure_version, view, theme_gen, layer) # Patchable: every dirty node is in the retained index -> in-place patch. # A node NOT in the index (e.g. it began drawing this frame without a # structure bump) cannot be patched -> fall back to a full re-collect. index = self._cached.node_index # type: ignore[union-attr] if any(id(n) not in index for n in render_dirty) or any(id(n) not in index for n in transform_dirty): return self._recollect(root, structure_version, view, theme_gen, layer) try: self._patch(render_dirty, transform_dirty) except _ShapeChanged: # A node's on_draw op count changed (a conditional/variable-shape # body): rows can't stay stable -> full re-collect (design §2.2 the # call-site-ordinal identity is valid only for static-shape bodies). return self._recollect(root, structure_version, view, theme_gen, layer) self._last_skipped = False self._patch_count += 1 self._epoch += 1 return self._cached # type: ignore[return-value]
# -- decision helpers ---------------------------------------------------- def _needs_full_recollect( self, root: Any, structure_version: int, view: ViewState, theme_gen: int ) -> bool: if self._cached is None: return True if self._explicit_dirty: return True if self.is_dynamic(root): return True if structure_version != self._cached_structure_version: return True if view != self._cached_view: return True if theme_gen != self._cached_theme_gen: # the theme bridge (design §2.7) return True return False def _scan_dirty(self, root: Any) -> tuple[list[Any], list[Any], bool]: """Collect the render-dirty / transform-dirty drawables under ``root``. Pure flag reads over the tree (cheap; NOT re-running ``on_draw``). Returns ``(render_dirty_nodes, transform_dirty_nodes, has_dynamic)``. A node may be in both lists (a move that also changed a Property). ``has_dynamic`` is set if any node opts into per-node ``dynamic`` re-emit (``_render_dynamic``). """ render_dirty: list[Any] = [] transform_dirty: list[Any] = [] has_dynamic = False for node in _iter_tree(root): if getattr(node, "_render_dynamic", False): has_dynamic = True if getattr(node, "_render_dirty", False): render_dirty.append(node) if getattr(node, "_transform_render_dirty", False): transform_dirty.append(node) return render_dirty, transform_dirty, has_dynamic # -- the patch paths (item-level + transform-only, design §2.7) ---------- def _patch(self, render_dirty: list[Any], transform_dirty: list[Any]) -> None: """Apply in-place item-level + transform-only patches, then drain the bits. - **transform-only** (a node in ``transform_dirty`` but not ``render_dirty``): rewrite its LOCAL transform row from the node's current ``world_transform`` -- geometry untouched. With the transitional op-adapter bridge the captured verts also carry the node's world position, so a move additionally re-captures the moved node's OWN geometry (one node, never a re-collect); P3a's native local emission drops that recapture to a pure one-row rewrite. The transform-row write is always exactly one row per moved node (the gate-3b column the test asserts reflects the FINAL position). - **item-level** (a node in ``render_dirty``): re-capture the node's ``on_draw`` and overwrite its geometry slice(s) + colour/texture/blend/ flags item columns in place -- one item re-uploaded, the rest retained. Drains ``_render_dirty`` / ``_transform_render_dirty`` on every patched node so the next frame is clean (the render-owned lifetime, design §2.7). """ cached = self._cached assert cached is not None index = cached.node_index # Transform-only first (cheaper). A node that is ALSO render-dirty gets its # geometry re-captured below anyway, so only rewrite the row here for the # pure transform-only set to avoid duplicate work. render_ids = {id(n) for n in render_dirty} for node in transform_dirty: entry = index[id(node)] cached.transforms[entry.transform_id] = affine_row(node) self._last_transform_writes += 1 if id(node) not in render_ids: # Bridge: world position is baked into the captured verts, so a # pure move must also refresh this node's own geometry (one node). self._recapture_geometry(node, entry) self._clear_transform_bit(node) for node in render_dirty: entry = index[id(node)] self._recapture_geometry(node, entry) self._clear_render_bit(node) # A render-dirty node may also have moved; refresh its transform row. if id(node) not in {id(n) for n in transform_dirty}: cached.transforms[entry.transform_id] = affine_row(node) def _recapture_geometry(self, node: Any, entry) -> None: """Re-run one node's ``on_draw`` and overwrite its geometry + item columns. Replays the op-adapter bridge for a single node and writes the fresh ops back into the SAME geometry handles + item rows the node owns (stable handles/rows -> every consumer keying by them stays valid). The op count must match the retained slot (a static-shape ``on_draw``); a changed op count means the node's draw shape changed structurally and the caller must full-re-collect -- the cache guards that by only patching nodes whose op count is unchanged, else it raises to force the caller's re-collect. """ cached = self._cached assert cached is not None rec = _OpRecorder() node._draw_dispatch(rec) ops = rec.ops if len(ops) != len(entry.rows): # Structural shape change inside on_draw (e.g. a conditional draw): # patching cannot keep rows stable -> signal a full re-collect by # marking the cache dirty and raising the sentinel the caller catches. raise _ShapeChanged cols = cached.items geom = cached.geometry # Re-bake the enclosing CanvasLayer affine the node was collected under so # an item-level patch of a canvas child keeps its offset/rotation/scale_val # (the affine is baked into geometry, not stored per item -- design §5.5, P4). affine = entry.canvas_affine # N1: the node's hdr override is read fresh here (not from the retained # entry) so toggling ``hdr`` is picked up by an in-place patch. from .item_builder import hdr_flag as _hdr_flag node_hdr_flag = _hdr_flag(node) for op, row, handle in zip(ops, entry.rows, entry.geometry, strict=True): verts = op.verts if affine is None else _bake_affine(op.verts, affine) geom.set(handle, verts, op.indices) _write_item_columns(cols, row, op, hdr_flag=node_hdr_flag) self._last_geo_uploads += 1 # -- bit draining -------------------------------------------------------- @staticmethod def _clear_render_bit(node: Any) -> None: clear = getattr(node, "_clear_render_dirty", None) if clear is not None: clear() else: # duck-typed fallback node._render_dirty = False @staticmethod def _clear_transform_bit(node: Any) -> None: clear = getattr(node, "_clear_transform_render_dirty", None) if clear is not None: clear() else: node._transform_render_dirty = False # -- full re-collect ----------------------------------------------------- def _recollect( self, root: Any, structure_version: int, view: ViewState, theme_gen: int, layer: int ) -> CollectResult: """Walk the tree, store the fresh result, and drain every drawable's bits.""" self._cached = self._builder_factory().collect(root, layer=layer) self._cached_structure_version = structure_version self._cached_view = view self._cached_theme_gen = theme_gen self._explicit_dirty = False self._collect_count += 1 self._last_skipped = False self._epoch += 1 # A full re-collect captures the current state of every drawable, so drain # all bits in the tree (the next frame starts clean). for node in _iter_tree(root): if getattr(node, "_render_dirty", False): self._clear_render_bit(node) if getattr(node, "_transform_render_dirty", False): self._clear_transform_bit(node) return self._cached # -- invalidation on reuse / introspection -------------------------------
[docs] def invalidate(self) -> None: """Drop the retained set entirely (forces a full re-collect next frame).""" self._cached = None self._cached_structure_version = -1 self._cached_view = _NO_VIEW self._cached_theme_gen = _NO_THEME self._explicit_dirty = True
[docs] @property def cached(self) -> CollectResult | None: """The currently retained result (``None`` before the first frame).""" return self._cached
[docs] @property def epoch(self) -> int: """Monotonic change counter: bumped on every collect OR in-place patch. The publish layer freezes a new view when the epoch advances; a clean (skipped) frame leaves it unchanged, so the publisher reuses the frozen view verbatim (the §4 zero-byte clean frame). """ return self._epoch
[docs] @property def collect_count(self) -> int: """How many frames ran a full re-collect (the walk). Test/perf introspection.""" return self._collect_count
[docs] @property def patch_count(self) -> int: """How many frames applied an in-place item/transform patch (no full walk).""" return self._patch_count
[docs] @property def skip_count(self) -> int: """How many frames skipped entirely (clean: reused the retained set).""" return self._skip_count
[docs] @property def last_skipped(self) -> bool: """Whether the most recent :meth:`frame` skipped (clean, no upload).""" return self._last_skipped
[docs] @property def last_geo_uploads(self) -> int: """Geometry slices re-uploaded by the most recent :meth:`frame` (gate-3).""" return self._last_geo_uploads
[docs] @property def last_transform_writes(self) -> int: """Transform rows rewritten by the most recent :meth:`frame` (gate-3).""" return self._last_transform_writes
class _ShapeChanged(Exception): """Internal: a node's ``on_draw`` op count changed -> patch impossible.""" def _write_item_columns(items, row: int, op, *, hdr_flag: int = 0) -> None: """Overwrite the appearance columns of one item row from a re-captured op. Geometry handle / transform / seq / layer / z / item_id stay put (stable retention); only what a colour/texture/blend/msdf change can affect is rewritten (the item-level upload, design §2.7). ``pipeline`` can change if a node's draw kind flipped (rect outline <-> fill) -- handled too. Colour lives in the geometry verts (refreshed by the caller via the geometry store). ``hdr_flag`` (N1) is the node's resolved HDR-lane override, OR-ed in so an in-place patch preserves / updates the HDR lane. """ from .item_builder import _BLEND_TO_MODE, _KIND_TO_PIPELINE from .item_list import BlendMode, ItemFlags flags = ItemFlags(int(hdr_flag)) if op.is_msdf: flags |= ItemFlags.IS_MSDF if op.screen_space: flags |= ItemFlags.SCREEN_SPACE items.set_appearance( row, pipeline=_KIND_TO_PIPELINE[op.kind], blend=_BLEND_TO_MODE.get(op.blend, BlendMode.ALPHA), texture=op.tex_id, flags=flags, ) def _iter_tree(root: Any) -> Iterable[Any]: """Yield ``root`` and every descendant in tree order (the dirty-scan walk). Uses ``children.safe_iter()`` when present to match the live walk's iteration semantics; falls back to plain iteration otherwise. SubViewport subtrees are PRUNED (design §2.6 / §5.3): their drawables belong to the viewport's own item cache, so the main dirty scan must not see them (matching the :class:`ItemBuilder` walk's prune -- otherwise a dirty drawable inside a SubViewport, absent from the main retained index, would force a spurious main re-collect every frame). ``root`` itself is never pruned (collecting a SubViewport's own contents passes the SubViewport as ``root``). """ stack = [root] while stack: node = stack.pop() yield node if node is not root and getattr(node, "_is_subviewport", False): continue # do not descend: children belong to the viewport's cache ch = getattr(node, "children", None) if ch is None: continue safe = getattr(ch, "safe_iter", None) children = list(safe()) if safe is not None else list(ch) # Reverse so the natural (pre-order) tree order is preserved on a stack. stack.extend(reversed(children))