CollisionWorld¶
Interactive sandbox for CollisionWorld + raycasting.
▶ Run in browserTags: 3d
Spawn sphere and box bodies into a CollisionWorld, watch them bounce on
the ground with custom gravity, and fire camera-through-cursor rays that
intersect against every body in the world. Each hit flashes its body.
Controls: A / D - Orbit camera left / right W / S - Zoom in / out Q / E - Raise / lower camera 1 - Drop a sphere 2 - Drop a box LClick / 3 - Fire raycast toward mouse cursor R - Reset scene
Source¶
1"""
2CollisionWorld: Interactive sandbox for CollisionWorld + raycasting.
3
4Spawn sphere and box bodies into a ``CollisionWorld``, watch them bounce on
5the ground with custom gravity, and fire camera-through-cursor rays that
6intersect against every body in the world. Each hit flashes its body.
7
8Controls:
9 A / D - Orbit camera left / right
10 W / S - Zoom in / out
11 Q / E - Raise / lower camera
12 1 - Drop a sphere
13 2 - Drop a box
14 LClick / 3 - Fire raycast toward mouse cursor
15 R - Reset scene
16"""
17
18
19import math
20import random
21import time
22
23import numpy as np
24
25from simvx.core import (
26 BoxShape,
27 Camera3D,
28 CollisionWorld,
29 DirectionalLight3D,
30 Input,
31 InputMap,
32 Key,
33 Material,
34 Mesh,
35 MeshInstance3D,
36 MouseButton,
37 Node3D,
38 PointLight3D,
39 Quat,
40 SphereShape,
41 Text2D,
42 Vec3,
43)
44from simvx.graphics import App
45from simvx.graphics.debug_draw import DebugDraw
46
47# ============================================================================
48# Constants
49# ============================================================================
50
51GRAVITY = -18.0
52BOUNCE_DAMPING = 0.55
53GROUND_Y = 0.0
54SPAWN_HEIGHT = 10.0
55WIDTH, HEIGHT = 1024, 768
56MAX_FPS = 30
57FRAME_TIME = 1.0 / MAX_FPS
58
59PRESETS = [
60 {"colour": (0.95, 0.08, 0.08), "metallic": 0.0, "roughness": 0.7}, # Bold red
61 {"colour": (0.10, 0.40, 0.95), "metallic": 0.9, "roughness": 0.08}, # Chrome blue
62 {
63 "colour": (1.0, 0.75, 0.0),
64 "metallic": 1.0,
65 "roughness": 0.2, # Gold (emissive glow)
66 "emissive_colour": (1.0, 0.85, 0.2, 3.0),
67 },
68 {"colour": (0.05, 0.85, 0.25), "metallic": 0.0, "roughness": 0.8}, # Vivid green
69 {
70 "colour": (0.75, 0.10, 0.95),
71 "metallic": 0.5,
72 "roughness": 0.15, # Neon purple (emissive glow)
73 "emissive_colour": (0.8, 0.2, 1.0, 2.0),
74 },
75 {"colour": (0.95, 0.95, 1.0), "metallic": 1.0, "roughness": 0.02}, # Mirror
76 {"colour": (1.0, 0.45, 0.0), "metallic": 0.0, "roughness": 0.5}, # Bright orange
77 {
78 "colour": (0.12, 0.12, 0.14),
79 "metallic": 0.95,
80 "roughness": 0.05, # Dark chrome (blue emissive)
81 "emissive_colour": (0.2, 0.5, 1.0, 1.5),
82 },
83]
84
85
86# ============================================================================
87# Falling body: tracks physics state alongside its MeshInstance3D
88# ============================================================================
89
90
91class FallingBody:
92 """Lightweight physics state for a dropped object (not a Node)."""
93
94 __slots__ = (
95 "mesh_node",
96 "shape_type",
97 "radius",
98 "half_extents",
99 "vel_y",
100 "settled",
101 "hit_flash",
102 )
103
104 def __init__(self, mesh_node: MeshInstance3D, shape_type: str, radius: float, half_extents: tuple):
105 self.mesh_node = mesh_node
106 self.shape_type = shape_type
107 self.radius = radius
108 self.half_extents = half_extents
109 self.vel_y: float = 0.0
110 self.settled: bool = False
111 self.hit_flash: float = 0.0
112
113 @property
114 def pos(self) -> Vec3:
115 return self.mesh_node.position
116
117 @pos.setter
118 def pos(self, v: Vec3):
119 self.mesh_node.position = v
120
121 def ground_offset(self) -> float:
122 if self.shape_type == "sphere":
123 return self.radius
124 return self.half_extents[1]
125
126
127# ============================================================================
128# Main scene
129# ============================================================================
130
131
132class CollisionWorldDemo(Node3D):
133 def __init__(self, **kwargs):
134 super().__init__(name="CollisionWorld", **kwargs)
135
136 # ---- Camera ----
137 self._cam_angle: float = 35.0
138 self._cam_height: float = 14.0
139 self._cam_dist: float = 28.0
140 self.camera = self.add_child(
141 Camera3D(
142 name="Camera",
143 fov=50,
144 near=0.1,
145 far=200.0,
146 )
147 )
148 self._update_camera()
149
150 # ---- Lights ----
151 sun = self.add_child(DirectionalLight3D(name="Sun"))
152 sun.colour = (1.0, 0.97, 0.90)
153 sun.intensity = 1.5
154 sun.rotation = Quat.from_euler(math.radians(-55), math.radians(-40), 0)
155
156 fill = self.add_child(PointLight3D(name="Fill", position=Vec3(-10, 8, 10)))
157 fill.colour = (0.3, 0.4, 0.9)
158 fill.intensity = 0.8
159 fill.range = 35.0
160
161 rim = self.add_child(PointLight3D(name="Rim", position=Vec3(12, 5, -8)))
162 rim.colour = (1.0, 0.6, 0.2)
163 rim.intensity = 0.6
164 rim.range = 30.0
165
166 # ---- Ground (dark so grid + shadows are visible) ----
167 self.add_child(
168 MeshInstance3D(
169 name="Ground",
170 mesh=Mesh.cube(1.0),
171 material=Material(colour=(0.06, 0.06, 0.08), metallic=0.1, roughness=0.9),
172 position=Vec3(0, -0.05, 0),
173 scale=Vec3(30, 0.1, 30),
174 )
175 )
176
177 # ---- Collision world ----
178 self.cworld = CollisionWorld()
179 self._bodies: list[FallingBody] = []
180 self._spawn_count: int = 0
181
182 # ---- Raycast state (multiple rays persist with fade) ----
183 self._rays: list[dict] = [] # [{origin, dir, hits, timer}, ...]
184 self._max_rays = 10
185
186 # ---- FPS tracking ----
187 self._fps_samples: list[float] = []
188 self._fps_display: float = 0.0
189 self._fps_update_timer: float = 0.0
190
191 # ---- Status ----
192 self._last_action = "Ready"
193 self._frame_time_target = FRAME_TIME
194 self._last_frame_time = 0.0
195
196 # ---- Place pedestal ring ----
197 self._place_pedestals()
198
199 # ---- HUD ----
200 self._title = self.add_child(Text2D(text="COLLISION WORLD", font_scale=3.6))
201 self._info = self.add_child(Text2D(text="", font_scale=2.4))
202 self._fps_text = self.add_child(Text2D(text="FPS: --", font_scale=2.4))
203 self._controls = self.add_child(
204 Text2D(
205 text="1:SPHERE 2:BOX CLICK/3:RAY R:RESET WASD/QE:CAMERA",
206 font_scale=2.4,
207 )
208 )
209
210 def on_ready(self):
211 InputMap.add_action("cam_left", [Key.A, Key.LEFT])
212 InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
213 InputMap.add_action("cam_fwd", [Key.W, Key.UP])
214 InputMap.add_action("cam_back", [Key.S, Key.DOWN])
215 InputMap.add_action("cam_up", [Key.Q])
216 InputMap.add_action("cam_down", [Key.E])
217 InputMap.add_action("spawn_sphere", [Key.KEY_1])
218 InputMap.add_action("spawn_box", [Key.KEY_2])
219 InputMap.add_action("fire_ray", [Key.KEY_3])
220 InputMap.add_action("reset", [Key.R])
221
222 # ------------------------------------------------------------------
223 # Pedestal ring: 8 objects showcasing different meshes + materials
224 # ------------------------------------------------------------------
225
226 def _place_pedestals(self):
227 meshes_and_shapes = [
228 (Mesh.sphere(0.7, rings=16, segments=24), "sphere", 0.7, (0.7, 0.7, 0.7)),
229 (Mesh.cube(1.0), "box", 0.7, (0.5, 0.5, 0.5)),
230 (Mesh.cylinder(0.5, 1.2, segments=20), "sphere", 0.7, (0.5, 0.6, 0.5)),
231 (Mesh.cone(0.6, 1.3, segments=16), "sphere", 0.7, (0.6, 0.65, 0.6)),
232 (Mesh.sphere(0.65, rings=12, segments=16), "sphere", 0.65, (0.65, 0.65, 0.65)),
233 (Mesh.cube(0.9), "box", 0.65, (0.45, 0.45, 0.45)),
234 (Mesh.cylinder(0.45, 1.0, segments=12), "sphere", 0.6, (0.45, 0.5, 0.45)),
235 (Mesh.cone(0.55, 1.1, segments=12), "sphere", 0.6, (0.55, 0.55, 0.55)),
236 ]
237
238 for i, (mesh, stype, radius, hext) in enumerate(meshes_and_shapes):
239 angle = (i / len(meshes_and_shapes)) * math.tau
240 x = math.cos(angle) * 9.0
241 z = math.sin(angle) * 9.0
242 y = radius if stype == "sphere" else hext[1]
243
244 preset = PRESETS[i % len(PRESETS)]
245 mi = self.add_child(
246 MeshInstance3D(
247 name=f"Pedestal{i}",
248 mesh=mesh,
249 material=Material(**preset),
250 position=Vec3(x, y, z),
251 )
252 )
253
254 body = FallingBody(mi, stype, radius, hext)
255 body.settled = True
256 self._bodies.append(body)
257 self._spawn_count += 1
258
259 if stype == "sphere":
260 shape = SphereShape(radius=radius)
261 else:
262 shape = BoxShape(half_extents=hext)
263 self.cworld.add_body(
264 body,
265 shape,
266 position=np.array([x, y, z], dtype=np.float32),
267 )
268
269 # ------------------------------------------------------------------
270 # Camera
271 # ------------------------------------------------------------------
272
273 def _update_camera(self):
274 rad = math.radians(self._cam_angle)
275 x = math.cos(rad) * self._cam_dist
276 z = math.sin(rad) * self._cam_dist
277 self.camera.position = Vec3(x, self._cam_height, z)
278 self.camera.look_at(Vec3(0, 2.5, 0))
279
280 # ------------------------------------------------------------------
281 # Spawning
282 # ------------------------------------------------------------------
283
284 def _spawn(self, shape_type: str):
285 x = random.uniform(-5, 5)
286 z = random.uniform(-5, 5)
287 preset = random.choice(PRESETS)
288 self._spawn_count += 1
289
290 if shape_type == "sphere":
291 r = random.uniform(0.35, 0.8)
292 mi = self.add_child(
293 MeshInstance3D(
294 name=f"Sphere{self._spawn_count}",
295 mesh=Mesh.sphere(r, rings=12, segments=16),
296 material=Material(**preset),
297 position=Vec3(x, SPAWN_HEIGHT, z),
298 )
299 )
300 body = FallingBody(mi, "sphere", r, (r, r, r))
301 shape = SphereShape(radius=r)
302 else:
303 hx = random.uniform(0.25, 0.65)
304 hy = random.uniform(0.25, 0.65)
305 hz = random.uniform(0.25, 0.65)
306 mi = self.add_child(
307 MeshInstance3D(
308 name=f"Box{self._spawn_count}",
309 mesh=Mesh.cube(1.0),
310 material=Material(**preset),
311 position=Vec3(x, SPAWN_HEIGHT, z),
312 scale=Vec3(hx * 2, hy * 2, hz * 2),
313 )
314 )
315 body = FallingBody(mi, "box", max(hx, hy, hz), (hx, hy, hz))
316 shape = BoxShape(half_extents=(hx, hy, hz))
317
318 self._bodies.append(body)
319 self.cworld.add_body(
320 body,
321 shape,
322 position=np.array([x, SPAWN_HEIGHT, z], dtype=np.float32),
323 )
324 self._last_action = f"Dropped {shape_type}"
325
326 # ------------------------------------------------------------------
327 # Raycast: fires from camera through mouse cursor
328 # ------------------------------------------------------------------
329
330 def _fire_ray(self):
331 from simvx.core import screen_to_ray
332
333 mouse = Input.mouse_position
334 sw, sh = self.tree.screen_size
335 view = self.camera.view_matrix
336 proj = self.camera.projection_matrix(sw / sh if sh > 0 else 1.0)
337 origin, d = screen_to_ray(mouse, (sw, sh), view, proj)
338
339 # Collision ray uses true screen_to_ray result
340 ray_origin = np.array([origin.x, origin.y, origin.z], dtype=np.float32)
341 ray_dir = np.array([d.x, d.y, d.z], dtype=np.float32)
342 hits = self.cworld.raycast(ray_origin, ray_dir, max_dist=60.0)
343
344 # Visual origin offset slightly below camera so beam is visible.
345 # Aim toward the nearest hit point (or far along the true ray if no hits).
346 vis_origin = origin - self.camera.up * 1.5
347 vis_origin_np = np.array([vis_origin.x, vis_origin.y, vis_origin.z], dtype=np.float32)
348 if hits:
349 target_pt = hits[0].point
350 else:
351 target_pt = ray_origin + ray_dir * 60.0
352 vis_dir = target_pt - vis_origin_np
353 vis_dir = vis_dir / np.linalg.norm(vis_dir)
354
355 for hit in hits:
356 if isinstance(hit.body, FallingBody):
357 hit.body.hit_flash = 1.0
358
359 self._rays.append(
360 {
361 "origin": vis_origin_np,
362 "dir": vis_dir,
363 "hits": hits,
364 "timer": 5.0,
365 }
366 )
367 if len(self._rays) > self._max_rays:
368 self._rays.pop(0)
369
370 n = len(hits)
371 self._last_action = f"Ray: {n} hit{'s' if n != 1 else ''}"
372
373 # ------------------------------------------------------------------
374 # Reset
375 # ------------------------------------------------------------------
376
377 def _reset(self):
378 for b in self._bodies:
379 self.cworld.remove_body(b)
380 b.mesh_node.destroy()
381 self._bodies.clear()
382 self._spawn_count = 0
383 self._rays.clear()
384 self._place_pedestals()
385 self._last_action = "Reset"
386
387 # ------------------------------------------------------------------
388 # Physics (fixed timestep)
389 # ------------------------------------------------------------------
390
391 def on_physics_process(self, dt: float):
392 # Camera (continuous input)
393 speed = 45.0
394 if Input.is_action_pressed("cam_left"):
395 self._cam_angle += speed * dt
396 if Input.is_action_pressed("cam_right"):
397 self._cam_angle -= speed * dt
398 if Input.is_action_pressed("cam_fwd"):
399 self._cam_dist = max(10, self._cam_dist - 12 * dt)
400 if Input.is_action_pressed("cam_back"):
401 self._cam_dist = min(45, self._cam_dist + 12 * dt)
402 if Input.is_action_pressed("cam_up"):
403 self._cam_height = min(30, self._cam_height + 8 * dt)
404 if Input.is_action_pressed("cam_down"):
405 self._cam_height = max(3, self._cam_height - 8 * dt)
406 self._update_camera()
407
408 for b in self._bodies:
409 if not b.settled:
410 b.vel_y += GRAVITY * dt
411 new_y = b.pos.y + b.vel_y * dt
412 gnd = GROUND_Y + b.ground_offset()
413 if new_y <= gnd:
414 new_y = gnd
415 if abs(b.vel_y) < 1.5:
416 b.vel_y = 0
417 b.settled = True
418 else:
419 b.vel_y = -b.vel_y * BOUNCE_DAMPING
420 b.pos = Vec3(b.pos.x, new_y, b.pos.z)
421
422 p = b.pos
423 self.cworld.update_position(b, np.array([p.x, p.y, p.z], dtype=np.float32))
424
425 if b.hit_flash > 0:
426 b.hit_flash = max(0, b.hit_flash - dt * 2.5)
427
428 for ray in self._rays:
429 ray["timer"] -= dt
430 self._rays = [r for r in self._rays if r["timer"] > 0]
431
432 # ------------------------------------------------------------------
433 # Visual (process runs every frame)
434 # ------------------------------------------------------------------
435
436 def on_process(self, dt: float):
437 # ---- FPS limiter ----
438 now = time.perf_counter()
439 elapsed = now - self._last_frame_time
440 if elapsed < self._frame_time_target:
441 time.sleep(self._frame_time_target - elapsed)
442 self._last_frame_time = time.perf_counter()
443
444 # ---- Discrete input ----
445 if Input.is_action_just_pressed("spawn_sphere"):
446 self._spawn("sphere")
447 if Input.is_action_just_pressed("spawn_box"):
448 self._spawn("box")
449 if Input.is_action_just_pressed("fire_ray") or Input.is_mouse_button_just_pressed(MouseButton.LEFT):
450 self._fire_ray()
451 if Input.is_action_just_pressed("reset"):
452 self._reset()
453
454 # ---- FPS tracking ----
455 if dt > 0:
456 self._fps_samples.append(1.0 / dt)
457 if len(self._fps_samples) > 30:
458 self._fps_samples.pop(0)
459 self._fps_update_timer += dt
460 if self._fps_update_timer >= 0.5:
461 self._fps_update_timer = 0
462 if self._fps_samples:
463 self._fps_display = sum(self._fps_samples) / len(self._fps_samples)
464
465 # ---- DebugDraw: ground grid ----
466 half = 15
467 gc = (0.15, 0.16, 0.22, 0.35)
468 for i in range(-half, half + 1, 3):
469 fi = float(i)
470 DebugDraw.line((-half, 0.01, fi), (half, 0.01, fi), gc)
471 DebugDraw.line((fi, 0.01, -half), (fi, 0.01, half), gc)
472
473 # Origin axes
474 DebugDraw.axes((0, 0.02, 0), size=1.5)
475
476 # ---- DebugDraw: collision wireframes ----
477 for b in self._bodies:
478 p = b.pos
479 c = (p.x, p.y, p.z)
480
481 if b.hit_flash > 0:
482 t = b.hit_flash
483 col = (1.0, 0.1 + 0.4 * t, 0.05, 0.95)
484 elif b.settled:
485 col = (0.1, 0.9, 0.3, 0.5)
486 else:
487 col = (0.2, 0.6, 1.0, 0.7)
488
489 if b.shape_type == "sphere":
490 DebugDraw.sphere(c, b.radius, col, segments=10)
491 else:
492 DebugDraw.box(c, b.half_extents, col)
493
494 # ---- DebugDraw: active rays (all persist with fade) ----
495 # timer: 5→3 full brightness, 3→0 fade out
496 for ray in self._rays:
497 a = min(1.0, ray["timer"] / 3.0)
498 o = tuple(ray["origin"])
499 d = tuple(ray["dir"])
500 DebugDraw.ray(o, d, length=40.0, colour=(1.0, 1.0, 0.0, a))
501 for hit in ray["hits"]:
502 pt = hit.point
503 DebugDraw.sphere(
504 (float(pt[0]), float(pt[1]), float(pt[2])),
505 0.5,
506 (1.0, 0.0, 0.0, a),
507 segments=10,
508 )
509
510 # ---- HUD update ----
511 active = sum(1 for b in self._bodies if not b.settled)
512 total = len(self._bodies)
513 total_hits = sum(len(r["hits"]) for r in self._rays)
514 self._info.text = (
515 f"Bodies:{total} Falling:{active} "
516 f"Rays:{len(self._rays)} Hits:{total_hits} "
517 f"[{self._last_action}]"
518 )
519 self._fps_text.text = f"FPS: {self._fps_display:.0f}"
520
521 # ---- HUD layout: Text2D positions are in framebuffer pixels, so use
522 # engine.extent (not tree.screen_size, which is window-logical pixels). ----
523 extent = self.app.engine.extent if self.app and self.app.engine else None
524 if extent is not None:
525 fw, fh = extent
526 margin = 16
527 # MSDF glyph advance ~10 px per char at font_scale=1 (size=16 px).
528 char_w_at_1 = 10.0
529 line_h = lambda fs: int(fs * 22)
530 self._title.x = margin
531 self._title.y = margin
532 self._info.x = margin
533 self._info.y = margin + line_h(self._title.font_scale) + 8
534 fps_w = len(self._fps_text.text) * char_w_at_1 * self._fps_text.font_scale
535 self._fps_text.x = fw - margin - fps_w
536 self._fps_text.y = margin
537 self._controls.x = margin
538 self._controls.y = fh - margin - line_h(self._controls.font_scale)
539
540
541# ============================================================================
542# Main
543# ============================================================================
544
545
546def main():
547 app = App(title="SimVX CollisionWorld", width=WIDTH, height=HEIGHT, physics_fps=60)
548 app.run(CollisionWorldDemo())
549
550
551if __name__ == "__main__":
552 main()