Source code for simvx.core.viewport

"""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_canvas_transform(self, window_width: int, window_height: int) -> np.ndarray: """Compute the 2D canvas transform matrix based on stretch settings. Returns 3x3 affine matrix to apply to all 2D rendering. """ if self.stretch_mode == StretchMode.DISABLED: return np.eye(3, dtype=np.float32) dw, dh = self.design_width, self.design_height ww, wh = window_width, window_height sx = ww / dw sy = wh / dh # Apply aspect ratio correction ox, oy = 0.0, 0.0 if self.stretch_aspect == StretchAspect.IGNORE: pass # Use sx, sy as-is elif self.stretch_aspect == StretchAspect.KEEP: s = min(sx, sy) ox = (ww - dw * s) / 2 oy = (wh - dh * s) / 2 sx = sy = s elif self.stretch_aspect == StretchAspect.KEEP_WIDTH: sy = sx # Match width scale elif self.stretch_aspect == StretchAspect.KEEP_HEIGHT: sx = sy # Match height scale elif self.stretch_aspect == StretchAspect.EXPAND: s = max(sx, sy) sx = sy = s return np.array([[sx, 0, ox], [0, sy, oy], [0, 0, 1]], dtype=np.float32)
[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))