Split Screen¶

two players in one 3D world, each with their own RenderView.

â–¶ Run in browser

Tags: 3d renderview render-to-texture split-screen camera multiplayer

Two players drive around a single shared world. Each has a ChaseCamera trailing it and a RenderView capturing the main scene through that camera, and each RenderView is shown by a half-screen Sprite2D, with a thin divider between them. Neither half is a viewport of the window: both are full offscreen renders of the one world, composited by two sprites. Drive either player past the other and it appears in the other’s half, because there is only one world.

This is render-to-texture split screen. The cost is one complete scene render per view (plus the main pass), which is what buys the freedom: the halves can be any size or shape, they can overlap, and a third player is a third RenderView rather than a change to the renderer.

A RenderView captures the 3D world only. Its capture re-submits the main tree with the 2D overlays off, since those already drew for the main pass and are screen-space rather than part of the world a second camera would see. That is why the labels and the divider sit on top of both halves rather than inside them, and it is also why a sprite showing a view can never feed back into it.

RenderView capture is deferred entirely under the pipelined render mode (App(render_thread=True)), exactly like reflection-probe capture: the texture slots stay valid but stop updating, so both halves freeze. Run this on the default synchronous path.

What it demonstrates¶

  • Sprite2D(texture=render_view): an offscreen 3D capture drawn as a 2D quad, the screen-space counterpart of Material(albedo_map=render_view).

  • One RenderView per player, each pointed at its own ChaseCamera by node path.

  • Compositing several views into one frame: two half-screen panels and a divider.

  • Two players sharing one scene tree, each visible in the other’s half.

Controls: W / S - Player 1 forward / back A / D - Player 1 turn left / right Up / Down - Player 2 forward / back Left/ Right - Player 2 turn left / right ESC - Quit

Run: uv run python examples/features/3d/split_screen.py Headless self-check: uv run python examples/features/3d/split_screen.py –test

