Split Screen¶
two players in one 3D world, each with their own RenderView.
â–¶ Run in browserTags: 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 ofMaterial(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())