Physics Raycast Sandbox¶
drop bodies into a PhysicsRoot and raycast against them.
â–¶ Run in browserTags: 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())