"""Viewport, SubViewport, and Display system for SimVX."""
import logging
from enum import IntEnum
import numpy as np
from ._draw_context import READS_VIEWPORT_SIZE, redraw_readers
from .descriptors import Property
from .graphics.texture_slot import TextureSlot
from .node import Node
from .ui.core import Control
log = logging.getLogger(__name__)
[docs]
class StretchMode(IntEnum):
DISABLED = 0 # Pixel-perfect, no scaling
CANVAS_ITEMS = 1 # Scale 2D content, keep 3D at window resolution
VIEWPORT = 2 # Scale everything (render at design res, upscale)
[docs]
class StretchAspect(IntEnum):
IGNORE = 0 # Stretch to fill, distort if needed
KEEP = 1 # Letterbox/pillarbox to maintain aspect
KEEP_WIDTH = 2 # Keep width, adjust height
KEEP_HEIGHT = 3 # Keep height, adjust width
EXPAND = 4 # Expand viewport, no black bars
[docs]
class VSyncMode(IntEnum):
DISABLED = 0
ENABLED = 1
ADAPTIVE = 2
MAILBOX = 3
[docs]
class WindowMode(IntEnum):
WINDOWED = 0
FULLSCREEN = 1
BORDERLESS = 2
MAXIMIZED = 3
[docs]
class DisplaySettings:
"""Project-level display configuration. Read from simvx.toml."""
def __init__(self):
self.design_width: int = 1280
self.design_height: int = 720
self.stretch_mode: StretchMode = StretchMode.DISABLED
self.stretch_aspect: StretchAspect = StretchAspect.KEEP
self.window_mode: WindowMode = WindowMode.WINDOWED
self.vsync: VSyncMode = VSyncMode.ENABLED
self.max_fps: int = 0 # 0 = unlimited
self.msaa: int = 0 # 0, 2, 4, 8
[docs]
def compute_viewport_rect(self, window_width: int, window_height: int) -> tuple[int, int, int, int]:
"""Compute the viewport rectangle (x, y, w, h) for 3D rendering."""
if self.stretch_mode == StretchMode.DISABLED:
return (0, 0, window_width, window_height)
dw, dh = self.design_width, self.design_height
ww, wh = window_width, window_height
dar = dw / dh
war = ww / wh
if self.stretch_aspect == StretchAspect.KEEP:
if war > dar:
vh = wh
vw = int(vh * dar)
vx = (ww - vw) // 2
vy = 0
else:
vw = ww
vh = int(vw / dar)
vx = 0
vy = (wh - vh) // 2
return (vx, vy, vw, vh)
return (0, 0, window_width, window_height)
[docs]
class SubViewport(TextureSlot, Node):
"""Renders its children to an offscreen texture.
The SubViewport's subtree is rendered into its own offscreen render target
each frame, using its *own* camera: a ``Camera3D`` and/or ``Camera2D``
placed among its children (a sensible default is used if none is present).
The rendered colour buffer is exposed as a bindless texture index via
:attr:`texture`, which other nodes in the main scene can sample: assign it
to a ``Material.albedo_tex_index`` for a 3D "monitor in the world", or feed
a ``Sprite2D`` for a 2D minimap / picture-in-picture.
The render-to-texture integration is driven from the graphics backend
(``simvx.graphics.renderer.sub_viewport.SubViewportManager``), not from this
node: core stays rendering-agnostic. SubViewports render *before* the main
scene pass each frame, so the main pass samples fresh content the same
frame. A SubViewport that samples *another* SubViewport sees the previous
frame's content (one-frame lag).
The texture is display-referred: the viewport's 3D content renders as linear
HDR and is tone-mapped with the same exposure and curve the main scene uses,
so a ``Sprite2D`` drawing it at 1:1 shows what the main scene would show for
that camera. The curve comes from a ``WorldEnvironment`` inside the
viewport's subtree when it has one, and otherwise from the main scene's.
Only the tone curve follows that rule; the post effects (bloom, fog and the
rest) do not run on the feed. The viewport's 2D content draws over the feed
after the tone curve, as the main scene's screen-space 2D does.
Properties:
size: Offscreen target dimensions in pixels.
transparent_bg: Clear to transparent instead of opaque black.
render_target_update_mode: ``"always"`` (default, render every frame),
``"once"`` (render a single frame then freeze), or ``"disabled"``
(never render; the slot stays valid but stale).
use_occlusion: Run the two-phase Hi-Z occlusion cull for this view's
own camera (desktop renderer; the web Hi-Z is single-phase and has
no per-view hook yet). Off by default: an offscreen view is
usually small enough that the occlusion pass costs more than it
saves. In the pipelined render mode the cull is deferred (the view
renders un-culled with a one-time warning), like probe capture.
"""
# A stable structural marker (mirrors ``CanvasLayer._is_canvas_layer``) the
# graphics 2D item pipeline duck-types on to PRUNE this node's subtree from
# the MAIN walk: a SubViewport's children render into its OWN offscreen
# target, so the main item collection + dirty scan must stop here, exactly
# as ``find_all_outside_subviewports`` does for the 3D path. Without this
# the SubViewport's 2D content would double-draw into the main framebuffer.
_is_subviewport: bool = True
size = Property((256, 256), on_change="_on_size_changed")
transparent_bg = Property(False)
render_target_update_mode = Property("always")
use_occlusion = Property(False, hint="Run the two-phase Hi-Z occlusion cull for this view's camera")
def __init__(self, name="SubViewport", **kwargs):
super().__init__(name=name, **kwargs)
self._scene_tree = None
# Explicit render-ordering hint: SubViewport node(s) this viewport
# consumes (samples) and must therefore render *after*. A runtime
# attribute, NOT a Property: node references do not serialise into a
# ``.py`` scene, and this is a per-frame ordering hint, not authored
# state. Accepts a single SubViewport, an iterable of them, or None.
# The per-frame ordering (``order_subviewports``) reads it and unions
# it with the implicitly detected sampling edges, so producers always
# render before consumers in the same frame. Default: no explicit edge.
self.feeds_from: SubViewport | list[SubViewport] | None = None
def _on_size_changed(self) -> None:
"""Re-capture every draw under this viewport that read its size; the screen's readers are another target."""
redraw_readers(self, READS_VIEWPORT_SIZE)
[docs]
@property
def texture(self):
"""The rendered texture, usable as material input or Sprite2D texture."""
return self._texture_id
[docs]
@property
def texture_size(self) -> tuple[int, int]:
"""Pixel dimensions of the underlying render target."""
return tuple(self.size)
[docs]
def find_outside_subviewports[T](root: Node, node_type: type[T]) -> T | None:
"""The first ``node_type`` under *root* in ``Node.find`` order, not entering a ``SubViewport``.
A ``WorldEnvironment`` or ``Camera3D`` inside a SubViewport belongs to that
viewport, so a lookup for the main scene's must not reach it. *root* itself
may be a SubViewport: ``find_outside_subviewports(viewport, WorldEnvironment)``
finds that viewport's own environment and skips any nested viewport's.
"""
stack = list(reversed(root.children))
while stack:
node = stack.pop()
if isinstance(node, node_type):
return node
if not isinstance(node, SubViewport):
stack.extend(reversed(node.children))
return None
[docs]
class ViewportContainer(Control):
"""Control that owns a :class:`SubViewport` and ties its resolution to the layout.
Register the viewport with :meth:`set_viewport`. With ``stretch`` enabled
(the default) that call resizes the viewport's offscreen target to match
the control's current rect, so an offscreen view laid out by a container
renders at exactly the pixel size it will be shown at, with no rescaling.
With ``stretch`` disabled the viewport keeps whatever ``size`` it was
given and the container only holds the reference.
``stretch`` is sampled inside :meth:`set_viewport`, so it applies at
registration time: after the control is resized, call :meth:`set_viewport`
again (or assign ``viewport.size``) to re-match the target.
The container does not blit anything by itself. To show the rendered
result, sample ``viewport.texture`` from a ``Sprite2D`` or a material, as
described on :class:`SubViewport`.
"""
stretch = Property(True, hint="Resize the hosted SubViewport to this control's rect when it is registered")
def __init__(self, name="ViewportContainer", **kwargs):
super().__init__(name=name, **kwargs)
self._viewport: SubViewport | None = None
[docs]
def set_viewport(self, viewport: SubViewport):
"""Host *viewport*, matching its offscreen size to this control when ``stretch``."""
self._viewport = viewport
if self.stretch:
viewport.size = (int(self.size.x), int(self.size.y))