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 .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). 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)) 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
[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): """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))