"""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
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))