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 uv run python examples/features/3d/sub_viewport.py –test

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    uv run python examples/features/3d/sub_viewport.py --test
 17"""
 18
 19import sys
 20
 21from simvx.core import (
 22    Camera3D,
 23    DirectionalLight3D,
 24    Input,
 25    InputMap,
 26    Key,
 27    Material,
 28    Mesh,
 29    MeshInstance3D,
 30    Node,
 31    Sprite2D,
 32    SubViewport,
 33    Text2D,
 34)
 35from simvx.graphics import App
 36
 37WIDTH, HEIGHT = 1280, 720
 38
 39
 40class SubViewportScene(Node):
 41    def on_ready(self):
 42        InputMap.add_action("quit", [Key.ESCAPE])
 43
 44        # ── Main-scene camera (looks at the monitor) ──────────────────────
 45        self.add_child(Camera3D(position=(0, 2, 6), look_at=(0, 0, 0), up=(0, 1, 0)))
 46        sun = self.add_child(DirectionalLight3D(intensity=2.0))
 47        sun.direction = (-0.4, -1.0, -0.6)
 48
 49        # ── SubViewport: a self-contained 3D scene with its OWN camera ────
 50        # Its subtree never appears in the main pass; it renders offscreen.
 51        self.sub = self.add_child(SubViewport(size=(512, 512)))
 52        self.sub.add_child(Camera3D(position=(0, 0, 3.0), look_at=(0, 0, 0), up=(0, 1, 0)))
 53        sub_sun = self.sub.add_child(DirectionalLight3D(intensity=3.0))
 54        sub_sun.direction = (-0.5, -0.7, -0.5)
 55        # A large emissive backdrop fills the feed so the monitor reads as a
 56        # bright screen rather than a dark panel, and makes the live update
 57        # obvious as the foreground cube spins across it.
 58        self.sub.add_child(
 59            MeshInstance3D(
 60                mesh=Mesh.cube(6.0),
 61                material=Material(colour=(0.15, 0.35, 0.85, 1.0), roughness=1.0),
 62                position=(0, 0, -3.5),
 63            )
 64        )
 65        self.feed_cube = self.sub.add_child(
 66            MeshInstance3D(
 67                mesh=Mesh.cube(1.8),
 68                material=Material(colour=(0.95, 0.55, 0.15, 1.0), roughness=0.4),
 69            )
 70        )
 71
 72        # ── The in-world monitor: a thin slab textured with the live feed ─
 73        # albedo_tex_index is set each frame from the SubViewport's texture
 74        # slot. The slot is stable across resizes, so this binding holds.
 75        self.monitor_mat = Material(colour=(1, 1, 1, 1), roughness=1.0)
 76        self.monitor = self.add_child(
 77            MeshInstance3D(
 78                mesh=Mesh.cube(1.0),
 79                material=self.monitor_mat,
 80                position=(0, 0, 0),
 81                scale=(3.2, 1.8, 0.08),
 82            )
 83        )
 84
 85        # ── Reference props so the main scene clearly differs from the feed ─
 86        self.add_child(
 87            MeshInstance3D(
 88                mesh=Mesh.cube(0.6),
 89                material=Material(colour=(0.3, 0.8, 0.4, 1.0)),
 90                position=(-2.6, -0.6, 0.5),
 91            )
 92        )
 93        self.add_child(
 94            MeshInstance3D(
 95                mesh=Mesh.sphere(0.5),
 96                material=Material(colour=(0.3, 0.5, 0.95, 1.0)),
 97                position=(2.6, -0.6, 0.5),
 98            )
 99        )
100
101        # Picture-in-picture: the SAME live feed drawn directly as a 2D sprite
102        # in the corner (the Sprite2D._texture_id path), proving the texture is
103        # consumable both in 3D (monitor material) and 2D (HUD).
104        self.pip = self.add_child(Sprite2D(position=(180, 600), width=260, height=260))
105
106        self.add_child(Text2D(text="SubViewport → live monitor feed", x=20, y=20, font_scale=1.8))
107
108    def on_process(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            self.pip._texture_id = self.sub.texture
123
124
125def _run_test() -> int:
126    from simvx.graphics.testing import assert_not_blank, save_png
127
128    out_dir = "/tmp/verify/subviewport"
129    import os
130
131    os.makedirs(out_dir, exist_ok=True)
132    app = App(title="SubViewport", width=WIDTH, height=HEIGHT, visible=False)
133    frames = [20, 40, 60, 90]
134    captured = app.run_headless(SubViewportScene(), frames=120, capture_frames=frames)
135    ok = True
136    for i, frame in zip(frames, captured, strict=True):
137        path = f"{out_dir}/frame_{i:03d}.png"
138        save_png(path, frame)
139        try:
140            assert_not_blank(frame)
141            print(f"saved {path} (non-blank)")
142        except AssertionError:
143            ok = False
144            print(f"saved {path} (BLANK: FAIL)")
145    return 0 if ok else 1
146
147
148if __name__ == "__main__":
149    if "--test" in sys.argv:
150        sys.exit(_run_test())
151    app = App(title="SubViewport", width=WIDTH, height=HEIGHT)
152    app.run(SubViewportScene())