Physics Raycast Sandbox¶

drop bodies into a PhysicsRoot and raycast against them.

â–¶ Run in browser

Tags: 3d

A PhysicsRoot is a physics world: every body below it in the tree is simulated together, under the root’s own gravity. A body is a PhysicsBody3D whose mode says how it moves (BodyMode.STATIC for the ground and the pedestal ring, DYNAMIC, the default, for what you drop) and whose shape Property is its collider; a PhysicsMaterial sets its friction and bounce.

world = self.add_child(PhysicsRoot(gravity=Vec3(0, -18, 0)))
world.add_child(PhysicsBody3D(shape=SphereShape3D(radius=0.5), position=(0, 10, 0)))

Queries go through the world the bodies live in. node.physics resolves to the nearest PhysicsRoot ancestor, and this scene’s bodies sit under a child root, so it asks self.world.physics.raycast_all(origin, direction, distance=...), which returns every hit nearest first, each carrying the node it struck, the point and the distance. The ray itself comes from camera.ray_at(screen_pos), the line from the camera through a point on screen.

Click or tap the scene to fire a ray through that point: every body it passes through flashes, and the beam stays for a few seconds with a marker at each hit. The green wireframes are each body’s collider, drawn with DebugDraw.

Controls: Click / tap - Fire a ray through that point 3 - Fire a ray through the mouse cursor 1 / 2 - Drop a sphere / a box R - Reset the scene Drag / WASD, arrows - Orbit the camera Scroll / Q, E - Zoom

Source¶

  1"""Physics Raycast Sandbox: drop bodies into a PhysicsRoot and raycast against them.
  2
  3A ``PhysicsRoot`` is a physics world: every body below it in the tree is
  4simulated together, under the root's own ``gravity``. A body is a
  5``PhysicsBody3D`` whose ``mode`` says how it moves (``BodyMode.STATIC`` for the
  6ground and the pedestal ring, ``DYNAMIC``, the default, for what you drop) and
  7whose ``shape`` Property is its collider; a ``PhysicsMaterial`` sets its
  8friction and bounce.
  9
 10    world = self.add_child(PhysicsRoot(gravity=Vec3(0, -18, 0)))
 11    world.add_child(PhysicsBody3D(shape=SphereShape3D(radius=0.5), position=(0, 10, 0)))
 12
 13Queries go through the world the bodies live in. ``node.physics`` resolves to
 14the nearest ``PhysicsRoot`` *ancestor*, and this scene's bodies sit under a
 15child root, so it asks ``self.world.physics.raycast_all(origin, direction,
 16distance=...)``, which returns every hit nearest first, each carrying the
 17``node`` it struck, the ``point`` and the ``distance``. The ray itself comes
 18from ``camera.ray_at(screen_pos)``, the line from the camera through a point on
 19screen.
 20
 21Click or tap the scene to fire a ray through that point: every body it passes
 22through flashes, and the beam stays for a few seconds with a marker at each
 23hit. The green wireframes are each body's collider, drawn with ``DebugDraw``.
 24
 25Controls:
 26    Click / tap         - Fire a ray through that point
 27    3                   - Fire a ray through the mouse cursor
 28    1 / 2               - Drop a sphere / a box
 29    R                   - Reset the scene
 30    Drag / WASD, arrows - Orbit the camera
 31    Scroll / Q, E       - Zoom
 32"""
 33
 34import math
 35import random
 36from dataclasses import dataclass
 37from enum import StrEnum
 38
 39from simvx.core import (
 40    Anchor,
 41    Axis1D,
 42    Axis2D,
 43    BodyMode,
 44    BoxShape3D,
 45    Button,
 46    CanvasLayer,
 47    DirectionalLight3D,
 48    HBoxContainer,
 49    Input,
 50    Key,
 51    Label,
 52    Material,
 53    Mesh,
 54    MeshInstance3D,
 55    Node3D,
 56    OrbitCamera3D,
 57    PhysicsBody3D,
 58    PhysicsMaterial,
 59    PhysicsRoot,
 60    Pointer,
 61    PointLight3D,
 62    RayHit,
 63    SphereShape3D,
 64    VBoxContainer,
 65    Vec3,
 66    on_input,
 67)
 68from simvx.graphics import App
 69from simvx.graphics.debug_draw import DebugDraw
 70
 71SPAWN_HEIGHT = 10.0
 72RAY_LENGTH = 60.0
 73RAY_LIFETIME = 5.0
 74MAX_RAYS = 10
 75
 76BOUNCY = PhysicsMaterial(friction=0.5, restitution=0.55)
 77# emissive_colour is (r, g, b, intensity).
 78LOOKS = [
 79    Material(colour=(0.95, 0.08, 0.08), roughness=0.7),
 80    Material(colour=(0.10, 0.40, 0.95), metallic=0.9, roughness=0.08),
 81    Material(colour=(1.0, 0.75, 0.0), metallic=1.0, roughness=0.2, emissive_colour=(1.0, 0.85, 0.2, 3.0)),
 82    Material(colour=(0.05, 0.85, 0.25), roughness=0.8),
 83    Material(colour=(0.75, 0.10, 0.95), metallic=0.5, roughness=0.15, emissive_colour=(0.8, 0.2, 1.0, 2.0)),
 84    Material(colour=(0.95, 0.95, 1.0), metallic=1.0, roughness=0.02),
 85    Material(colour=(1.0, 0.45, 0.0), roughness=0.5),
 86    Material(colour=(0.12, 0.12, 0.14), metallic=0.95, roughness=0.05, emissive_colour=(0.2, 0.5, 1.0, 1.5)),
 87]
 88
 89
 90class Kind(StrEnum):
 91    SPHERE = "sphere"
 92    BOX = "box"
 93
 94
 95# The ring of static targets: a kind and a size (radius, or half-width) each.
 96PEDESTALS = [
 97    (Kind.SPHERE, 0.7), (Kind.BOX, 0.5), (Kind.SPHERE, 0.7), (Kind.SPHERE, 0.7),
 98    (Kind.SPHERE, 0.65), (Kind.BOX, 0.45), (Kind.SPHERE, 0.6), (Kind.SPHERE, 0.6),
 99]  # fmt: skip
