Source code for simvx.core.viewport

"""Viewport, SubViewport, and Display system for SimVX."""

import logging
from enum import IntEnum

import numpy as np

from .descriptors import Property
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(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). 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 (design §2.6 / §5.3): 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 (the gate-4 main-FB leak). _is_subviewport: bool = True size = Property((256, 256)) 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._texture_id: int = -1 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
[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] class ViewportContainer(Control): """UI widget that hosts and displays a SubViewport.""" stretch = Property(True) def __init__(self, name="ViewportContainer", **kwargs): super().__init__(name=name, **kwargs) self._viewport: SubViewport | None = None
[docs] def set_viewport(self, viewport: SubViewport): self._viewport = viewport if self.stretch: viewport.size = (int(self.size.x), int(self.size.y))