CollisionWorld

Interactive sandbox for CollisionWorld + raycasting.

▶ Run in browser

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