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. Hand the node to a material as Material(albedo_map=sub_viewport) and a flat “monitor” slab in the main scene becomes a live screen in the world: the binding is assign-once, and the renderer re-resolves the feed’s texture slot each frame. The same node works as a Sprite2D texture for a picture-in-picture overlay.

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. Hand the
  5node to a material as `Material(albedo_map=sub_viewport)` and a flat "monitor"
  6slab in the main scene becomes a live screen in the world: the binding is
  7assign-once, and the renderer re-resolves the feed's texture slot each frame.
  8The same node works as a `Sprite2D` texture for a picture-in-picture overlay.
  9
 10# /// simvx
 11# tags = ["3d", "subviewport", "render-to-texture"]
 12# screenshot_frame = 60
 13# ///
 14
 15Usage:
 16    uv run python examples/features/3d/sub_viewport.py
 17"""
 18
 19from simvx.core import (
 20    Camera3D,
 21    DirectionalLight3D,
 22    Input,
 23    InputMap,
 24    Key,
 25    Material,
 26    Mesh,
 27    MeshInstance3D,
 28    Node,
 29    Sprite2D,
 30    SubViewport,
 31    Text2D,
 32)
 33from simvx.graphics import App
 34
 35WIDTH, HEIGHT = 1280, 720
 36
 37PIP_SIZE = 260  # picture-in-picture edge length in pixels
 38PIP_MARGIN = 20  # gap from the window's left and bottom edges
 39
 40
 41class SubViewportScene(Node):
 42    def on_ready(self):
 43        InputMap.add_action("quit", [Key.ESCAPE])
 44
 45        # ── Main-scene camera (looks at the monitor) ──────────────────────
 46        self.add_child(Camera3D(position=(0, 2, 6), look_at=(0, 0, 0), up=(0, 1, 0)))
 47        sun = self.add_child(DirectionalLight3D(intensity=2.0))
 48        sun.direction = (-0.4, -1.0, -0.6)
 49
 50        # ── SubViewport: a self-contained 3D scene with its OWN camera ────
 51        # Its subtree never appears in the main pass; it renders offscreen.
 52        self.sub = self.add_child(SubViewport(size=(512, 512)))
 53        self.sub.add_child(Camera3D(position=(0, 0, 3.0), look_at=(0, 0, 0), up=(0, 1, 0)))
 54        sub_sun = self.sub.add_child(DirectionalLight3D(intensity=3.0))
 55        sub_sun.direction = (-0.5, -0.7, -0.5)
 56        # A large emissive backdrop fills the feed so the monitor reads as a
 57        # bright screen rather than a dark panel, and makes the live update
 58        # obvious as the foreground cube spins across it.
 59        self.sub.add_child(
 60            MeshInstance3D(
 61                mesh=Mesh.cube(6.0),
 62                material=Material(colour=(0.15, 0.35, 0.85, 1.0), roughness=1.0),
 63                position=(0, 0, -3.5),
 64            )
 65        )
 66        self.feed_cube = self.sub.add_child(
 67            MeshInstance3D(
 68                mesh=Mesh.cube(1.8),
 69                material=Material(colour=(0.95, 0.55, 0.15, 1.0), roughness=0.4),
 70            )
 71        )
 72
 73        # ── The in-world monitor: a thin slab textured with the live feed ─
 74        # Material(albedo_map=sub_viewport) is the first-class live-feed
 75        # binding: assign the node once and the backend re-resolves its
 76        # bindless slot every frame. The slab is shaded like any other
 77        # surface, so the main scene's sun rakes across the screen as the
 78        # monitor orbits.
 79        self.monitor = self.add_child(
 80            MeshInstance3D(
 81                mesh=Mesh.cube(1.0),
 82                material=Material(colour=(1, 1, 1, 1), albedo_map=self.sub),
 83                position=(0, 0, 0),
 84                scale=(3.2, 1.8, 0.08),
 85            )
 86        )
 87
 88        # ── Reference props so the main scene clearly differs from the feed ─
 89        self.add_child(
 90            MeshInstance3D(
 91                mesh=Mesh.cube(0.6),
 92                material=Material(colour=(0.3, 0.8, 0.4, 1.0)),
 93                position=(-2.6, -0.6, 0.5),
 94            )
 95        )
 96        self.add_child(
 97            MeshInstance3D(
 98                mesh=Mesh.sphere(0.5),
 99                material=Material(colour=(0.3, 0.5, 0.95, 1.0)),
100                position=(2.6, -0.6, 0.5),
101            )
102        )
103
104        # Picture-in-picture: the SAME live feed drawn directly as a 2D sprite in
105        # the corner. Pass the SubViewport node itself as the Sprite2D ``texture``:
106        # the sprite resolves the live bindless slot each draw and is treated as a
107        # dynamic drawable, so the retained 2D item cache re-emits it every frame
108        # instead of frame-skipping a feed whose slot it cannot see change.
109        self.pip = self.add_child(
110            Sprite2D(
111                texture=self.sub,
112                position=(PIP_MARGIN + PIP_SIZE * 0.5, HEIGHT - PIP_MARGIN - PIP_SIZE * 0.5),
113                width=PIP_SIZE,
114                height=PIP_SIZE,
115            )
116        )
117
118        self.add_child(Text2D(text="SubViewport → live monitor feed", position=(20, 20), font_scale=1.8))
119
120    def on_update(self, dt):
121        if Input.is_action_pressed("quit"):
122            self.app.quit()
123            return
124        # Spin the feed cube so the monitor visibly updates each frame.
125        self.feed_cube.rotate_y(1.4 * dt)
126        self.feed_cube.rotate_x(0.7 * dt)
127        # Slowly orbit the monitor for depth.
128        self.monitor.rotate_y(0.25 * dt)
129        # Keep the corner feed on the window's bottom-left (Sprite2D positions
130        # from its centre), so it survives a resize.
131        self.pip.position = (PIP_MARGIN + PIP_SIZE * 0.5, self.app.height - PIP_MARGIN - PIP_SIZE * 0.5)
132
133
134if __name__ == "__main__":
135    app = App(title="SubViewport", width=WIDTH, height=HEIGHT)
136    app.run(SubViewportScene())