simvx.graphics.render2d.cache

The auto-invalidation contract (“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 (Node2D 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);

  • structural changes -> SceneTree._structure_version bump (compared here);

  • theme generation -> a global counter compared here (the theme bridge: 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.

The dynamic opt-out (“time-driven draws”)

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 sets the PUBLIC dynamic attribute (_render_dynamic is the legacy internal alias) and is treated as render-dirty EVERY frame. Crucially it is folded into the SAME per-node patch path as a render-dirty drawable: only the dynamic node’s OWN on_draw re-runs and only its items re-upload, while every clean node around it (a 16k-cell static floor, a retained tilemap) stays fully retained. A single per-frame-animated HUD therefore no longer defeats retention for the whole scene. For this in-place patch to stay cheap a dynamic on_draw must emit a STABLE number of primitives each frame (animate attributes – colour, position, texture – not the op COUNT); a variable-count dynamic body still re-collects on the frames its count changes (the _ShapeChanged fallback – correct, just not free). A dynamic node thus never frame-skips, but the rest of the scene around it does. (The cache-level dynamic predicate, set via the constructor, still marks the WHOLE scene for a per-frame re-collect – that is the coarse scene-wide opt-out, kept distinct from per-node dynamic.) queue_redraw() remains the manual one-shot escape hatch, and a freeze detector (:meth:_maybe_detect_freeze) warns when neither was used.

Render-thread safety

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

The unified RenderItemCache. It retains the last collected+sorted item set for a scene and, riding the automatic invalidation contract, re-uploads only what changed:

  • frame level – whole-tree clean (no dirty drawable, structure + view + theme unchanged, not dynamic) -> reuse the retained set verbatim, skipping walk + sort entirely;

  • item level – a render-dirty drawable (a colour / texture / text Property change, via the blanket descriptor hook now that Node2D has queue_redraw; or a visible flip, marked directly by Node._propagate_visibility since visible is a plain @property, not a Property) 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 – 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.

RenderItemCache

Retains a scene’s collected items, re-uploading only what changed.

Data

API

simvx.graphics.render2d.cache.log

‘getLogger(…)’

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

The view/UBO inputs a collection was built under.

A compact, equality-comparable bundle of the camera + viewport state that the frame-level skip compares frame to frame. A pure view change does NOT re-collect: geometry is camera-independent (recorded at identity; the camera is applied downstream as a per-frame uniform via camera_affine_from_tree), so a scroll/zoom/viewport change rewrites only the cached view and signals a view-only uniform update – items stay clean, the epoch is unchanged. The seam: a view change rewrites 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
__hash__() int
__repr__() str
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, tree: Any = None, viewport: Any = None)

Retains a scene’s collected items, re-uploading only what changed.

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/theme unchanged, not dynamic) -> reuse the retained result verbatim, no walk, no patch (:attr:epoch unchanged);

  • view-only (only the view changed – a camera scroll/zoom/viewport change – nothing else dirty) -> reuse the retained items verbatim too, but adopt the new view and signal a view-only uniform update (:attr:last_view_changed); items stay clean, :attr:epoch unchanged. The camera is applied downstream as a per-frame uniform, so a scroll is one uniform write instead of a full re-collect + re-serialise + re-upload;

  • patchable (structure/theme unchanged, only some drawables dirty) -> patch those drawables in place: a transform-dirty node rewrites its LOCAL transform row; a render-dirty node (INCLUDING a per-node dynamic node, which is treated as render-dirty every frame) re-captures its on_draw and overwrites its geometry + item columns – the rest of the retained result untouched (:attr:epoch bumped). A per-node dynamic body with a stable op count rides this path every frame, so one animated HUD does not defeat scene retention;

  • full re-collect (first frame, structure-version / theme change, the SCENE-LEVEL dynamic opt-out, a dirty (or dynamic) node not in the retained index / inside an open overlay / a dirty CanvasLayer, or a dynamic node whose op count changed this frame) -> re-walk + re-sort + store (:attr:epoch bumped).

Render-thread-safe with the 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 (“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: 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

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

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

is_dynamic(root: Any) bool

Whether root’s scene is time-driven at the SCENE level.

Per-node dynamic marking (the public dynamic attribute, or its legacy _render_dynamic alias, 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

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

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

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

property cached: simvx.graphics.render2d.item_builder.CollectResult | None

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

property epoch: 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 zero-byte clean frame).

property collect_count: int

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

property patch_count: int

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

property skip_count: int

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

property view_update_count: int

How many frames adopted a pure view change without re-collecting.

The scroll-win counter: a retained scene panned over N frames bumps this N times while :attr:collect_count stays flat. Each increment is one downstream camera-uniform rewrite, never a geometry re-upload.

property last_skipped: bool

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

property last_view_changed: bool

Whether the most recent :meth:frame adopted a view-only change.

True when only the camera/viewport moved (items stayed clean, epoch unchanged): the producer should rewrite the per-frame view uniform. False on a frame that re-collected (the fresh collect already reflects the view) or whose view was unchanged.

property last_geo_uploads: int

Geometry slices re-uploaded by the most recent :meth:frame.

property last_transform_writes: int

Transform rows rewritten by the most recent :meth:frame.

property last_changed_item_ids: frozenset[int]

The stable item_ids whose geometry/transform changed in the last frame.

The per-frame changed-item seam a downstream delta-wire ships: only these items need re-serialising, every other retained item is reused verbatim. The item_id is the per-collect monotonic emission counter (the

Class:

~simvx.graphics.render2d.item_list.ItemList item_id column) – unique per item within a collect and NOT a positional row index, so it survives a re-sort and never aliases two items onto one id.

Semantics, by frame outcome:

  • patch -> exactly the item_ids of the rows the patch overwrote (the dynamic / render-dirty / transform-dirty nodes’ items). O(changed).

  • re-collect -> ALL retained item_ids (the whole scene was re-emitted). Built lazily here from the retained :class:ItemList, so a frame that re-collects but whose result no consumer reads pays nothing (:attr:last_changed_all lets a consumer branch to “ship everything” without materialising the set).

  • skip (clean / view-only) -> the empty set (nothing changed).

property last_changed_all: bool

Whether the last frame changed EVERY item (a re-collect), delta seam.

True after a re-collect (the downstream wire should re-serialise the whole item set rather than enumerate :attr:last_changed_item_ids); False after a patch (read the explicit set) or a skip (nothing changed).