100
101
102class SandboxBody(PhysicsBody3D):
103    """A sphere or a box whose collider, mesh and wireframe are the same size."""
104
105    def __init__(self, kind: Kind, size: float, look: Material, **kwargs):
106        super().__init__(material=BOUNCY, **kwargs)
107        self.kind = Kind(kind)
108        self.size = size
109        self.flash = 0.0  # 1.0 when a ray hits, fading back to 0
110        if self.kind is Kind.SPHERE:
111            self.shape = SphereShape3D(radius=size)
112            mesh = Mesh.sphere(size, rings=12, segments=16)
113        else:
114            self.shape = BoxShape3D(half_extents=(size, size, size))
115            mesh = Mesh.box(size * 2)
116        self.add_child(MeshInstance3D(mesh=mesh, material=look))
117
118    def on_update(self, dt):
119        self.flash = max(0.0, self.flash - dt * 2.5)
120        colour = (1.0, 0.1 + 0.4 * self.flash, 0.05, 0.95) if self.flash else (0.1, 0.9, 0.3, 0.5)
121        if self.kind is Kind.SPHERE:
122            DebugDraw.sphere(self.world_position, self.size, colour, segments=10)
123        else:
124            # DebugDraw.box is axis-aligned, so a tumbling box's wireframe does not turn with it.
125            DebugDraw.box(self.world_position, (self.size, self.size, self.size), colour)
126
127
128@dataclass
129class Shot:
130    """A fired ray, kept on screen for RAY_LIFETIME seconds."""
131
132    start: Vec3
133    end: Vec3
134    hits: list[RayHit]
135    age: float = 0.0
136
137
138class CollisionWorldDemo(Node3D):
139    input_context = {
140        "fire_ray": [Key.KEY_3],
141        "drop_sphere": [Key.KEY_1],
142        "drop_box": [Key.KEY_2],
143        "reset": [Key.R],
144        "orbit": [Axis2D.WASD, Axis2D.ARROWS],
145        "zoom": [Axis1D(low=Key.E, high=Key.Q)],
146    }
147
148    def on_ready(self):
149        self.world = self.add_child(PhysicsRoot(name="World", gravity=Vec3(0, -18.0, 0)))
150        self.camera = self.add_child(
151            OrbitCamera3D(
152                pivot=Vec3(0, 2.5, 0),
153                distance=30.0,
154                yaw=math.radians(55),
155                pitch=math.radians(-22),
156                min_distance=10.0,
157                max_distance=45.0,
158                min_pitch=math.radians(-70),
159                max_pitch=math.radians(-5),
160                fov=50,
161                far=200.0,
162            )
163        )
164        self.add_child(DirectionalLight3D(colour=(1.0, 0.97, 0.9), intensity=1.5, direction=(-0.5, -1.2, -0.6)))
165        self.add_child(PointLight3D(colour=(0.3, 0.4, 0.9), intensity=0.8, range=35.0, position=(-10, 8, 10)))
166        self.add_child(PointLight3D(colour=(1.0, 0.6, 0.2), intensity=0.6, range=30.0, position=(12, 5, -8)))
167
168        # The ground is a static slab whose top face is y = 0.
169        ground = self.world.add_child(
170            PhysicsBody3D(
171                name="Ground",
172                mode=BodyMode.STATIC,
173                shape=BoxShape3D(half_extents=(15, 0.05, 15)),
174                material=PhysicsMaterial(friction=0.8, restitution=0.3),
175                position=(0, -0.05, 0),
176            )
177        )
178        ground.add_child(MeshInstance3D(mesh=Mesh.box((30, 0.1, 30)), material=Material(colour=(0.06, 0.06, 0.08))))
179
180        self.shots: list[Shot] = []
181        self.status = "Ready"
182        self.place_pedestals()
183
184        hud = self.add_child(CanvasLayer())
185        self.info = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=12, font_size=15))
186        footer = hud.add_child(VBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12, separation=8))
187
188        # Example help overlay: not part of the technique shown.
189        footer.add_child(Label("Click: fire   Drag / WASD: orbit   Scroll / Q, E: zoom"))
190
191        bar = footer.add_child(HBoxContainer(separation=8))
192        for text, action in [("Sphere", "drop_sphere"), ("Box", "drop_box"), ("Reset", "reset")]:
193            bar.add_child(Button(text, action=action))
194        # A container keeps its own size rather than growing to its content, so a
195        # bottom-anchored stack is fitted by hand or it runs off the bottom edge.
196        footer.size = footer.get_minimum_size()
197
198    def place_pedestals(self):
199        for i, (kind, size) in enumerate(PEDESTALS):
200            angle = i / len(PEDESTALS) * math.tau
201            position = (math.cos(angle) * 9.0, size, math.sin(angle) * 9.0)
202            self.world.add_child(SandboxBody(kind, size, LOOKS[i], mode=BodyMode.STATIC, position=position))
203
204    def drop(self, kind: Kind):
205        size = random.uniform(0.35, 0.8) if kind is Kind.SPHERE else random.uniform(0.25, 0.65)
206        position = (random.uniform(-5, 5), SPAWN_HEIGHT, random.uniform(-5, 5))
207        self.world.add_child(SandboxBody(kind, size, random.choice(LOOKS), position=position))
208        self.status = f"Dropped a {kind}"
209
210    def fire_ray(self, screen_pos):
211        ray = self.camera.ray_at(screen_pos)
212        hits = self.world.physics.raycast_all(ray.origin, ray.direction, distance=RAY_LENGTH)
213        for hit in hits:
214            if isinstance(hit.node, SandboxBody):
215                hit.node.flash = 1.0
216        # A ray through the cursor points straight away from the camera and would draw
217        # as a dot, so the beam starts a little below the camera and ends on the target.
218        end = hits[0].point if hits else ray.at(RAY_LENGTH)
219        self.shots.append(Shot(ray.origin - self.camera.up * 1.5, end, hits))
220        del self.shots[:-MAX_RAYS]
221        self.status = f"Ray: {len(hits)} hit{'' if len(hits) == 1 else 's'}"
222
223    def reset(self):
224        for body in self.world.find_all(SandboxBody):
225            body.destroy()
226        self.shots.clear()
227        self.place_pedestals()
228        self.status = "Reset"
229
230    @on_input(Pointer.PRIMARY, edge="release")
231    def _on_click(self, event):
232        # A drag orbits the camera; only a press that never became a drag fires.
233        if not event.dragging:
234            self.fire_ray(event.position)
235
236    @on_input("fire_ray")
237    def _on_fire_key(self, _event):
238        self.fire_ray(Input.mouse_position)
239
240    @on_input("drop_sphere")
241    def _on_drop_sphere(self, _event):
242        self.drop(Kind.SPHERE)
243
244    @on_input("drop_box")
245    def _on_drop_box(self, _event):
246        self.drop(Kind.BOX)
247
248    @on_input("reset")
249    def _on_reset(self, _event):
250        self.reset()
251
252    def on_update(self, dt):
253        turn = Input.value("orbit")
254        self.camera.orbit(-turn.x * 0.8 * dt, turn.y * 0.6 * dt)
255        self.camera.zoom(Input.value("zoom") * 12.0 * dt)
256
257        for shot in self.shots:
258            shot.age += dt
259        self.shots = [shot for shot in self.shots if shot.age < RAY_LIFETIME]
260        for shot in self.shots:
261            alpha = min(1.0, (RAY_LIFETIME - shot.age) / 3.0)
262            DebugDraw.line(shot.start, shot.end, colour=(1.0, 1.0, 0.0, alpha))
263            for hit in shot.hits:
264                DebugDraw.sphere(hit.point, 0.5, (1.0, 0.0, 0.0, alpha), segments=10)
265
266        for i in range(-15, 16, 3):
267            DebugDraw.line((-15, 0.01, i), (15, 0.01, i), (0.15, 0.16, 0.22, 0.35))
268            DebugDraw.line((i, 0.01, -15), (i, 0.01, 15), (0.15, 0.16, 0.22, 0.35))
269        DebugDraw.axes((0, 0.02, 0), size=1.5)
270
271        hits = sum(len(shot.hits) for shot in self.shots)
272        bodies = len(self.world.find_all(SandboxBody))
273        self.info.text = f"Bodies: {bodies}   Rays: {len(self.shots)}   Hits: {hits}   [{self.status}]"
274
275
276if __name__ == "__main__":
277    App(title="Physics Raycast Sandbox", width=1024, height=768, physics_fps=60).run(CollisionWorldDemo())