Source code for 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``).
"""

from __future__ import annotations

import logging
from collections.abc import Callable, Iterable
from typing import Any

from .scene_target_graph import SceneJob, SceneTargetGraph

log = logging.getLogger(__name__)

__all__ = ["order_subviewports", "scan_consumed_slots"]


def _material_slots(material: Any) -> Iterable[int]:
    """Yield every bindless sampler slot a single material references.

    ``Material`` keeps texture *sources* (paths / bytes / ndarrays) in its
    ``*_uri`` fields, which never hold a bindless slot. The one field that
    carries a live bindless index is ``albedo_tex_index`` (set directly by a
    backend or by a consumer binding ``svp.texture``). Scanning only it is
    correct and unambiguous.
    """
    slot = getattr(material, "albedo_tex_index", None)
    if isinstance(slot, int):
        yield slot


[docs] def scan_consumed_slots(viewport: Any) -> set[int]: """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. """ slots: set[int] = set() stack = list(getattr(viewport, "children", ()) or ()) while stack: cur = stack.pop() if _is_subviewport(cur): # Do not descend into a nested SubViewport. continue if _is_renderview(cur): # A RenderView's ``_texture_id`` is the slot it PRODUCES, not one it # samples; its children are ordinary scene content, so keep walking. stack.extend(getattr(cur, "children", ()) or ()) continue mat = getattr(cur, "material", None) if mat is not None: slots.update(_material_slots(mat)) tid = getattr(cur, "_texture_id", None) if isinstance(tid, int): slots.add(tid) stack.extend(getattr(cur, "children", ()) or ()) return slots
def _is_subviewport(node: Any) -> bool: """True if *node* is a SubViewport, by MRO name (no import needed).""" return any(c.__name__ == "SubViewport" for c in type(node).__mro__) def _is_renderview(node: Any) -> bool: """True if *node* is a RenderView (duck-typed structural marker).""" return bool(getattr(node, "_is_renderview", False)) def _normalise_feeds_from(node: Any) -> list[Any]: """Return the explicit producer list from ``node.feeds_from``. Accepts a single SubViewport / RenderView, an iterable of them, or ``None``/empty. """ feeds = getattr(node, "feeds_from", None) if feeds is None: return [] if _is_subviewport(feeds) or _is_renderview(feeds): return [feeds] try: return [f for f in feeds if f is not None] except TypeError: return []
[docs] def order_subviewports( live: list[Any], slot_of: Callable[[Any], int], *, consumes_of: Callable[[Any], set[int]] | None = None, depth_cap: int = 1, graph: SceneTargetGraph | None = None, ) -> tuple[list[Any], set[tuple[Any, Any]]]: """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. """ n = len(live) if n <= 1: return list(live), set() if graph is None: graph = SceneTargetGraph() graph.clear() if consumes_of is None: consumes_of = scan_consumed_slots # One job per target, named by its published slot (stable across # frames for a stable structure, so the compile cache holds) with a # discovery-index fallback before slots exist. Registration order is # discovery order, which is the deterministic tiebreak. RenderView jobs # get their own prefix so a mixed compile never aliases a SubViewport job. names: list[str] = [] name_by_id: dict[int, str] = {} used: set[str] = set() for i, node in enumerate(live): prefix = "rv" if _is_renderview(node) else "svp" slot = slot_of(node) slot = slot if isinstance(slot, int) else -1 name = f"{prefix}:{slot}" if slot >= 0 else f"{prefix}:@{i}" if name in used: # Two nodes reporting one slot would be a backend bug; keep the # first (discovery order wins, matching the producer map below). name = f"{prefix}:@{i}" used.add(name) names.append(name) name_by_id[id(node)] = name for i, node in enumerate(live): slot = slot_of(node) slot = slot if isinstance(slot, int) and slot >= 0 else -1 after = tuple(name_by_id[id(p)] for p in _normalise_feeds_from(node) if id(p) in name_by_id) graph.add( SceneJob( names[i], node=node, produces=slot, consumes=consumes_of(node), after=after, ) ) # The main scene is the sink: it samples the SubViewport textures it # composites, so every offscreen job precedes it. graph.add(SceneJob("scene:main", after=tuple(names))) graph.compile() ordered = [job.node for job in graph.order if job.node is not None] lagged_edges = {(p.node, c.node) for p, c in graph.lagged_edges if p.node is not None and c.node is not None} # depth_cap is honoured by construction: each node is scheduled exactly once # (cap >= 1). A cap of 0 would mean "do not render"; we clamp to 1 so the # slot stays valid, matching the once-per-frame default. if depth_cap <= 0: log.debug("scene_feedback_max_depth <= 0 clamped to 1 (render once)") return ordered, lagged_edges