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