SubViewport

render a live 3D scene to a texture, shown on an in-world monitor.

▶ Run in browser

Tags: 3d subviewport render-to-texture

A SubViewport renders its own subtree (a spinning cube on a blue background, viewed by its own Camera3D) into an offscreen texture every frame. That texture is fed to a Material on a flat “monitor” slab in the main scene: a live screen in the world. The monitor’s feed updates each frame, proving the render-to- texture is genuinely live, not a one-shot capture.

Usage: uv run python examples/features/3d/sub_viewport.py

Source

  1"""SubViewport: render a live 3D scene to a texture, shown on an in-world monitor.
  2
  3A SubViewport renders its own subtree (a spinning cube on a blue background,
  4viewed by its own Camera3D) into an offscreen texture every frame. That texture
  5is fed to a Material on a flat "monitor" slab in the main scene: a live screen
  6in the world. The monitor's feed updates each frame, proving the render-to-
  7texture is genuinely live, not a one-shot capture.
  8
  9# /// simvx
 10# tags = ["3d", "subviewport", "render-to-texture"]
 11# screenshot_frame = 60
 12# ///
 13
 14Usage:
 15    uv run python examples/features/3d/sub_viewport.py
 16"""
 17
 18from simvx.core import (
 19    Camera3D,
 20    DirectionalLight3D,
 21    Input,
 22    InputMap,
 23    Key,
 24    Material,
 25    Mesh,
 26    MeshInstance3D,
 27    Node,
 28    Sprite2D,
 29    SubViewport,
 30    Text2D,
 31)
 32from simvx.graphics import App
 33
 34WIDTH, HEIGHT = 1280, 720
 35
 36
 37class SubViewportScene(Node):
 38    def on_ready(self):
 39        InputMap.add_action("quit", [Key.ESCAPE])
 40
 41        # ── Main-scene camera (looks at the monitor) ──────────────────────
 42        self.add_child(Camera3D(position=(0, 2, 6), look_at=(0, 0, 0), up=(0, 1, 0)))
 43        sun = self.add_child(DirectionalLight3D(intensity=2.0))
 44        sun.direction = (-0.4, -1.0, -0.6)
 45
 46        # ── SubViewport: a self-contained 3D scene with its OWN camera ────
 47        # Its subtree never appears in the main pass; it renders offscreen.
 48        self.sub = self.add_child(SubViewport(size=(512, 512)))
 49        self.sub.add_child(Camera3D(position=(0, 0, 3.0), look_at=(0, 0, 0), up=(0, 1, 0)))
 50        sub_sun = self.sub.add_child(DirectionalLight3D(intensity=3.0))
 51        sub_sun.direction = (-0.5, -0.7, -0.5)
 52        # A large emissive backdrop fills the feed so the monitor reads as a
 53        # bright screen rather than a dark panel, and makes the live update
 54        # obvious as the foreground cube spins across it.
 55        self.sub.add_child(
 56            MeshInstance3D(
 57                mesh=Mesh.cube(6.0),
 58                material=Material(colour=(0.15, 0.35, 0.85, 1.0), roughness=1.0),
 59                position=(0, 0, -3.5),
 60            )
 61        )
 62        self.feed_cube = self.sub.add_child(
 63            MeshInstance3D(
 64                mesh=Mesh.cube(1.8),
 65                material=Material(colour=(0.95, 0.55, 0.15, 1.0), roughness=0.4),
 66            )
 67        )
 68
 69        # ── The in-world monitor: a thin slab textured with the live feed ─
 70        # albedo_tex_index is set each frame from the SubViewport's texture
 71        # slot. The slot is stable across resizes, so this binding holds.
 72        self.monitor_mat = Material(colour=(1, 1, 1, 1), roughness=1.0)
 73        self.monitor = self.add_child(
 74            MeshInstance3D(
 75                mesh=Mesh.cube(1.0),
 76                material=self.monitor_mat,
 77                position=(0, 0, 0),
 78                scale=(3.2, 1.8, 0.08),
 79            )
 80        )
 81
 82        # ── Reference props so the main scene clearly differs from the feed ─
 83        self.add_child(
 84            MeshInstance3D(
 85                mesh=Mesh.cube(0.6),
 86                material=Material(colour=(0.3, 0.8, 0.4, 1.0)),
 87                position=(-2.6, -0.6, 0.5),
 88            )
 89        )
 90        self.add_child(
 91            MeshInstance3D(
 92                mesh=Mesh.sphere(0.5),
 93                material=Material(colour=(0.3, 0.5, 0.95, 1.0)),
 94                position=(2.6, -0.6, 0.5),
 95            )
 96        )
 97
 98        # Picture-in-picture: the SAME live feed drawn directly as a 2D sprite in
 99        # the corner. Pass the SubViewport node itself as the Sprite2D ``texture``
100        # (the canonical SubViewport-as-texture API, design §5.4): the sprite
101        # resolves the live bindless slot each draw and is treated as a dynamic
102        # drawable, so the retained 2D item cache re-emits it every frame instead
103        # of frame-skipping a feed whose slot it cannot see change.
104        self.pip = self.add_child(Sprite2D(texture=self.sub, position=(180, 600), width=260, height=260))
105
106        self.add_child(Text2D(text="SubViewport → live monitor feed", position=(20, 20), font_scale=1.8))
107
108    def on_update(self, dt):
109        if Input.is_action_pressed("quit"):
110            self.app.quit()
111            return
112        # Spin the feed cube so the monitor visibly updates each frame.
113        self.feed_cube.rotate_y(1.4 * dt)
114        self.feed_cube.rotate_x(0.7 * dt)
115        # Slowly orbit the monitor for depth.
116        self.monitor.rotate_y(0.25 * dt)
117        # Bind the SubViewport's live texture to the monitor material. The
118        # SubViewportManager publishes the bindless slot into sub.texture; we
119        # forward it to the material's direct GPU texture index.
120        if self.sub.texture >= 0:
121            self.monitor_mat.albedo_tex_index = self.sub.texture
122
123
124if __name__ == "__main__":
125    app = App(title="SubViewport", width=WIDTH, height=HEIGHT)
126    app.run(SubViewportScene())