Source¶

  1"""Split Screen: two players in one 3D world, each with their own RenderView.
  2
  3Two players drive around a single shared world. Each has a ChaseCamera trailing
  4it and a RenderView capturing the main scene through that camera, and each
  5RenderView is shown by a half-screen Sprite2D, with a thin divider between them.
  6Neither half is a viewport of the window: both are full offscreen renders of the
  7one world, composited by two sprites. Drive either player past the other and it
  8appears in the other's half, because there is only one world.
  9
 10This is render-to-texture split screen. The cost is one complete scene render per
 11view (plus the main pass), which is what buys the freedom: the halves can be any
 12size or shape, they can overlap, and a third player is a third RenderView rather
 13than a change to the renderer.
 14
 15A RenderView captures the 3D world only. Its capture re-submits the main tree
 16with the 2D overlays off, since those already drew for the main pass and are
 17screen-space rather than part of the world a second camera would see. That is
 18why the labels and the divider sit on top of both halves rather than inside
 19them, and it is also why a sprite showing a view can never feed back into it.
 20
 21RenderView capture is deferred entirely under the pipelined render mode
 22(``App(render_thread=True)``), exactly like reflection-probe capture: the texture
 23slots stay valid but stop updating, so both halves freeze. Run this on the
 24default synchronous path.
 25
 26# /// simvx
 27# tags = ["3d", "renderview", "render-to-texture", "split-screen", "camera", "multiplayer"]
 28# screenshot_frame = 40
 29# ///
 30
 31## What it demonstrates
 32- `Sprite2D(texture=render_view)`: an offscreen 3D capture drawn as a 2D quad,
 33  the screen-space counterpart of `Material(albedo_map=render_view)`.
 34- One RenderView per player, each pointed at its own ChaseCamera by node path.
 35- Compositing several views into one frame: two half-screen panels and a divider.
 36- Two players sharing one scene tree, each visible in the other's half.
 37
 38Controls:
 39  W / S       - Player 1 forward / back
 40  A / D       - Player 1 turn left / right
 41  Up / Down   - Player 2 forward / back
 42  Left/ Right - Player 2 turn left / right
 43  ESC         - Quit
 44
 45Run: uv run python examples/features/3d/split_screen.py
 46Headless self-check: uv run python examples/features/3d/split_screen.py --test
 47"""
 48
 49import math
 50
 51from simvx.core import (
 52    ChaseCamera,
 53    DirectionalLight3D,
 54    Input,
 55    InputMap,
 56    Key,
 57    Material,
 58    Mesh,
 59    MeshInstance3D,
 60    Node,
 61    Polygon2D,
 62    RenderView,
 63    Sprite2D,
 64    Text2D,
 65    Vec3,
 66    WorldEnvironment,
 67)
 68from simvx.graphics import App
 69
 70WIDTH, HEIGHT = 1280, 720
 71DIVIDER = 8
 72VIEW_W = (WIDTH - DIVIDER) // 2  # 636: each half is its own offscreen render
 73PANEL_X = (VIEW_W // 2, VIEW_W + DIVIDER + VIEW_W // 2)
 74
 75PLAYER_SPEED = 7.0
 76TURN_RATE = math.radians(130.0)
 77ARENA = 22.0  # half-extent the players are kept inside
 78
 79P1_COLOUR = (0.95, 0.45, 0.15, 1.0)
 80P2_COLOUR = (0.25, 0.6, 0.95, 1.0)
 81
 82
 83class SplitScreenScene(Node):
 84    """One world, two chase cameras, two RenderViews, two sprites."""
 85
 86    def on_ready(self):
 87        InputMap.add_action("quit", [Key.ESCAPE])
 88        InputMap.add_action("p1_forward", [Key.W])
 89        InputMap.add_action("p1_back", [Key.S])
 90        InputMap.add_action("p1_left", [Key.A])
 91        InputMap.add_action("p1_right", [Key.D])
 92        InputMap.add_action("p2_forward", [Key.UP])
 93        InputMap.add_action("p2_back", [Key.DOWN])
 94        InputMap.add_action("p2_left", [Key.LEFT])
 95        InputMap.add_action("p2_right", [Key.RIGHT])
 96
 97        env = self.add_child(WorldEnvironment())
 98        env.ambient_light_energy = 0.8  # the pillars are seen from all sides at once
 99        sun = self.add_child(DirectionalLight3D(intensity=2.6))
100        sun.direction = (-0.4, -1.0, -0.5)
101
102        self._build_arena()
103
104        # ── The two players, and a view apiece ────────────────────────────
105        # Each ChaseCamera is visible=False so neither can be picked as the main
106        # scene's active camera: they exist to drive a RenderView, and the window
107        # itself shows nothing but the two panels. Both players start facing the
108        # obelisk, so each half opens on the same landmark with the other player
109        # beyond it, and they start off the same line through the centre, or each
110        # would be hidden behind the obelisk in the other's half.
111        self.p1, self.view1 = self._player("One", P1_COLOUR, Vec3(-8, 0, 6))
112        self.p2, self.view2 = self._player("Two", P2_COLOUR, Vec3(7, 0, -8))
113
114        # ── The composite: two panels, a divider, and labels on top ───────
115        # Each sprite draws its view at the panel's exact size; the RenderView is
116        # created at that size too, so the capture is never scaled.
117        self.panel1 = self.add_child(Sprite2D(name="PanelOne", texture=self.view1))
118        self.panel2 = self.add_child(Sprite2D(name="PanelTwo", texture=self.view2))
119        self.divider = self.add_child(Polygon2D(colour=(0.05, 0.06, 0.08, 1.0)))
120        self.label1 = self.add_child(
121            Text2D(text="Player One  WASD", position=(16, 14), font_scale=1.4, colour=P1_COLOUR)
122        )
123        self.label2 = self.add_child(Text2D(text="Player Two  Arrows", font_scale=1.4, colour=P2_COLOUR))
124        self.hud = self.add_child(Text2D(text="", font_scale=1.1, colour=(0.8, 0.85, 0.9, 1.0)))
125        self._size = (0, 0)
126        self._layout(WIDTH, HEIGHT)
127
128    def _layout(self, w: int, h: int) -> None:
129        """Fit both panels, the divider and the labels to the window.
130
131        Called at ready and again whenever the window is resized: each panel is
132        half the width, and each RenderView is re-sized to its panel so the
133        capture is never scaled (the offscreen resize is debounced a few frames
134        by the render-view manager, which is invisible at interactive rates).
135        """
136        self._size = (w, h)
137        view_w = max(2, (w - DIVIDER) // 2)
138        self.view1.size = (view_w, h)
139        self.view2.size = (view_w, h)
140        for panel, x in ((self.panel1, view_w // 2), (self.panel2, view_w + DIVIDER + view_w // 2)):
141            panel.position = (x, h // 2)
142            panel.width = view_w
143            panel.height = h
144        self.divider.polygon = ((view_w, 0), (view_w + DIVIDER, 0), (view_w + DIVIDER, h), (view_w, h))
145        self.label2.position = (view_w + DIVIDER + 16, 14)
146        self.hud.position = (16, h - 34)
147
148    # -- Construction ------------------------------------------------------
149
150    def _player(self, label, colour, position):
151        """A body, a ChaseCamera trailing it, and the RenderView that captures it."""
152        body = self.add_child(
153            MeshInstance3D(
154                name=f"Player{label}",
155                mesh=Mesh.cube(size=1.2),
156                material=Material(colour=colour, roughness=0.45),
157                pivot="bottom",
158                position=position,
159            )
160        )
161        # A node's forward is -Z in its own frame, so this is the yaw whose
162        # forward points from the start position back at the centre.
163        body.rotate_y(math.atan2(position.x, position.z))
164        # A roof marker in a contrasting colour, so each player is recognisable
165        # in the OTHER player's half from any angle.
166        body.add_child(
167            MeshInstance3D(
168                mesh=Mesh.sphere(0.34),
169                material=Material(colour=(1.0, 1.0, 1.0, 1.0), unlit=True),
170                position=(0, 1.5, 0),
171            )
172        )
173        camera = self.add_child(
174            ChaseCamera(
175                name=f"Cam{label}",
176                target=body,
177                offset=Vec3(0, 3.2, 7.5),
178                look_offset=Vec3(0, 1.0, 0),
179                half_life=0.16,
180                fov=62.0,
181            )
182        )
183        camera.visible = False
184        camera.snap()
185        view = self.add_child(RenderView(name=f"View{label}", camera=f"../Cam{label}", size=(VIEW_W, HEIGHT)))
186        return body, view
187
188    def _build_arena(self):
189        self.add_child(
190            MeshInstance3D(
191                name="Ground",
192                mesh=Mesh.cube(size=1.0),
193                material=Material(colour=(0.2, 0.22, 0.26, 1.0), roughness=0.95),
194                position=(0, -0.1, 0),
195                scale=Vec3(ARENA * 2 + 6, 0.2, ARENA * 2 + 6),
196            )
197        )
198        # Every other floor tile, laid over the ground slab. Motion in a chase
199        # view reads off the ground more than off anything else, and a flat
200        # colour gives it nothing to read.
201        tile = Material(colour=(0.26, 0.29, 0.34, 1.0), roughness=0.9)
202        span = 7
203        for i in range(-span, span + 1):
204            for j in range(-span, span + 1):
205                if (i + j) % 2:
206                    continue
207                self.add_child(
208                    MeshInstance3D(
209                        mesh=Mesh.cube(size=1.0),
210                        material=tile,
211                        position=(i * 4.0, 0.01, j * 4.0),
212                        scale=Vec3(4.0, 0.02, 4.0),
213                    )
214                )
215
216        # A central obelisk and a ring of pillars: landmarks that tell the two
217        # halves apart at a glance, and something to drive around. The ring sits
218        # outside where the chase cameras rest, or a pillar would stand between a
219        # camera and its own player.
220        self.add_child(
221            MeshInstance3D(
222                mesh=Mesh.cube(size=1.0),
223                material=Material(colour=(0.85, 0.8, 0.55, 1.0), roughness=0.5),
224                pivot="bottom",
225                position=(0, 0, 0),
226                scale=Vec3(1.6, 7.0, 1.6),
227            )
228        )
229        for i in range(8):
230            angle = i * math.tau / 8
231            self.add_child(
232                MeshInstance3D(
233                    mesh=Mesh.cube(size=1.0),
234                    material=Material(colour=(0.45, 0.5, 0.6, 1.0), roughness=0.7),
235                    pivot="bottom",
236                    position=(math.cos(angle) * 19.0, 0, math.sin(angle) * 19.0),
237                    scale=Vec3(1.4, 2.6 + (i % 3), 1.4),
238                )
239            )
240
241    # -- Frame -------------------------------------------------------------
242
243    def on_update(self, dt: float):
244        if Input.is_action_just_pressed("quit"):
245            self.app.quit()
246            return
247        if (self.app.width, self.app.height) != self._size:
248            self._layout(self.app.width, self.app.height)
249        self._drive(self.p1, "p1", dt)
250        self._drive(self.p2, "p2", dt)
251        gap = (self.p1.position - self.p2.position).length()
252        self.hud.text = f"One world, two views: the players are {gap:.1f} units apart.  ESC quits."
253
254    def _drive(self, body, prefix, dt):
255        """Tank controls: turn in place, walk along the current heading."""
256        walk = Input.is_action_pressed(f"{prefix}_forward") - Input.is_action_pressed(f"{prefix}_back")
257        turn = Input.is_action_pressed(f"{prefix}_right") - Input.is_action_pressed(f"{prefix}_left")
258        if turn:
259            body.rotate_y(-turn * TURN_RATE * dt)
260        if walk:
261            fwd = body.forward
262            mag = math.hypot(fwd.x, fwd.z) or 1.0
263            step = Vec3(fwd.x / mag, 0.0, fwd.z / mag) * (walk * PLAYER_SPEED * dt)
264            moved = body.position + step
265            body.position = Vec3(max(-ARENA, min(ARENA, moved.x)), moved.y, max(-ARENA, min(ARENA, moved.z)))
266
267
268def _selftest() -> bool:
269    """Headless: drive both players and check the two halves are live and distinct."""
270    import numpy as np
271
272    from simvx.core.testing import InputSimulator
273    from simvx.graphics.testing import assert_not_blank, save_png
274
275    FRAMES = 60
276    ok = True
277
278    def check(label: str, passed: bool, detail: str) -> None:
279        nonlocal ok
280        ok = ok and passed
281        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
282
283    def half(frame, index):
284        x0 = 0 if index == 0 else VIEW_W + DIVIDER
285        return frame[:, x0 : x0 + VIEW_W]
286
287    scene = SplitScreenScene(name="SplitScreenScene")
288    sim = InputSimulator()
289    start = {}
290
291    def on_frame(idx: int, _t: float) -> bool:
292        if idx == 2:
293            start["p1"] = scene.p1.position
294            start["p2"] = scene.p2.position
295            # Player one drives forward, player two turns: the halves must then
296            # diverge in different ways, not move as one image.
297            sim.press_key(Key.W)
298            sim.press_key(Key.LEFT)
299        return True
300
301    app = App(title="Split Screen", width=WIDTH, height=HEIGHT, visible=False)
302    frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[6, FRAMES - 1])
303    early, late = frames[0], frames[1]
304    assert_not_blank(late)
305    save_png(late, "/tmp/split_screen_test.png")
306
307    check(
308        "both RenderViews published a live texture slot",
309        scene.view1.texture >= 0 and scene.view2.texture >= 0,
310        f"slots {scene.view1.texture} and {scene.view2.texture}",
311    )
312    check(
313        "the sprites read the slot live rather than being poked",
314        scene.panel1._texture_id == -1 and scene.panel2._texture_id == -1,
315        f"panel slots {scene.panel1._texture_id} and {scene.panel2._texture_id}",
316    )
317
318    left_late, right_late = half(late, 0), half(late, 1)
319    check(
320        "each half has content",
321        left_late.std() > 5.0 and right_late.std() > 5.0,
322        f"pixel spread {left_late.std():.1f} and {right_late.std():.1f}",
323    )
324    check(
325        "the two halves show different viewpoints",
326        not np.array_equal(left_late, right_late),
327        f"mean absolute difference {np.abs(left_late.astype(int) - right_late.astype(int)).mean():.1f}",
328    )
329    check(
330        "both halves update as the world moves",
331        not np.array_equal(half(early, 0), left_late) and not np.array_equal(half(early, 1), right_late),
332        "each half differs between frame 6 and the last frame",
333    )
334
335    p1_moved = (scene.p1.position - start["p1"]).length()
336    p2_moved = (scene.p2.position - start["p2"]).length()
337    check("input reached player one", p1_moved > 1.0, f"moved {p1_moved:.2f} units")
338    check(
339        "player two turned in place rather than walking",
340        p2_moved < 0.01,
341        f"moved {p2_moved:.4f} units",
342    )
343    sim.release_key(Key.W)
344    sim.release_key(Key.LEFT)
345
346    # And the advertised quit key really ends the loop, through the action map.
347    quit_sim = InputSimulator()
348    last = [-1]
349
350    def quit_frame(idx: int, _t: float) -> bool:
351        last[0] = idx
352        if idx == 10:
353            quit_sim.press_key(Key.ESCAPE)
354        elif idx == 11:
355            quit_sim.release_key(Key.ESCAPE)
356        return True
357
358    App(title="Split Screen quit", width=WIDTH, height=HEIGHT, visible=False).run_headless(
359        SplitScreenScene(name="SplitScreenScene"), frames=120, on_frame=quit_frame
360    )
361    check("ESC ends the run", last[0] < 40, f"the loop stopped at frame {last[0]} of 120")
362
363    print("screenshot: /tmp/split_screen_test.png")
364    print("SELFTEST:", "PASS" if ok else "FAIL")
365    return ok
366
367
368if __name__ == "__main__":
369    import sys
370
371    if "--test" in sys.argv:
372        sys.exit(0 if _selftest() else 1)
373    App(title="SimVX - Split Screen", width=WIDTH, height=HEIGHT).run(SplitScreenScene())