"""SubViewport render-to-texture: offscreen targets for ``core.SubViewport`` nodes.
A :class:`~simvx.core.SubViewport` renders its own subtree (with its own
camera) into an offscreen texture that other nodes in the *main* scene can
sample: a "live monitor in the world", a security-camera feed, a minimap, a
render-to-texture portal, etc.
Architecture mirrors :class:`~simvx.graphics.renderer.game_viewport.GameViewportRenderer`
(the proven editor game-preview path): each SubViewport owns a
:class:`RenderTarget`, registered once as a *bindless texture* whose slot id
stays stable across resizes so consumers that captured ``subviewport.texture``
keep working. :class:`SubViewportManager` keys one renderer per SubViewport by
``id(node)``, creates it on first sight, debounce-resizes on size changes, and
unregisters on exit-tree / teardown.
Frame ordering: all SubViewports are recorded before the main scene pass, in
:meth:`SubViewportManager.render_all` (driven from the engine ``pre_render``
callback) into the *same* primary command buffer the whole frame uses. Each is
its own scene-render unit (SRU): it reserves a slice of the shared transform
SSBO and records its draws with absolute ``first_instance`` indices, so a single
``vkQueueSubmit`` covers every SubViewport plus the main scene with no
per-viewport ``vkQueueWaitIdle``. Each offscreen pass ends in
``SHADER_READ_ONLY`` (the render pass's outgoing colour dependency makes the
write visible to a later sample in the same command buffer).
The per-frame render list is **topologically ordered** by the shared core
helper :func:`simvx.core._subviewport_order.order_subviewports`: when one
SubViewport samples another's offscreen texture, the producer renders first, so
the consumer sees *this* frame's content (no lag). The producer's offscreen pass
ends in ``SHADER_READ_ONLY`` and its render pass's outgoing colour dependency
already makes that write visible to the later same-cmd sample, so no extra
inter-SubViewport barrier is needed. Genuine cycles (mirror facing mirror)
degrade gracefully: the minimal back-edge is broken and only that one consumer
lags a frame; the helper never raises. The recursion depth is bounded by
``WorldEnvironment.scene_feedback_max_depth`` (default 1 = render each once).
"""
from __future__ import annotations
import logging
from typing import Any
import vulkan as vk
from .draw2d_pass import Draw2DPass
from .render_target import RenderTarget
log = logging.getLogger(__name__)
__all__ = ["SubViewportRenderer", "SubViewportManager"]
[docs]
class SubViewportRenderer:
"""One offscreen render target for a single SubViewport node.
Wraps a :class:`RenderTarget` and exposes the ``ready`` / ``width`` /
``height`` / ``begin_pass`` / ``end_pass`` / ``render_draw2d`` surface that
:meth:`SceneAdapter.render_to_target` expects: the same contract
:class:`GameViewportRenderer` satisfies.
"""
def __init__(self, engine: Any) -> None:
self._engine = engine
self._target: RenderTarget | None = None
self._draw2d_pass: Draw2DPass | None = None
self._item_submitter: Any = None # lazy ItemSubmitter (RTT-2D path)
self._texture_id: int = -1
self._width: int = 0
self._height: int = 0
# Thin G-buffer: whether this target carries the second colour
# attachment. Mirrors the main HDR target's ``engine.gbuffer_active`` so the
# shared forward pipelines (rebuilt with a second colour output when SSR/SSGI
# activate the G-buffer) stay render-pass-compatible when they draw the scene
# into this offscreen target. False (single attachment) on the default path.
self._gbuffer: bool = False
# Match the HDR offscreen pass format so the forward renderer's
# pipelines (compiled against the HDR pass) are render-pass compatible.
self._colour_format = vk.VK_FORMAT_R16G16B16A16_SFLOAT
def _want_gbuffer(self) -> bool:
return bool(getattr(self._engine, "gbuffer_active", False))
[docs]
def create(self, width: int, height: int) -> None:
"""Create the offscreen target and register its colour view as bindless."""
if width < 1 or height < 1:
return
self._width = width
self._height = height
self._build_target(register=True)
log.debug("SubViewportRenderer created %dx%d, texture_id=%d", width, height, self._texture_id)
[docs]
def resize(self, width: int, height: int) -> None:
"""Recreate the target at a new size, preserving the bindless slot.
The slot id handed out as ``subviewport.texture`` stays stable; only
the backing image view changes, so a Sprite2D / Material that captured
the slot keeps sampling the live feed after a resize.
"""
if (width, height) == (self._width, self._height) or width < 1 or height < 1:
return
self._width = width
self._height = height
self._build_target(register=False)
[docs]
def ensure_gbuffer(self) -> None:
"""Recreate the target if the engine's thin-G-buffer activation changed.
SSR/SSGI toggling the G-buffer rebuilds the forward pipelines with a
second colour output; a single-attachment offscreen pass would then be
render-pass-incompatible with them. Called from the manager's prepare
phase (before any offscreen draw is recorded), it rebuilds the target
to match, preserving the bindless slot. A no-op on the common path."""
if self._target is None or self._want_gbuffer() == self._gbuffer:
return
self._build_target(register=False)
def _build_target(self, *, register: bool) -> None:
"""(Re)create the target + Draw2DPass at the current size and G-buffer
state. When *register* is False the existing bindless slot is rebound to
the new colour view (resize / G-buffer toggle); otherwise a fresh slot
is registered."""
old_slot = self._texture_id
vk.vkDeviceWaitIdle(self._engine.ctx.device)
if self._draw2d_pass is not None:
self._draw2d_pass.cleanup()
self._draw2d_pass = None
if self._target is not None:
self._target.destroy()
self._target = None
# Match the HDR target's render pass exactly (colour[+gbuffer]+depth) so the
# forward renderer's pipelines are render-pass-compatible when drawing into it.
self._gbuffer = self._want_gbuffer()
self._target = RenderTarget(
self._engine.ctx.device,
self._engine.ctx.physical_device,
self._width,
self._height,
colour_format=self._colour_format,
use_depth=True,
samplable_depth=True,
queue=self._engine.ctx.graphics_queue,
command_pool=self._engine.ctx.command_pool,
gbuffer=self._gbuffer,
)
if register or old_slot < 0:
self._texture_id = self._engine.register_texture(self._target.colour_view)
else:
self._engine.update_texture(old_slot, self._target.colour_view)
text_pass = getattr(self._engine.renderer, "_text_pass", None)
self._draw2d_pass = Draw2DPass(self._engine, text_pass=text_pass)
# Compile the 2D pipelines against the COLOUR-ONLY overlay pass (depth-
# attachment resolution): 2D needs no depth, so the offscreen target
# keeps its depth attachment for 3D content while the 2D overlay/item submit
# records into the colour-only ``overlay_render_pass`` + ``overlay_framebuffer``
# (a pipeline compiled against the depth-having ``render_pass`` is render-pass-
# INCOMPATIBLE with the colour-only overlay FB -- attachment count + depth ref
# mismatch). Both ``render_draw2d`` and ``render_items`` use the overlay FB.
self._draw2d_pass.setup(render_pass=self._target.overlay_render_pass, extent=(self._width, self._height))
# The submitter holds the draw2d pass by reference; drop the stale one so a
# rebuild recompiles it against the new pass on next use.
self._item_submitter = None
[docs]
def begin_pass(self, cmd: Any) -> None:
"""Begin the offscreen colour+depth pass (clear)."""
rt = self._target
if rt is None:
return
clear_values = [vk.VkClearValue(color=vk.VkClearColorValue(float32=self._clear_colour))]
# G-buffer attachment (index 1) clears to zero, matching begin_hdr_pass.
if rt.gbuffer_view is not None:
clear_values.append(vk.VkClearValue(color=vk.VkClearColorValue(float32=[0.0, 0.0, 0.0, 0.0])))
clear_values.append(vk.VkClearValue(depthStencil=vk.VkClearDepthStencilValue(depth=1.0, stencil=0)))
rp_begin = vk.VkRenderPassBeginInfo(
renderPass=rt.render_pass,
framebuffer=rt.framebuffer,
renderArea=vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=rt.width, height=rt.height),
),
clearValueCount=len(clear_values),
pClearValues=clear_values,
)
rt.begin_frame_barrier(cmd) # cross-frame WAW: shared target reused across FRAMES_IN_FLIGHT
vk.vkCmdBeginRenderPass(cmd, rp_begin, vk.VK_SUBPASS_CONTENTS_INLINE)
vk.vkCmdSetViewport(
cmd,
0,
1,
[
vk.VkViewport(
x=0.0,
y=0.0,
width=float(rt.width),
height=float(rt.height),
minDepth=0.0,
maxDepth=1.0,
)
],
)
vk.vkCmdSetScissor(
cmd,
0,
1,
[
vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=rt.width, height=rt.height),
)
],
)
[docs]
def end_pass(self, cmd: Any) -> None:
if self._target is not None:
vk.vkCmdEndRenderPass(cmd)
[docs]
def render_draw2d(self, cmd: Any, ops: list) -> None:
"""Overlay pre-extracted Draw2D ops on top of the 3D content (LOAD_OP_LOAD)."""
rt = self._target
if rt is None or not ops or self._draw2d_pass is None:
return
rp_begin = vk.VkRenderPassBeginInfo(
renderPass=rt.overlay_render_pass,
framebuffer=rt.overlay_framebuffer,
renderArea=vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=rt.width, height=rt.height),
),
clearValueCount=0,
pClearValues=None,
)
vk.vkCmdBeginRenderPass(cmd, rp_begin, vk.VK_SUBPASS_CONTENTS_INLINE)
vk.vkCmdSetViewport(
cmd,
0,
1,
[
vk.VkViewport(
x=0.0,
y=0.0,
width=float(rt.width),
height=float(rt.height),
minDepth=0.0,
maxDepth=1.0,
)
],
)
vk.vkCmdSetScissor(
cmd,
0,
1,
[
vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=rt.width, height=rt.height),
)
],
)
self._draw2d_pass.render(cmd, rt.width, rt.height, ops=ops)
vk.vkCmdEndRenderPass(cmd)
[docs]
def render_items(self, cmd: Any, view: Any, camera: Any) -> None:
"""Render a published 2D item view into this target (RTT-2D).
The item-pipeline counterpart of :meth:`render_draw2d`: the SubViewport's
own 2D subtree is collected + published (with the viewport's *own* Camera2D
affine, ``camera``) on the game thread, and this submits that frozen
:class:`~simvx.graphics.render2d.publish.PublishedItemView` into the
offscreen colour target through the existing 2D pipelines. It reuses the
colour-only ``overlay_render_pass`` (``LOAD_OP_LOAD``, no depth): 2D needs
no depth attachment, which resolves the 2D-only target depth-attachment
incompatibility: the SubViewport keeps its depth attachment for 3D content,
and the 2D overlay pass simply ignores it.
The submit is render-target-agnostic: same collection / sort /
batch as the main framebuffer; only the target framebuffer + the per-target
Camera2D differ. Text2D flows through as first-class GLYPH items (the legacy
``render_draw2d`` path never saw Text2D because its ``on_draw`` only emits
through the item builder).
"""
rt = self._target
if rt is None or view is None or getattr(view, "count", 0) == 0 or self._draw2d_pass is None:
return
if self._item_submitter is None:
from ..render2d.submit import ItemSubmitter
self._item_submitter = ItemSubmitter(self._draw2d_pass)
rp_begin = vk.VkRenderPassBeginInfo(
renderPass=rt.overlay_render_pass,
framebuffer=rt.overlay_framebuffer,
renderArea=vk.VkRect2D(
offset=vk.VkOffset2D(x=0, y=0),
extent=vk.VkExtent2D(width=rt.width, height=rt.height),
),
clearValueCount=0,
pClearValues=None,
)
vk.vkCmdBeginRenderPass(cmd, rp_begin, vk.VK_SUBPASS_CONTENTS_INLINE)
vk.vkCmdSetViewport(
cmd,
0,
1,
[vk.VkViewport(x=0.0, y=0.0, width=float(rt.width), height=float(rt.height), minDepth=0.0, maxDepth=1.0)],
)
vk.vkCmdSetScissor(
cmd,
0,
1,
[vk.VkRect2D(offset=vk.VkOffset2D(x=0, y=0), extent=vk.VkExtent2D(width=rt.width, height=rt.height))],
)
self._item_submitter.render(cmd, view, rt.width, rt.height, camera=camera)
vk.vkCmdEndRenderPass(cmd)
[docs]
@property
def texture_id(self) -> int:
return self._texture_id
[docs]
@property
def width(self) -> int:
return self._width
[docs]
@property
def height(self) -> int:
return self._height
[docs]
@property
def ready(self) -> bool:
return self._target is not None and self._texture_id >= 0
# Per-frame clear colour, set by the manager from the SubViewport's
# transparent_bg flag before begin_pass.
_clear_colour: list[float] = [0.0, 0.0, 0.0, 1.0]
[docs]
def destroy(self) -> None:
"""Release the target, Draw2D pass, and the bindless slot."""
if self._target is None and self._draw2d_pass is None:
return
vk.vkDeviceWaitIdle(self._engine.ctx.device)
if self._draw2d_pass is not None:
self._draw2d_pass.cleanup()
self._draw2d_pass = None
if self._target is not None:
self._target.destroy()
self._target = None
if self._texture_id >= 0:
self._engine.unregister_texture(self._texture_id)
self._texture_id = -1
self._width = 0
self._height = 0
class _SubTreeView:
"""Minimal duck-typed SceneTree view over one SubViewport's subtree.
:meth:`SceneAdapter.render_to_target` / :meth:`SceneAdapter.submit_scene`
only read a small, fixed set of ``tree`` attributes. Rather than spin up a
full :class:`SceneTree` (which fires lifecycle hooks, owns input tables,
etc.) per SubViewport, this carries exactly those attributes. ``root`` is
the SubViewport node itself, so ``_collect_nodes`` walks only its children.
"""
__slots__ = (
"root",
"_screen_size",
"_structure_version",
"play_viewport_rect",
"_render_camera_override",
"_current_camera_2d",
"overlay_offset",
"_app",
)
def __init__(self, root: Any, screen_size: tuple[float, float]) -> None:
self.root = root
self._screen_size = screen_size
self._structure_version = -1 # forces a re-walk on first submit
self.play_viewport_rect: tuple[float, float, float, float] | None = None
self._render_camera_override = None
self._current_camera_2d = None
self.overlay_offset = (0.0, 0.0)
self._app = None
@property
def screen_size(self) -> tuple[float, float]:
return self._screen_size
[docs]
class SubViewportManager:
"""Owns one :class:`SubViewportRenderer` per live SubViewport node.
Created once per :class:`App` run and invoked from the engine ``pre_render``
callback via :meth:`render_all`. Discovers SubViewports each frame by
walking the main scene tree, lazily creating a target on first sight,
debounce-resizing when ``SubViewport.size`` changes, and reaping targets
whose nodes have left the tree.
"""
# Resize debounce: only resize once the requested size has been stable for
# this many consecutive frames (mirrors PlayMode.ensure_game_viewport_size).
_RESIZE_DEBOUNCE = 6
def __init__(self, engine: Any, adapter: Any) -> None:
self._engine = engine
self._adapter = adapter
# id(node) -> renderer / view / debounce bookkeeping
self._renderers: dict[int, SubViewportRenderer] = {}
self._views: dict[int, _SubTreeView] = {}
self._nodes: dict[int, Any] = {} # keep a ref so id() stays unique while live
self._pending_size: dict[int, tuple[int, int]] = {}
self._stable_frames: dict[int, int] = {}
# Per-SubViewport 2D item cache + publisher (RTT-2D path).
# Each SubViewport's 2D subtree is its own retained item set, with
# its own Camera2D, rendered into its own offscreen target. Lazily created
# the first ON frame a viewport draws 2D; ``None`` while the flag is OFF
# (the legacy ``render_draw2d`` op-stream path stays authoritative).
self._item_caches: dict[int, Any] = {}
self._item_publishers: dict[int, Any] = {}
# Monotonic stamp handed to each view's _structure_version before a
# submit. The shared SceneAdapter caches its collected-node list by
# (tree, _structure_version); without a unique stamp per view, two
# views (both starting at -1) or a view and the main tree could collide
# and one would render another tree's geometry. A fresh value each
# render forces a correct re-walk per view.
self._version_counter: int = 1_000_000
# Per-view two-phase Hi-Z occlusion bundles, keyed by id(node),
# created lazily for SubViewports with ``use_occlusion=True``. Empty
# (one falsy getattr per record) on the default path.
self._occlusion: dict[int, Any] = {}
# SubViewport ids already warned that use_occlusion is deferred in
# pipelined render mode (one-time, mirrors probe-capture deferral).
self._warned_occ_pipelined: set[int] = set()
# Set of broken (producer, consumer) edges last logged, so a stable
# feedback cycle is reported once rather than every frame.
self._logged_lagged: set = set()
# Persistent SceneTargetGraph driving the SubViewport render order.
# Keeping one instance across frames engages its compile cache: the
# topo-sort reruns only when the job/edge fingerprint changes (a
# structure bump, a slot assignment, a new sampling binding), not per
# frame. Lazily created (core import stays off the module top level).
self._target_graph: Any = None
[docs]
def render_all(self, cmd: Any, tree: Any) -> bool:
"""Record every SubViewport in *tree* into the frame's primary command buffer.
Called from the engine ``pre_render`` callback, before the main scene
pass, with the frame's primary ``cmd``. Each SubViewport renders its own
subtree with its own camera as a scene-render unit (SRU): it reserves a
slice of the shared transform SSBO and records its draws with absolute
``first_instance`` indices into ``cmd``. No per-viewport command buffer,
no ``vkQueueWaitIdle``: the whole frame ends in one ``vkQueueSubmit``.
Each offscreen pass leaves its colour target in ``SHADER_READ_ONLY`` and
the offscreen render pass's outgoing colour dependency makes that write
visible to a later sample in the same command buffer.
The list is topologically ordered so a producer renders before any
SubViewport that samples it (see :meth:`_order_live`); a consumer
therefore sees *this* frame's content. Genuine cycles degrade to a
one-frame-lagged back-edge and never raise.
Returns ``True`` if at least one SubViewport rendered this frame (purely
informational now: there is no longer a re-submit, since the main scene's
submission lists are preserved across each SRU).
"""
to_record = self.prepare_all(tree)
for entry in to_record:
self.record_one(cmd, entry)
return bool(to_record)
[docs]
def prepare_all(self, tree: Any) -> list[tuple[Any, SubViewportRenderer, _SubTreeView, Any, Any]]:
"""Pass 1 of :meth:`render_all`: create/resize/publish, NO cmd recording.
Discovers + topologically orders the live SubViewports, creates or
debounce-resizes each renderer, honours ``render_target_update_mode``,
publishes each viewport's 2D items, and plans multi-GPU offload. All
bindless-texture descriptor updates (register_texture on create,
update_texture on resize) happen HERE, so a caller can interleave the
returned entries' recording with other offscreen targets (RenderViews)
without mutating a descriptor set an already-recorded draw bound.
Returns the ordered entries for :meth:`record_one`; empty when nothing
renders this frame.
"""
if tree is None or tree.root is None:
return []
# Lazy import keeps this module import-light and avoids a hard core dep
# at module load (the type is only needed for isinstance discovery).
from simvx.core import SubViewport
live = self._collect_subviewports(tree.root, SubViewport)
self._reap(set(map(id, live)))
if not live:
return []
# Topologically order the live SubViewports so a producer (whose
# offscreen texture another SubViewport samples) renders BEFORE its
# consumer in this same primary cmd. Without this, flat discovery order
# would let a consumer sample last frame's content (1-frame lag).
# Cycles (mirror-facing-mirror) degrade gracefully: the minimal
# back-edge(s) are broken and that consumer alone lags one frame.
live, lagged = self._order_live(live, tree)
struct_v = int(getattr(tree, "_structure_version", 0))
# Pass 1: create / resize every renderer and publish each SubViewport's
# 2D items. All bindless-texture descriptor updates (register_texture on
# create, update_texture on resize) MUST happen here, BEFORE any draw is
# recorded into the shared primary cmd. Updating a descriptor set that an
# already-recorded draw in this same cmd bound would invalidate the cmd
# ("destroyed or updated without UPDATE_AFTER_BIND"). The old per-viewport
# one-time command buffers hid this; the single-submit model exposes it.
# ``item_2d = (view, camera)`` is this viewport's published RTT-2D view.
to_record: list[tuple[Any, SubViewportRenderer, _SubTreeView, Any, Any]] = []
for node in live:
key = id(node)
w, h = int(node.size[0]), int(node.size[1])
if w < 1 or h < 1:
continue
rend = self._ensure_renderer(key, node, w, h)
if rend is None or not rend.ready:
continue
self._debounce_resize(key, rend, node, w, h)
# Keep the target's attachment layout in step with the main HDR target
# (thin G-buffer on/off) so the forward pipelines stay render-pass-
# compatible when SSR/SSGI toggles. No-op on the common path.
rend.ensure_gbuffer()
# Honour update_mode: "always" (default) renders every frame;
# "once" renders a single frame then freezes; "disabled" never
# renders (slot stays valid but stale).
mode = getattr(node, "render_target_update_mode", "always")
if mode == "disabled":
node._texture_id = rend.texture_id
continue
if mode == "once" and getattr(node, "_svp_rendered_once", False):
node._texture_id = rend.texture_id
continue
view = self._views[key]
self._version_counter += 1
view._structure_version = self._version_counter
view._screen_size = (float(rend.width), float(rend.height))
view._current_camera_2d = self._find_camera_2d(node)
camera_3d = self._find_camera_3d(node)
rend._clear_colour = (
[0.0, 0.0, 0.0, 0.0] if getattr(node, "transparent_bg", False) else [0.0, 0.0, 0.0, 1.0]
)
# Collect the subtree's 2D content as a published item view (honours
# the SubViewport's own Camera2D + carries Text2D).
item_2d = self._publish_2d_items(key, node, view, struct_v)
to_record.append((node, rend, view, camera_3d, item_2d))
# D8 multi-GPU: compute this frame's per-SRU device routes on the ordered
# node list before recording. No-op on the single-GPU / unopted path (the
# adapter has no offload coordinator), so the recording below is unchanged.
# sru_id keys by id(node) (matching the per-SRU lookup); cost is the node's
# renderable-descendant count, the live-path proxy for snapshotted instances.
ordered_nodes = [n for (n, _r, _v, _c, _i) in to_record]
self._adapter.plan_sru_offload(
ordered_nodes,
sru_id=id,
cost=self._sru_node_cost,
)
return to_record
[docs]
def record_one(self, cmd: Any, entry: tuple[Any, SubViewportRenderer, _SubTreeView, Any, Any]) -> None:
"""Pass 2 of :meth:`render_all`: record ONE prepared SubViewport SRU.
No descriptor mutation happens here, so binds recorded for an earlier
offscreen target stay valid. Each SRU reserves its own transform-SSBO
slice and records absolute first_instance draws; the main scene's base-0
slice is untouched. sru_id = id(node) keeps the visibility cache from
colliding.
"""
node, rend, view, camera_3d, item_2d = entry
from .view_occlusion import view_occlusion_for
self._adapter.render_to_target(
cmd,
rend,
view,
camera=camera_3d,
screen_size=(float(rend.width), float(rend.height)),
item_2d=item_2d,
sru_id=id(node),
occlusion=view_occlusion_for(self._occlusion, node, self._engine),
)
node._texture_id = rend.texture_id
node._svp_rendered_once = True
[docs]
def build_srus(self, tree: Any) -> list:
"""Snapshot each live SubViewport into an owned :class:`SubViewportSRU` plan.
The pipelined-extract counterpart of :meth:`render_all`: same discovery,
topological ordering, create/resize, and update-mode gating, but instead
of recording into a command buffer it captures, on the MAIN thread, the
owned inputs the render thread needs to record the SRU offscreen WITHOUT
walking the live tree, the submission lists, camera matrices, clear
colour, and isolated Draw2D ops.
Returns the plans producer-first (so a consumer SRU follows the producer
it samples). Empty when no SubViewport is present. Create/resize +
bindless descriptor updates still happen here (on the main thread, before
the render thread records), exactly as in ``render_all`` pass 1.
"""
if tree is None or tree.root is None:
return []
from simvx.core import SubViewport
from .render_packet import SubViewportSRU
live = self._collect_subviewports(tree.root, SubViewport)
self._reap(set(map(id, live)))
if not live:
return []
live, _lagged = self._order_live(live, tree)
struct_v = int(getattr(tree, "_structure_version", 0))
srus: list[SubViewportSRU] = []
for node in live:
key = id(node)
w, h = int(node.size[0]), int(node.size[1])
if w < 1 or h < 1:
continue
rend = self._ensure_renderer(key, node, w, h)
if rend is None or not rend.ready:
continue
self._debounce_resize(key, rend, node, w, h)
# Keep the target's attachment layout in step with the main HDR target's
# thin G-buffer (see prepare_all): the render thread replays the SRU with
# the shared forward pipelines, which must be render-pass-compatible.
rend.ensure_gbuffer()
# Per-view occlusion is DEFERRED in pipelined mode, exactly
# like reflection-probe capture: the SRU replays un-culled from the
# packet plan. Warn once per node so the omission is visible.
if getattr(node, "use_occlusion", False) and key not in self._warned_occ_pipelined:
self._warned_occ_pipelined.add(key)
log.warning(
"SubViewport '%s': use_occlusion is deferred in pipelined render mode "
"(the view renders un-culled); use the immediate render mode",
node.name,
)
mode = getattr(node, "render_target_update_mode", "always")
if mode == "disabled":
node._texture_id = rend.texture_id
continue
if mode == "once" and getattr(node, "_svp_rendered_once", False):
node._texture_id = rend.texture_id
continue
view = self._views[key]
self._version_counter += 1
view._structure_version = self._version_counter
view._screen_size = (float(rend.width), float(rend.height))
view._current_camera_2d = self._find_camera_2d(node)
camera_3d = self._find_camera_3d(node)
clear_colour = (0.0, 0.0, 0.0, 0.0) if getattr(node, "transparent_bg", False) else (0.0, 0.0, 0.0, 1.0)
# Publish the subtree's 2D items (own Camera2D + Text2D) as a frozen,
# render-thread-safe view.
item_view, item_camera = self._publish_2d_items(key, node, view, struct_v)
instances, skinned, cam_view, cam_proj, mm_blocks, billboards = self._adapter.snapshot_sru(
view,
camera=camera_3d,
screen_size=(float(rend.width), float(rend.height)),
)
srus.append(
SubViewportSRU(
sru_id=key,
renderer=rend,
width=rend.width,
height=rend.height,
clear_colour=clear_colour,
camera_view=cam_view,
camera_proj=cam_proj,
screen_size=(float(rend.width), float(rend.height)),
instances=instances,
skinned_instances=skinned,
multimesh_blocks=mm_blocks,
billboard_submissions=billboards,
item_view=item_view,
item_camera=item_camera,
)
)
node._texture_id = rend.texture_id
node._svp_rendered_once = True
# D8 multi-GPU: compute this frame's per-SRU device routes on the MAIN
# thread from the ordered SRU plans (real sru_id + instance counts), before
# the packet is handed to the render thread. The render thread's
# ``render_sru_from_plan`` then reads the cached route read-only. No-op
# without an offload coordinator (single-GPU / unopted): every SRU stays on
# the primary and the render thread records it byte-identically.
self._adapter.plan_sru_offload(srus)
return srus
def _publish_2d_items(self, key: int, node: Any, view: _SubTreeView, structure_version: int) -> tuple[Any, Any]:
"""Collect + publish ``node``'s 2D subtree as a frozen item view.
The RTT-2D producer: walks the SubViewport's OWN subtree through a
per-viewport :class:`RenderItemCache` (so a static 2D scene retains and a
moving sprite patches one item), publishes the frozen view, and returns
``(view, camera_affine)`` where the affine is the SubViewport's OWN
Camera2D (so its pan/zoom shows up IN the texture). Text2D enters here as
a first-class GLYPH item.
"""
from ..render2d import (
ItemPublisher,
RenderItemCache,
ViewState,
camera_affine_from_tree,
)
cache = self._item_caches.get(key)
if cache is None:
from simvx.core.ui.theme import theme_generation
# viewport=node scopes this cache to overlays opened INSIDE this
# SubViewport (a context menu in a nested editor), so they draw in this
# offscreen target through this viewport's Camera2D, exactly once, and
# never leak into the main pass.
cache = RenderItemCache(theme_generation=theme_generation, tree=getattr(node, "_tree", None), viewport=node)
self._item_caches[key] = cache
self._item_publishers[key] = ItemPublisher()
cam = view._current_camera_2d
if cam is not None:
z = float(cam.zoom) if cam.zoom > 0 else 1.0
view_state = ViewState(
offset=(float(cam.current[0]), float(cam.current[1])),
zoom=(z, z),
rotation=0.0,
viewport=(int(view._screen_size[0]), int(view._screen_size[1])),
)
else:
view_state = ViewState(viewport=(int(view._screen_size[0]), int(view._screen_size[1])))
# Resolve the subtree's Sprite2D / NinePatchRect textures FIRST: a sprite
# whose ``_texture_id`` is still -1 emits no item, and that empty capture
# would stay cached (``_texture_id`` is not a Property). The main pass's own
# resolve runs later and prunes SubViewports, so do it here.
self._adapter.resolve_2d_textures(node)
# Retention key: the MAIN tree's structure version (the SubViewport subtree
# is part of it, so an add/remove anywhere bumps it). NOT ``view._structure_
# version`` -- that is the per-frame stamp the 3D node-list cache needs, which
# changes every frame and would defeat 2D retention. With a stable version a
# static 2D scene retains (frame-skip) and a moved sprite patches one item.
result = cache.frame(node, structure_version=structure_version, view=view_state)
published = self._item_publishers[key].publish(result, epoch=cache.epoch)
return published, camera_affine_from_tree(view)
def _order_live(self, live: list, tree: Any) -> tuple[list, set]:
"""Return *live* reordered so producers precede consumers (same frame).
Reuses the single canonical core helper
:func:`simvx.core._subviewport_order.order_subviewports` (no algorithm
is duplicated here): it registers one ``SceneJob`` per SubViewport plus
the main scene into this manager's persistent ``SceneTargetGraph``,
scanning each subtree for a sampler slot equal to another live
SubViewport's bindless slot (``node._texture_id``) and unioning the
explicit ``SubViewport.feeds_from`` hints; the graph Kahn topo-sorts
(recompiling only when the structure changes, not per frame) and breaks
minimal back-edges on cycles. The depth cap comes from the scene's
``WorldEnvironment``. Broken (lagged) edges are logged once.
"""
from simvx.core._subviewport_order import order_subviewports
from simvx.core.scene_target_graph import SceneTargetGraph
if self._target_graph is None:
self._target_graph = SceneTargetGraph()
depth_cap = self._feedback_depth(tree)
ordered, lagged = order_subviewports(
live,
lambda n: getattr(n, "_texture_id", -1),
depth_cap=depth_cap,
graph=self._target_graph,
)
if lagged and lagged != self._logged_lagged:
self._logged_lagged = set(lagged)
log.info(
"SubViewport feedback cycle: %d edge(s) lag one frame %s",
len(lagged),
[(type(p).__name__, type(c).__name__) for p, c in lagged],
)
return ordered, lagged
@staticmethod
def _feedback_depth(tree: Any) -> int:
"""Read ``WorldEnvironment.scene_feedback_max_depth`` (default 1)."""
from simvx.core.world_environment import WorldEnvironment
root = getattr(tree, "root", None)
env = root.find(WorldEnvironment) if root is not None else None
return int(getattr(env, "scene_feedback_max_depth", 1)) if env is not None else 1
def _ensure_renderer(self, key: int, node: Any, w: int, h: int) -> SubViewportRenderer | None:
rend = self._renderers.get(key)
if rend is not None:
return rend
rend = SubViewportRenderer(self._engine)
rend.create(w, h)
if not rend.ready:
rend.destroy()
return None
self._renderers[key] = rend
self._views[key] = _SubTreeView(node, (float(w), float(h)))
self._nodes[key] = node
self._pending_size[key] = (w, h)
self._stable_frames[key] = 0
node._texture_id = rend.texture_id
return rend
def _debounce_resize(self, key: int, rend: SubViewportRenderer, node: Any, w: int, h: int) -> None:
target = (max(w, 1), max(h, 1))
if (rend.width, rend.height) == target:
self._pending_size[key] = target
self._stable_frames[key] = 0
return
if target != self._pending_size.get(key):
self._pending_size[key] = target
self._stable_frames[key] = 0
else:
self._stable_frames[key] += 1
if self._stable_frames[key] >= self._RESIZE_DEBOUNCE:
rend.resize(*target)
# Slot is stable across resize, but re-publish defensively.
node._texture_id = rend.texture_id
self._stable_frames[key] = 0
def _reap(self, live_keys: set[int]) -> None:
"""Destroy renderers whose SubViewport has left the tree."""
for key in [k for k in self._renderers if k not in live_keys]:
self._renderers.pop(key).destroy()
self._views.pop(key, None)
self._nodes.pop(key, None)
self._pending_size.pop(key, None)
self._stable_frames.pop(key, None)
self._item_caches.pop(key, None)
self._item_publishers.pop(key, None)
occ = self._occlusion.pop(key, None)
if occ is not None:
occ.destroy()
self._warned_occ_pipelined.discard(key)
@staticmethod
def _collect_subviewports(root: Any, sub_viewport_type: type) -> list:
"""Find all SubViewports under *root*, but do NOT descend into them.
A SubViewport's children belong to its own offscreen subtree, never the
main pass, so we stop the walk at each SubViewport.
"""
found: list = []
stack = [root]
while stack:
node = stack.pop()
if node is not root and isinstance(node, sub_viewport_type):
found.append(node)
continue # do not descend: children render offscreen
stack.extend(node.children)
# Root itself may be a SubViewport (unusual but valid).
if root is not None and isinstance(root, sub_viewport_type) and root not in found:
found.append(root)
return found
@staticmethod
def _sru_node_cost(node: Any) -> int:
"""Offload-cost proxy for a live SubViewport node: its renderable descendants.
The synchronous path plans offload from live nodes (not snapshotted
instance lists), so count the MeshInstance3D / MultiMeshInstance3D
descendants in the SubViewport's subtree as the cheap heaviest-first proxy
mirroring ``multi_device._sru_cost``. At least 1 so ordering is stable.
"""
from simvx.core import MeshInstance3D, MultiMeshInstance3D
n = 0
stack = list(node.children)
while stack:
child = stack.pop()
if isinstance(child, MeshInstance3D | MultiMeshInstance3D):
n += 1
stack.extend(child.children)
return n if n > 0 else 1
@staticmethod
def _find_camera_3d(node: Any) -> Any:
from simvx.core import Camera3D
for child in node.children:
cam = SubViewportManager._first_of_type(child, Camera3D)
if cam is not None:
return cam
return None
@staticmethod
def _find_camera_2d(node: Any) -> Any:
from simvx.core import Camera2D
for child in node.children:
cam = SubViewportManager._first_of_type(child, Camera2D)
if cam is not None:
return cam
return None
@staticmethod
def _first_of_type(node: Any, typ: type) -> Any:
stack = [node]
while stack:
n = stack.pop()
if isinstance(n, typ) and getattr(n, "_visible_in_hierarchy", True):
return n
stack.extend(n.children)
return None
[docs]
def destroy(self) -> None:
"""Tear down all targets (call on app shutdown)."""
for rend in self._renderers.values():
rend.destroy()
for occ in self._occlusion.values():
occ.destroy()
self._occlusion.clear()
self._warned_occ_pipelined.clear()
self._renderers.clear()
self._views.clear()
self._nodes.clear()
self._pending_size.clear()
self._stable_frames.clear()
self._item_caches.clear()
self._item_publishers.clear()