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(Node2Ddefinesqueue_redrawvia the mixin, so the hook fires for colour/text/visible/texture/size/anchor/margin/…);the
_ObservedVec._notify -> _invalidate_transformpath + the direct_invalidate_transform()on spatial replacement (_spatial_property.py) ->_transform_render_dirty(move, including in-place+=);rotation’son_change="_invalidate_transform"->_transform_render_dirty;z_index/z_as_relative’son_change="_invalidate_z_cache"->_render_dirtyon self + descendants (z is structural);structural changes ->
SceneTree._structure_versionbump (compared here);theme generation -> a global counter compared here (the theme bridge: a
set_themeforces 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_changemarks the layer subtree render-dirty (CanvasLayer is aNode, 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
Node2Dhasqueue_redraw; or avisibleflip, marked directly byNode._propagate_visibilitysincevisibleis a plain@property, not aProperty) re-captures only that node’son_drawand overwrites its geometryitem 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_transformpath) rewrites that node’s LOCAL transform row in place (the scroll-by-translation / moving-sprite fast path).
Module Contents¶
Classes¶
The view/UBO inputs a collection was built under. |
|
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, theepochis unchanged. The seam: a view change rewrites one UBO row, items stay clean.Noneis a valid value (no camera / unknown view) and compares equal to anotherNoneview, 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:
frameonce per render frame with the current structure-version and view state; it returns the- Class:
CollectResultto submit and applies the minimal update:
clean (nothing dirty, structure/theme unchanged, not dynamic) -> reuse the retained result verbatim, no walk, no patch (:attr:
epochunchanged);view-only (only the
viewchanged – 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:epochunchanged. 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
dynamicnode, which is treated as render-dirty every frame) re-captures itson_drawand overwrites its geometry + item columns – the rest of the retained result untouched (:attr:epochbumped). A per-nodedynamicbody 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
dynamicopt-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:epochbumped).
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:
epochlets the publisher freeze a fresh view after an in-place patch (which keeps theCollectResultobject identity).Initialization
Create a cache.
builder_factoryconstructs the collector run on a full re-collect (default: a fresh :class:ItemBuilderper collection). A test spy can pass a counting factory to prove the walk is skipped/patched, not re-run.dynamicis 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 defaultFalseadditionally honours per-nodedynamicmarking found during the dirty scan.theme_generationis 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.Nonedisables 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_dirtyis pending (debug/introspection).
- is_dynamic(root: Any) bool¶
Whether
root’s scene is time-driven at the SCENE level.Per-node
dynamicmarking (the publicdynamicattribute, or its legacy_render_dynamicalias, 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:
CollectResultis the retained object (patched in place when patchable); read :attr:epochto 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 (
Nonebefore 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_countstays flat. Each increment is one downstream camera-uniform rewrite, never a geometry re-upload.
- property last_skipped: bool¶
Whether the most recent :meth:
frameskipped (clean, no upload).
- property last_view_changed: bool¶
Whether the most recent :meth:
frameadopted a view-only change.Truewhen only the camera/viewport moved (items stayed clean, epoch unchanged): the producer should rewrite the per-frame view uniform.Falseon 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_idis the per-collect monotonic emission counter (the- Class:
~simvx.graphics.render2d.item_list.ItemListitem_idcolumn) – 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_alllets 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.
Trueafter a re-collect (the downstream wire should re-serialise the whole item set rather than enumerate :attr:last_changed_item_ids);Falseafter a patch (read the explicit set) or a skip (nothing changed).