simvx.graphics.render2d.cache

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

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

Module Contents

Classes

ViewState

The view/UBO inputs a collection was built under (design §2.3 seam).

RenderItemCache

Retains a scene’s collected items, re-uploading only what changed (design §2.7).

Data

API

simvx.graphics.render2d.cache.__all__

[‘RenderItemCache’, ‘ViewState’]

class simvx.graphics.render2d.cache.ViewState(*, 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))[source]

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.

Initialization

__slots__

(‘offset’, ‘zoom’, ‘rotation’, ‘viewport’)

__eq__(other: object) bool[source]
__hash__() int[source]
__repr__() str[source]
class simvx.graphics.render2d.cache.RenderItemCache(*, builder_factory: collections.abc.Callable[[], simvx.graphics.render2d.item_builder.ItemBuilder] = ItemBuilder, dynamic: bool | collections.abc.Callable[[Any], bool] = False, theme_generation: collections.abc.Callable[[], int] | None = None)[source]

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

Initialization

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

__slots__

(‘_builder_factory’, ‘_dynamic’, ‘_cached’, ‘_cached_structure_version’, ‘_cached_view’, ‘_cached_th…

mark_dirty() None[source]

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.

property dirty: bool[source]

Whether an explicit :meth:mark_dirty is pending (debug/introspection).

is_dynamic(root: Any) bool[source]

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.

set_dynamic(dynamic: bool | collections.abc.Callable[[Any], bool]) None[source]

Reconfigure the scene-level dynamic opt-out (flag or per-root predicate).

frame(root: Any, *, structure_version: int = 0, view: simvx.graphics.render2d.cache.ViewState | None = None, layer: int = 0) simvx.graphics.render2d.item_builder.CollectResult[source]

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.

invalidate() None[source]

Drop the retained set entirely (forces a full re-collect next frame).

property cached: simvx.graphics.render2d.item_builder.CollectResult | None[source]

The currently retained result (None before the first frame).

property epoch: int[source]

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

property collect_count: int[source]

How many frames ran a full re-collect (the walk). Test/perf introspection.

property patch_count: int[source]

How many frames applied an in-place item/transform patch (no full walk).

property skip_count: int[source]

How many frames skipped entirely (clean: reused the retained set).

property last_skipped: bool[source]

Whether the most recent :meth:frame skipped (clean, no upload).

property last_geo_uploads: int[source]

Geometry slices re-uploaded by the most recent :meth:frame (gate-3).

property last_transform_writes: int[source]

Transform rows rewritten by the most recent :meth:frame (gate-3).