simvx.core._subviewport_order

Backend-agnostic per-frame ordering of offscreen scene render targets.

A :class:~simvx.core.SubViewport renders its subtree, and a

class:

~simvx.core.RenderView renders the main scene, into an offscreen bindless texture each frame. When target A samples target B’s texture (material.albedo_tex_index = B.texture, or a Sprite2D whose texture is B), B is a producer and A a consumer: B must render first so A sees fresh content the same frame instead of last frame’s (the 1-frame lag that flat discovery order causes).

func:

order_subviewports is the single canonical implementation both the desktop (Vulkan) and web (WebGPU) backends call, for SubViewports alone or for a mixed SubViewport + RenderView list (via consumes_of). It:

  1. Builds one :class:~simvx.core.scene_target_graph.SceneJob per live target (produces = the node’s current bindless slot, node._texture_id) plus a scene:main job that renders after every offscreen target.

  2. Computes each job’s consumed slots (the default scans the SubViewport’s own subtree, stopping at nested SubViewport boundaries; a RenderView consumes what the main tree samples, supplied through consumes_of), and carries the explicit node.feeds_from hints as after edges.

  3. Compiles the jobs through :class:SceneTargetGraph, which Kahn topo-sorts so producers precede consumers (deterministic tiebreak by discovery index) via the shared :func:simvx.core._topo_sort.topo_sort_lagged helper.

  4. On a genuine cycle (e.g. two mirrors facing each other) the graph runs Tarjan SCC and breaks the minimal back-edge(s) deterministically, so only the cyclic boundary lags (the broken consumer reads last frame’s texture, the natural result of rendering it before its producer). It never raises.

First-frame warmup. Before the backend has assigned slots, every slot_of(node) returns -1; no edges form, so the order is the flat discovery order. That is correct: there is nothing to sample yet. From the second frame on (slots assigned) the topological order takes effect, so a producer -> consumer chain is lag-free from frame 1 of actually sampling.

The function is dependency-light: it imports nothing from simvx.graphics and inspects only core data (Material, Sprite-like _texture_id).

Module Contents

Functions

scan_consumed_slots

Collect every bindless slot consumed inside viewport’s subtree.

order_subviewports

Order live offscreen targets so producers render before consumers.

Data

API

simvx.core._subviewport_order.log

‘getLogger(…)’

simvx.core._subviewport_order.__all__

[‘order_subviewports’, ‘scan_consumed_slots’]

simvx.core._subviewport_order.scan_consumed_slots(viewport: Any) set[int][source]

Collect every bindless slot consumed inside viewport’s subtree.

Walks the SubViewport’s children in DFS, stopping at nested SubViewport boundaries (a nested SubViewport’s own consumption is its own edge, scanned when that nested node is processed from the live list, so each edge is counted once). For every node it reads:

  • node.material.albedo_tex_index (MeshInstance3D and friends);

  • node._texture_id (Sprite2D / Sprite3D / MeshInstance2D / NinePatch / AnimatedSprite2D, whose drawn texture slot lives here).

Returns the set of slot integers found (negatives included; the caller filters them against the live-producer map). Excludes viewport’s own published slot, which it does not consume.

simvx.core._subviewport_order.order_subviewports(live: list[Any], slot_of: collections.abc.Callable[[Any], int], *, consumes_of: collections.abc.Callable[[Any], set[int]] | None = None, depth_cap: int = 1, graph: simvx.core.scene_target_graph.SceneTargetGraph | None = None) tuple[list[Any], set[tuple[Any, Any]]][source]

Order live offscreen targets so producers render before consumers.

The order is produced by a :class:SceneTargetGraph over one

Class:

SceneJob per target plus a scene:main sink job (the main scene renders after every offscreen target, which is exactly the engine’s pre-render/main-pass split).

Args: live: SubViewport (and optionally RenderView) nodes in discovery order (the flat DFS order the backend already collects). slot_of: node -> int returning the node’s current bindless slot (node._texture_id); < 0 means “no slot yet” (first frame). consumes_of: node -> set[int] returning the bindless slots the node’s render job samples. Defaults to :func:scan_consumed_slots (the SubViewport own-subtree scan). A mixed-target caller supplies a dispatcher that hands RenderView jobs the main tree’s consumed slots (a RenderView renders the main scene, so it samples whatever the main pass samples), memoised per frame. depth_cap: Bound on recursive scene-feedback depth (the WorldEnvironment.scene_feedback_max_depth property). The default 1 renders each SubViewport once per frame; cyclic back-edges then lag by one frame. depth_cap <= 0 is treated as 1 (each node still renders once). Higher caps are reserved for a future multi-pass feedback expansion and currently behave like 1 for the ordering itself (each node appears once); the cap is honoured in that no node is scheduled more times than the cap allows. graph: Optional persistent :class:SceneTargetGraph. Passing the same instance every frame engages its compile cache, so the topo-sort runs only when the structure changes (a viewport added/removed, a slot assigned, a sampling binding changed), not per frame. None builds a throwaway graph (identical result, sorts every call).

Returns: (ordered, lagged_edges) where ordered is the list of the same SubViewport nodes in render order, and lagged_edges is the set of (producer, consumer) edges that were broken to resolve a cycle (the consumer reads last frame’s texture across that edge). Empty on the acyclic common case.

Never raises on a cyclic graph: cycles degrade to a 1-frame-lagged edge.