Physics Raycast Sandbox¶
drop bodies into a PhysicsRoot and raycast against them.
▶ Run in browserTags: 3d
Spawn sphere and box bodies into the scene’s physics world (a PhysicsRoot
with custom gravity), watch the engine simulate and bounce them on a static
ground body, and fire camera-through-cursor rays that query the world via
self.world.physics.raycast_all. 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
Mouse / touch: tap the [SPHERE] [BOX] [RESET] buttons in the bottom-left corner to spawn and reset; click or tap anywhere else to fire a ray.
Source¶
1"""
2Physics Raycast Sandbox: drop bodies into a PhysicsRoot and raycast against them.
3
4Spawn sphere and box bodies into the scene's physics world (a ``PhysicsRoot``
5with custom gravity), watch the engine simulate and bounce them on a static
6ground body, and fire camera-through-cursor rays that query the world via
7``self.world.physics.raycast_all``. Each hit flashes its body.
8
9Controls:
10 A / D - Orbit camera left / right
11 W / S - Zoom in / out
12 Q / E - Raise / lower camera
13 1 - Drop a sphere
14 2 - Drop a box
15 LClick / 3 - Fire raycast toward mouse cursor
16 R - Reset scene
17
18Mouse / touch: tap the [SPHERE] [BOX] [RESET] buttons in the bottom-left
19corner to spawn and reset; click or tap anywhere else to fire a ray.
20"""
21
22
23import math
24import random
25import time
26
27import numpy as np
28
29from simvx.core import (
30 BodyMode,
31 BoxShape3D,
32 Camera3D,
33 CollisionShape3D,
34 DirectionalLight3D,
35 Input,
36 InputMap,
37 Key,
38 Material,
39 Mesh,
40 MeshInstance3D,
41 MouseButton,
42 Node3D,
43 PhysicsBody3D,
44 PhysicsMaterial,
45 PointLight3D,
46 Quat,
47 SphereShape3D,
48 Text2D,
49 Vec3,
50 screen_to_ray,
51)
52from simvx.core.physics.root import PhysicsRoot
53from simvx.graphics import App
54from simvx.graphics.debug_draw import DebugDraw
55
56# ============================================================================
57# Constants
58# ============================================================================
59
60GRAVITY = Vec3(0, -18.0, 0)
61GROUND_Y = 0.0
62SPAWN_HEIGHT = 10.0
63WIDTH, HEIGHT = 1024, 768
64MAX_FPS = 30
65FRAME_TIME = 1.0 / MAX_FPS
66
67PRESETS = [
68 {"colour": (0.95, 0.08, 0.08), "metallic": 0.0, "roughness": 0.7}, # Bold red
69 {"colour": (0.10, 0.40, 0.95), "metallic": 0.9, "roughness": 0.08}, # Chrome blue
70 {
71 "colour": (1.0, 0.75, 0.0),
72 "metallic": 1.0,
73 "roughness": 0.2, # Gold (emissive glow)
74 "emissive_colour": (1.0, 0.85, 0.2, 3.0),
75 },
76 {"colour": (0.05, 0.85, 0.25), "metallic": 0.0, "roughness": 0.8}, # Vivid green
77 {
78 "colour": (0.75, 0.10, 0.95),
79 "metallic": 0.5,
80 "roughness": 0.15, # Neon purple (emissive glow)
81 "emissive_colour": (0.8, 0.2, 1.0, 2.0),
82 },
83 {"colour": (0.95, 0.95, 1.0), "metallic": 1.0, "roughness": 0.02}, # Mirror
84 {"colour": (1.0, 0.45, 0.0), "metallic": 0.0, "roughness": 0.5}, # Bright orange
85 {
86 "colour": (0.12, 0.12, 0.14),
87 "metallic": 0.95,
88 "roughness": 0.05, # Dark chrome (blue emissive)
89 "emissive_colour": (0.2, 0.5, 1.0, 1.5),
90 },
91]
92
93
94# ============================================================================
95# Body node: a PhysicsBody3D that owns its mesh + a hit-flash timer
96# ============================================================================
97
98
99class SandboxBody(PhysicsBody3D):
100 """A simulated body (sphere or box) that renders a mesh and tracks ray hits."""
101
102 def __init__(self, shape_type, render_mesh, half_extent, *, mode=BodyMode.DYNAMIC, **kwargs):
103 super().__init__(mode=mode, material=PhysicsMaterial(friction=0.5, restitution=0.55), **kwargs)
104 self.shape_type = shape_type
105 self.half_extent = half_extent # bounding half-size for debug wireframe
106 self.hit_flash: float = 0.0
107 if shape_type == "sphere":
108 self.shape = SphereShape3D(radius=half_extent)
109 else:
110 self.shape = BoxShape3D(half_extents=Vec3(half_extent, half_extent, half_extent))
111 self.add_child(CollisionShape3D(shape=self.shape))
112 self.mesh = self.add_child(render_mesh)
113
114
115# ============================================================================
116# Main scene
117# ============================================================================
118
119
120class CollisionWorldDemo(Node3D):
121 def __init__(self, **kwargs):
122 super().__init__(name="PhysicsRaycastSandbox", **kwargs)
123
124 # ---- Physics world ----
125 self.world = self.add_child(PhysicsRoot(name="World", gravity=GRAVITY))
126
127 # ---- Camera ----
128 self._cam_angle: float = 35.0
129 self._cam_height: float = 14.0
130 self._cam_dist: float = 28.0
131 self.camera = self.add_child(Camera3D(name="Camera", fov=50, near=0.1, far=200.0))
132 self._update_camera()
133
134 # ---- Lights ----
135 sun = self.add_child(DirectionalLight3D(name="Sun"))
136 sun.colour = (1.0, 0.97, 0.90)
137 sun.intensity = 1.5
138 sun.rotation = Quat.from_euler(math.radians(-55), math.radians(-40), 0)
139
140 fill = self.add_child(PointLight3D(name="Fill", position=Vec3(-10, 8, 10)))
141 fill.colour = (0.3, 0.4, 0.9)
142 fill.intensity = 0.8
143 fill.range = 35.0
144
145 rim = self.add_child(PointLight3D(name="Rim", position=Vec3(12, 5, -8)))
146 rim.colour = (1.0, 0.6, 0.2)
147 rim.intensity = 0.6
148 rim.range = 30.0
149
150 # ---- Ground: a STATIC body so dropped bodies rest on it ----
151 ground = PhysicsBody3D(
152 name="Ground", mode=BodyMode.STATIC, position=Vec3(0, -0.05, 0),
153 material=PhysicsMaterial(friction=0.8, restitution=0.3),
154 )
155 ground.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(15, 0.05, 15))))
156 ground.add_child(MeshInstance3D(
157 name="GroundMesh",
158 mesh=Mesh.cube(1.0),
159 material=Material(colour=(0.06, 0.06, 0.08), metallic=0.1, roughness=0.9),
160 scale=Vec3(30, 0.1, 30),
161 ))
162 self.world.add_child(ground)
163
164 self._bodies: list[SandboxBody] = []
165 self._spawn_count: int = 0
166
167 # ---- Raycast state (multiple rays persist with fade) ----
168 self._rays: list[dict] = [] # [{origin, target, hits, timer}, ...]
169 self._max_rays = 10
170
171 # ---- FPS tracking ----
172 self._fps_samples: list[float] = []
173 self._fps_display: float = 0.0
174 self._fps_update_timer: float = 0.0
175
176 # ---- Status ----
177 self._last_action = "Ready"
178 self._frame_time_target = FRAME_TIME
179 self._last_frame_time = 0.0
180
181 # ---- HUD ----
182 self._title = self.add_child(Text2D(text="PHYSICS RAYCAST", font_scale=3.6))
183 self._info = self.add_child(Text2D(text="", font_scale=2.4))
184 self._fps_text = self.add_child(Text2D(text="FPS: --", font_scale=2.4))
185 self._controls = self.add_child(
186 Text2D(text="CLICK/3:RAY 1/2:DROP R:RESET WASD/QE:CAMERA", font_scale=2.0)
187 )
188 # Tappable HUD buttons so mouse/touch users can spawn and reset too.
189 self._buttons = {
190 "sphere": self.add_child(Text2D(text="[SPHERE]", font_scale=2.4)),
191 "box": self.add_child(Text2D(text="[BOX]", font_scale=2.4)),
192 "reset": self.add_child(Text2D(text="[RESET]", font_scale=2.4)),
193 }
194 self._button_rects: dict[str, tuple[float, float, float, float]] = {}
195
196 def on_ready(self):
197 InputMap.add_action("cam_left", [Key.A, Key.LEFT])
198 InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
199 InputMap.add_action("cam_fwd", [Key.W, Key.UP])
200 InputMap.add_action("cam_back", [Key.S, Key.DOWN])
201 InputMap.add_action("cam_up", [Key.Q])
202 InputMap.add_action("cam_down", [Key.E])
203 InputMap.add_action("spawn_sphere", [Key.KEY_1])
204 InputMap.add_action("spawn_box", [Key.KEY_2])
205 InputMap.add_action("fire_ray", [Key.KEY_3])
206 InputMap.add_action("reset", [Key.R])
207 self._place_pedestals()
208
209 # ------------------------------------------------------------------
210 # Pedestal ring: 8 settled bodies showcasing different meshes + materials
211 # ------------------------------------------------------------------
212
213 def _place_pedestals(self):
214 meshes_and_shapes = [
215 (Mesh.sphere(0.7, rings=16, segments=24), "sphere", 0.7),
216 (Mesh.cube(1.0), "box", 0.5),
217 (Mesh.sphere(0.7, rings=16, segments=24), "sphere", 0.7),
218 (Mesh.sphere(0.7, rings=16, segments=24), "sphere", 0.7),
219 (Mesh.sphere(0.65, rings=12, segments=16), "sphere", 0.65),
220 (Mesh.cube(0.9), "box", 0.45),
221 (Mesh.sphere(0.6, rings=12, segments=16), "sphere", 0.6),
222 (Mesh.sphere(0.6, rings=12, segments=16), "sphere", 0.6),
223 ]
224
225 for i, (mesh, stype, half) in enumerate(meshes_and_shapes):
226 angle = (i / len(meshes_and_shapes)) * math.tau
227 x = math.cos(angle) * 9.0
228 z = math.sin(angle) * 9.0
229 y = half + GROUND_Y
230
231 preset = PRESETS[i % len(PRESETS)]
232 scale = Vec3(half * 2, half * 2, half * 2) if stype == "box" else Vec3(1, 1, 1)
233 mi = MeshInstance3D(name=f"Pedestal{i}Mesh", mesh=mesh, material=Material(**preset), scale=scale)
234 # Pedestals are STATIC: they sit on the ring and serve as ray targets.
235 body = SandboxBody(stype, mi, half, mode=BodyMode.STATIC,
236 name=f"Pedestal{i}", position=Vec3(x, y, z))
237 self.world.add_child(body)
238 self._bodies.append(body)
239 self._spawn_count += 1
240
241 # ------------------------------------------------------------------
242 # Camera
243 # ------------------------------------------------------------------
244
245 def _update_camera(self):
246 rad = math.radians(self._cam_angle)
247 x = math.cos(rad) * self._cam_dist
248 z = math.sin(rad) * self._cam_dist
249 self.camera.position = Vec3(x, self._cam_height, z)
250 self.camera.look_at(Vec3(0, 2.5, 0))
251
252 # ------------------------------------------------------------------
253 # Spawning
254 # ------------------------------------------------------------------
255
256 def _spawn(self, shape_type: str):
257 x = random.uniform(-5, 5)
258 z = random.uniform(-5, 5)
259 preset = random.choice(PRESETS)
260 self._spawn_count += 1
261
262 if shape_type == "sphere":
263 r = random.uniform(0.35, 0.8)
264 mi = MeshInstance3D(name=f"Sphere{self._spawn_count}Mesh",
265 mesh=Mesh.sphere(r, rings=12, segments=16), material=Material(**preset))
266 body = SandboxBody("sphere", mi, r, name=f"Sphere{self._spawn_count}",
267 position=Vec3(x, SPAWN_HEIGHT, z))
268 else:
269 h = random.uniform(0.25, 0.65)
270 mi = MeshInstance3D(name=f"Box{self._spawn_count}Mesh", mesh=Mesh.cube(1.0),
271 material=Material(**preset), scale=Vec3(h * 2, h * 2, h * 2))
272 body = SandboxBody("box", mi, h, name=f"Box{self._spawn_count}",
273 position=Vec3(x, SPAWN_HEIGHT, z))
274
275 self.world.add_child(body)
276 self._bodies.append(body)
277 self._last_action = f"Dropped {shape_type}"
278
279 # ------------------------------------------------------------------
280 # Raycast: fires from camera through mouse cursor
281 # ------------------------------------------------------------------
282
283 def _fire_ray(self):
284 mouse = Input.mouse_position
285 sw, sh = self.tree.screen_size
286 view = self.camera.view_matrix
287 proj = self.camera.projection_matrix(sw / sh if sh > 0 else 1.0)
288 origin, d = screen_to_ray(mouse, (sw, sh), view, proj)
289
290 # Query the SAME world the bodies live in: ``self.physics`` resolves to
291 # the nearest PhysicsRoot *ancestor* (or the tree default), but our bodies
292 # are children of ``self.world`` -- a child PhysicsRoot -- so we must query
293 # through it, else the ray hits the (empty) default world.
294 hits = self.world.physics.raycast_all(Vec3(*origin), Vec3(*d), distance=60.0)
295
296 # A camera-through-cursor ray is collinear with the view, so it would draw
297 # as a single dot from this camera. Offset the beam's origin slightly below
298 # the camera to give it visible screen-space length; its endpoint stays on
299 # the true target (nearest hit, or a far point along the ray) so the beam
300 # visibly terminates where the cursor points.
301 vis_origin = origin - self.camera.up * 1.5
302 vis_origin_np = np.array([vis_origin.x, vis_origin.y, vis_origin.z], dtype=np.float32)
303 if hits:
304 target_pt = np.asarray(hits[0].point, dtype=np.float32)
305 else:
306 ray_dir = np.array([d.x, d.y, d.z], dtype=np.float32)
307 target_pt = np.array([origin.x, origin.y, origin.z], dtype=np.float32) + ray_dir * 60.0
308
309 for hit in hits:
310 if isinstance(hit.node, SandboxBody):
311 hit.node.hit_flash = 1.0
312
313 self._rays.append({"origin": vis_origin_np, "target": target_pt, "hits": hits, "timer": 5.0})
314 if len(self._rays) > self._max_rays:
315 self._rays.pop(0)
316
317 n = len(hits)
318 self._last_action = f"Ray: {n} hit{'s' if n != 1 else ''}"
319
320 # ------------------------------------------------------------------
321 # HUD buttons: mouse/touch access to spawning and reset
322 # ------------------------------------------------------------------
323
324 def _handle_hud_click(self) -> bool:
325 """Route a click/tap that lands on a HUD button. Returns True if one was hit."""
326 extent = self.app.engine.extent if self.app and self.app.engine else None
327 sw, sh = self.tree.screen_size
328 if not self._button_rects or extent is None or sw <= 0 or sh <= 0:
329 return False
330 # Button rects are in framebuffer pixels; the mouse is in window-logical pixels.
331 mx, my = Input.mouse_position
332 fx, fy = mx * extent[0] / sw, my * extent[1] / sh
333 for name, (x, y, w, h) in self._button_rects.items():
334 if x <= fx <= x + w and y <= fy <= y + h:
335 if name == "reset":
336 self._reset()
337 else:
338 self._spawn(name)
339 return True
340 return False
341
342 # ------------------------------------------------------------------
343 # Reset
344 # ------------------------------------------------------------------
345
346 def _reset(self):
347 for b in self._bodies:
348 b.destroy()
349 self._bodies.clear()
350 self._spawn_count = 0
351 self._rays.clear()
352 self._place_pedestals()
353 self._last_action = "Reset"
354
355 # ------------------------------------------------------------------
356 # Physics (fixed timestep)
357 # ------------------------------------------------------------------
358
359 def on_fixed_update(self, dt: float):
360 # Camera (continuous input)
361 speed = 45.0
362 if Input.is_action_pressed("cam_left"):
363 self._cam_angle += speed * dt
364 if Input.is_action_pressed("cam_right"):
365 self._cam_angle -= speed * dt
366 if Input.is_action_pressed("cam_fwd"):
367 self._cam_dist = max(10, self._cam_dist - 12 * dt)
368 if Input.is_action_pressed("cam_back"):
369 self._cam_dist = min(45, self._cam_dist + 12 * dt)
370 if Input.is_action_pressed("cam_up"):
371 self._cam_height = min(30, self._cam_height + 8 * dt)
372 if Input.is_action_pressed("cam_down"):
373 self._cam_height = max(3, self._cam_height - 8 * dt)
374 self._update_camera()
375
376 for b in self._bodies:
377 if b.hit_flash > 0:
378 b.hit_flash = max(0, b.hit_flash - dt * 2.5)
379
380 for ray in self._rays:
381 ray["timer"] -= dt
382 self._rays = [r for r in self._rays if r["timer"] > 0]
383
384 # ------------------------------------------------------------------
385 # Visual (process runs every frame)
386 # ------------------------------------------------------------------
387
388 def on_update(self, dt: float):
389 # ---- FPS limiter ----
390 now = time.perf_counter()
391 elapsed = now - self._last_frame_time
392 if elapsed < self._frame_time_target:
393 time.sleep(self._frame_time_target - elapsed)
394 self._last_frame_time = time.perf_counter()
395
396 # ---- Discrete input ----
397 if Input.is_action_just_pressed("spawn_sphere"):
398 self._spawn("sphere")
399 if Input.is_action_just_pressed("spawn_box"):
400 self._spawn("box")
401 if Input.is_action_just_pressed("fire_ray"):
402 self._fire_ray()
403 if Input.is_mouse_button_just_pressed(MouseButton.LEFT) and not self._handle_hud_click():
404 self._fire_ray()
405 if Input.is_action_just_pressed("reset"):
406 self._reset()
407
408 # ---- FPS tracking ----
409 if dt > 0:
410 self._fps_samples.append(1.0 / dt)
411 if len(self._fps_samples) > 30:
412 self._fps_samples.pop(0)
413 self._fps_update_timer += dt
414 if self._fps_update_timer >= 0.5:
415 self._fps_update_timer = 0
416 if self._fps_samples:
417 self._fps_display = sum(self._fps_samples) / len(self._fps_samples)
418
419 # ---- DebugDraw: ground grid ----
420 half = 15
421 gc = (0.15, 0.16, 0.22, 0.35)
422 for i in range(-half, half + 1, 3):
423 fi = float(i)
424 DebugDraw.line((-half, 0.01, fi), (half, 0.01, fi), gc)
425 DebugDraw.line((fi, 0.01, -half), (fi, 0.01, half), gc)
426
427 # Origin axes
428 DebugDraw.axes((0, 0.02, 0), size=1.5)
429
430 # ---- DebugDraw: collision wireframes ----
431 for b in self._bodies:
432 p = b.world_position
433 c = (p.x, p.y, p.z)
434 if b.hit_flash > 0:
435 t = b.hit_flash
436 col = (1.0, 0.1 + 0.4 * t, 0.05, 0.95)
437 else:
438 col = (0.1, 0.9, 0.3, 0.5)
439 if b.shape_type == "sphere":
440 DebugDraw.sphere(c, b.half_extent, col, segments=10)
441 else:
442 DebugDraw.box(c, (b.half_extent, b.half_extent, b.half_extent), col)
443
444 # ---- DebugDraw: active rays (all persist with fade) ----
445 # timer: 5->3 full brightness, 3->0 fade out
446 for ray in self._rays:
447 a = min(1.0, ray["timer"] / 3.0)
448 DebugDraw.line(tuple(ray["origin"]), tuple(ray["target"]), colour=(1.0, 1.0, 0.0, a))
449 for hit in ray["hits"]:
450 pt = hit.point
451 DebugDraw.sphere((float(pt[0]), float(pt[1]), float(pt[2])), 0.5, (1.0, 0.0, 0.0, a), segments=10)
452
453 # ---- HUD update ----
454 total = len(self._bodies)
455 total_hits = sum(len(r["hits"]) for r in self._rays)
456 self._info.text = (
457 f"Bodies:{total} Rays:{len(self._rays)} Hits:{total_hits} [{self._last_action}]"
458 )
459 self._fps_text.text = f"FPS: {self._fps_display:.0f}"
460
461 # ---- HUD layout: Text2D positions are in framebuffer pixels, so use
462 # engine.extent (not tree.screen_size, which is window-logical pixels). ----
463 extent = self.app.engine.extent if self.app and self.app.engine else None
464 if extent is not None:
465 fw, fh = extent
466 margin = 16
467 # MSDF glyph advance ~10 px per char at font_scale=1 (size=16 px).
468 char_w_at_1 = 10.0
469 line_h = lambda fs: int(fs * 22)
470 self._title.position = (margin, margin)
471 self._info.position = (margin, margin + line_h(self._title.font_scale) + 8)
472 fps_w = len(self._fps_text.text) * char_w_at_1 * self._fps_text.font_scale
473 self._fps_text.position = (fw - margin - fps_w, margin)
474
475 # Bottom strip: tappable button row along the bottom, keyboard hints above.
476 btn_y = fh - margin - line_h(2.4)
477 x = float(margin)
478 self._button_rects.clear()
479 pad = 8 # widen hit areas a little for touch
480 for name, label in self._buttons.items():
481 w = len(label.text) * char_w_at_1 * label.font_scale
482 h = line_h(label.font_scale)
483 label.position = (x, btn_y)
484 self._button_rects[name] = (x - pad, btn_y - pad, w + 2 * pad, h + 2 * pad)
485 x += w + 24
486 # Clamp the hint line's scale so it always fits the framebuffer width.
487 hint_w_at_1 = len(self._controls.text) * char_w_at_1
488 self._controls.font_scale = min(2.0, (fw - 2 * margin) / hint_w_at_1)
489 self._controls.position = (margin, btn_y - 8 - line_h(self._controls.font_scale))
490
491
492# ============================================================================
493# Main
494# ============================================================================
495
496
497def main():
498 app = App(title="SimVX Physics Raycast Sandbox", width=WIDTH, height=HEIGHT, physics_fps=60)
499 app.run(CollisionWorldDemo())
500
501
502if __name__ == "__main__":
503 main()