Source code for simvx.graphics.renderer.render_view

"""RenderView render-to-texture: the MAIN scene from a second camera (design D6).

A :class:`~simvx.core.RenderView` publishes an offscreen texture of the main
scene tree rendered through the ``Camera3D`` its ``camera`` path points at: a
security monitor, a rear-view mirror, a kill-cam. Where
:class:`~.sub_viewport.SubViewportManager` renders each SubViewport's OWN
subtree, this manager re-renders the MAIN tree with a camera override, reusing
the proven :class:`~.game_viewport.GameViewportRenderer` offscreen target
(begin_pass / render_scene_content / end_pass contract) as one scene-render
unit (SRU) per view: an isolated transform-SSBO slice recorded into the same
primary command buffer as the rest of the frame, exactly like the
reflection-probe face captures.

Scheduling (D6/B1): RenderViews and SubViewports are ordered together by the
shared :class:`~simvx.core.scene_target_graph.SceneTargetGraph` through
:func:`render_offscreen_targets`, the one pre-render entry point. A RenderView
job *consumes* whatever the main tree samples (it renders the main scene), so
a RenderView showing a SubViewport monitor renders after that SubViewport, and
a SubViewport whose subtree samples a RenderView renders after the RenderView.
Genuine cycles (two RenderViews each visible on the other's monitor) degrade
to a one-frame lag on the broken edge, never an error. A scene with no
RenderView takes today's exact ``SubViewportManager.render_all`` path.

The re-submit of the main tree happens with ``submit_2d=False``: the 2D
overlay submits are per-frame appends to shared renderer lists and already ran
for the main pass, so a second run would double the main pass's 2D content. A
RenderView therefore captures the 3D world only (2D HUD content is a
screen-space overlay, not part of the world a second camera would see).

Pipelined (P2) mode: RenderView capture is DEFERRED, exactly like
reflection-probe capture (see ``render_packet.py``, "State intentionally NOT
snapshotted"): the manager is not invoked, the published slot stays valid but
stale, and ``App._warn_pipelined_unpacketised`` logs a one-time warning.
Immediate (synchronous) mode is the supported path.
"""

from __future__ import annotations

import logging
from typing import Any

from .game_viewport import GameViewportRenderer

log = logging.getLogger(__name__)

__all__ = ["RenderViewManager", "render_offscreen_targets"]


