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