Source code for simvx.graphics.renderer.sub_viewport

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