[docs] class RenderViewManager: """Owns one :class:`GameViewportRenderer` per live RenderView node. Created once per :class:`App` run next to the ``SubViewportManager`` and driven from the engine ``pre_render`` callback via :func:`render_offscreen_targets`. Discovery is cached by the tree's structure version (the live set only changes on add/remove), so a scene with no RenderView pays one integer compare per frame. """ # Resize debounce: only resize once the requested size has been stable for # this many consecutive frames (mirrors SubViewportManager). _RESIZE_DEBOUNCE = 6 def __init__(self, engine: Any, adapter: Any) -> None: self._engine = engine self._adapter = adapter # id(node) -> renderer / debounce bookkeeping self._renderers: dict[int, GameViewportRenderer] = {} 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] = {} # (structure_version, live nodes) discovery cache. self._live_cache: tuple[int, tuple[Any, ...]] = (-1, ()) # Node ids already warned for a missing/unresolvable camera (once each). self._warned_no_camera: set[int] = set() # Per-view two-phase Hi-Z occlusion bundles (RM-B6), keyed by id(node), # created lazily for RenderViews with ``use_occlusion=True``. Empty # (one falsy getattr per record) on the default path. self._occlusion: dict[int, Any] = {} # Persistent SceneTargetGraph for the combined SubViewport + RenderView # order (fingerprint-cached compile, see SubViewportManager._order_live). self._target_graph: Any = None self._logged_lagged: set = set()
[docs] def collect_live(self, tree: Any) -> tuple[Any, ...]: """Return the live RenderView nodes, cached by tree structure version.""" if tree is None or tree.root is None: return () version = int(getattr(tree, "_structure_version", 0)) if self._live_cache[0] == version: return self._live_cache[1] from simvx.core import RenderView found: list[Any] = [] stack = [tree.root] while stack: node = stack.pop() if isinstance(node, RenderView): found.append(node) stack.extend(node.children) live = tuple(found) self._live_cache = (version, live) self._reap({id(n) for n in live}) return live
[docs] def prepare_all(self, tree: Any) -> list[tuple[Any, GameViewportRenderer, Any]]: """Create/resize each live RenderView's target; NO cmd recording. Bindless descriptor updates (register on create, update on resize) happen here, before any offscreen draw is recorded into the shared primary cmd (same invariant as ``SubViewportManager.prepare_all``). Honours ``render_target_update_mode`` and resolves each view's camera; views without a resolvable camera are skipped with a one-time warning per node. """ entries: list[tuple[Any, GameViewportRenderer, Any]] = [] for node in self.collect_live(tree): key = id(node) # A planar reflection derives its camera and size instead of owning # them: re-mirror the active main camera across the node's plane # (and resize to the main viewport * resolution_scale) BEFORE the # size read and camera resolve below (design RM-B4). if getattr(node, "_is_planar_reflection", False): node.sync_reflection(self._main_viewport_size(tree)) 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() node._texture_id = rend.texture_id mode = getattr(node, "render_target_update_mode", "always") if mode == "disabled": continue if mode == "once" and node._rendered_once: continue if mode == "when_visible" and not getattr(node, "_visible_in_hierarchy", True): continue camera = node.resolve_camera() if camera is None: if key not in self._warned_no_camera: self._warned_no_camera.add(key) log.warning( "RenderView '%s' has no resolvable camera (camera=%r); not rendering", node.name, node.camera, ) continue self._warned_no_camera.discard(key) entries.append((node, rend, camera)) return entries
[docs] def record_one(self, cmd: Any, tree: Any, entry: tuple[Any, GameViewportRenderer, Any]) -> None: """Record ONE prepared RenderView SRU: the MAIN tree, camera overridden. Mirrors the reflection-probe face capture call shape: the live tree with a camera override and a stable sru_id (no ``screen_size`` override: the 3D viewport is sized from the target inside ``render_to_target``, and 2D state is untouched because ``submit_2d=False``). """ node, rend, camera = entry from .view_occlusion import view_occlusion_for self._adapter.render_to_target( cmd, rend, tree, camera=camera, sru_id=id(node), submit_2d=False, occlusion=view_occlusion_for(self._occlusion, node, self._engine), ) node._texture_id = rend.texture_id node._rendered_once = True
def _main_viewport_size(self, tree: Any) -> tuple[float, float]: """Pixel size of the main scene's 3D viewport this frame. Mirrors ``SceneAdapter.submit_scene``: the play-mode viewport rect (scaled to physical pixels) when set, else the full engine extent. A planar reflection sizes its target from this so its projective UV stays aligned with the main view. """ vp_rect = getattr(tree, "play_viewport_rect", None) if vp_rect is not None: sx, sy = self._engine.content_scale return (vp_rect[2] * sx, vp_rect[3] * sy) w, h = self._engine.extent return (float(w), float(h)) def _ensure_renderer(self, key: int, node: Any, w: int, h: int) -> GameViewportRenderer | None: rend = self._renderers.get(key) if rend is not None: return rend rend = GameViewportRenderer(self._engine) rend.create(w, h) if not rend.ready: rend.destroy() return None self._renderers[key] = rend 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: GameViewportRenderer, 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 RenderView 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._nodes.pop(key, None) self._pending_size.pop(key, None) self._stable_frames.pop(key, None) self._warned_no_camera.discard(key) occ = self._occlusion.pop(key, None) if occ is not None: occ.destroy()
[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._renderers.clear() self._nodes.clear() self._pending_size.clear() self._stable_frames.clear() self._warned_no_camera.clear() self._live_cache = (-1, ())
[docs] def render_offscreen_targets(cmd: Any, tree: Any, sub_viewports: Any, render_views: Any) -> None: """Record every offscreen scene target (SubViewports + RenderViews) into *cmd*. The one pre-render entry point (synchronous path). With no live RenderView this is exactly ``sub_viewports.render_all(cmd, tree)``: the common path is unchanged (golden gate). With RenderViews present, both managers prepare (descriptor updates first), then one combined :func:`~simvx.core._subviewport_order.order_subviewports` compile over both kinds decides the recording order, so producers render before consumers across target kinds in the same frame. """ rv_entries = render_views.prepare_all(tree) if render_views is not None else [] if not rv_entries: if sub_viewports is not None: sub_viewports.render_all(cmd, tree) return svp_entries = sub_viewports.prepare_all(tree) if sub_viewports is not None else [] from simvx.core._subviewport_order import order_subviewports, scan_consumed_slots from simvx.core.scene_target_graph import SceneTargetGraph if render_views._target_graph is None: render_views._target_graph = SceneTargetGraph() # A RenderView renders the MAIN tree, so its job consumes whatever the main # pass samples; scan once and share it across every RenderView job. main_slots = scan_consumed_slots(tree.root) svp_by_id = {id(e[0]): e for e in svp_entries} rv_by_id = {id(e[0]): e for e in rv_entries} nodes = [e[0] for e in svp_entries] + [e[0] for e in rv_entries] ordered, lagged = order_subviewports( nodes, lambda n: getattr(n, "_texture_id", -1), consumes_of=lambda n: main_slots if id(n) in rv_by_id else scan_consumed_slots(n), graph=render_views._target_graph, ) if lagged and lagged != render_views._logged_lagged: render_views._logged_lagged = set(lagged) log.info( "offscreen-target feedback cycle: %d edge(s) lag one frame %s", len(lagged), [(type(p).__name__, type(c).__name__) for p, c in lagged], ) for node in ordered: entry = svp_by_id.get(id(node)) if entry is not None: sub_viewports.record_one(cmd, entry) else: render_views.record_one(cmd, tree, rv_by_id[id(node)])