Physics Raycast Sandbox

drop bodies into a PhysicsRoot and raycast against them.

▶ Run in browser

Tags: 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()