Raycast¶
click a cube in the grid to highlight the ray hit.
▶ Run in browserTags: 3d
Fires a world-space ray from the camera through the mouse cursor using
screen_to_ray and queries it against the scene’s physics world (a grid of
STATIC PhysicsBody3D boxes) via self.physics.raycast. The ray and the
closest hit are visualised with DebugDraw lines; the hit cube flashes yellow
for a moment.
This is the manual-ray approach. For the engine’s built-in object-picking
(CollisionShape3D.pickable + on_picked) see picking.py.
Controls: Left mouse - Cast a ray through the cursor Escape - Quit
Run: uv run python examples/features/3d/raycast.py Headless self-check: uv run python examples/features/3d/raycast.py –test
Source¶
1"""Raycast -- click a cube in the grid to highlight the ray hit.
2
3Fires a world-space ray from the camera through the mouse cursor using
4``screen_to_ray`` and queries it against the scene's physics world (a grid of
5STATIC ``PhysicsBody3D`` boxes) via ``self.physics.raycast``. The ray and the
6closest hit are visualised with ``DebugDraw`` lines; the hit cube flashes yellow
7for a moment.
8
9This is the manual-ray approach. For the engine's built-in object-picking
10(``CollisionShape3D.pickable`` + ``on_picked``) see ``picking.py``.
11
12Controls:
13 Left mouse - Cast a ray through the cursor
14 Escape - Quit
15
16Run: uv run python examples/features/3d/raycast.py
17Headless self-check: uv run python examples/features/3d/raycast.py --test
18"""
19
20import numpy as np
21
22from simvx.core import (
23 BodyMode,
24 BoxShape3D,
25 Camera3D,
26 CollisionShape3D,
27 DirectionalLight3D,
28 Input,
29 InputMap,
30 Key,
31 Material,
32 Mesh,
33 MeshInstance3D,
34 MouseButton,
35 Node,
36 PhysicsBody3D,
37 Text2D,
38 Vec3,
39 WorldEnvironment,
40 screen_to_ray,
41)
42from simvx.graphics import App
43from simvx.graphics.debug_draw import DebugDraw
44
45GRID = 5
46SPACING = 2.0
47HALF = 0.5
48RAY_LENGTH = 40.0
49FLASH_TIME = 0.4
50RAY_HOLD = 1.5
51
52
53class RayCube(PhysicsBody3D):
54 """A STATIC box body that renders a cube and remembers its base colour."""
55
56 def __init__(self, colour, **kwargs):
57 super().__init__(mode=BodyMode.STATIC, **kwargs)
58 self.base_colour = colour
59 self.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(HALF, HALF, HALF))))
60 self.mesh = self.add_child(
61 MeshInstance3D(mesh=Mesh.cube(), material=Material(colour=colour, roughness=0.5, metallic=0.1))
62 )
63
64
65class RaycastScene(Node):
66 def on_ready(self):
67 InputMap.add_action("fire", [MouseButton.LEFT])
68 InputMap.add_action("quit", [Key.ESCAPE])
69
70 self.add_child(WorldEnvironment(name="Env"))
71
72 self._cam = Camera3D(position=(8, 10, 14), fov=55, near=0.1, far=200.0)
73 self._cam.look_at((0, 0, 0))
74 self.add_child(self._cam)
75
76 sun = DirectionalLight3D(name="Sun", intensity=1.4, colour=(1.0, 0.96, 0.88))
77 sun.look_at((-0.4, -1.0, -0.6))
78 self.add_child(sun)
79
80 ground = MeshInstance3D(mesh=Mesh.cube(), material=Material(colour=(0.25, 0.26, 0.28), roughness=0.9))
81 ground.scale = (40.0, 0.1, 40.0)
82 ground.position = (0, -1.1, 0)
83 self.add_child(ground)
84
85 # Grid of cubes; each is a STATIC PhysicsBody3D. With no PhysicsRoot in
86 # the scene they land in the tree's default physics world, the one
87 # ``self.physics`` queries.
88 self._flash: dict[int, tuple[RayCube, tuple, float]] = {} # id -> (cube, orig_colour, time_left)
89 rng = np.random.default_rng(7)
90 offset = (GRID - 1) * SPACING * 0.5
91 for ix in range(GRID):
92 for iz in range(GRID):
93 c = (0.3 + rng.random() * 0.5, 0.3 + rng.random() * 0.5, 0.4 + rng.random() * 0.5, 1.0)
94 cube = RayCube(colour=c, position=Vec3(ix * SPACING - offset, 0.0, iz * SPACING - offset))
95 self.add_child(cube)
96
97 self.add_child(Text2D(text="Left click to cast a ray | Esc quit", position=(10, 10), font_scale=1.4))
98 self._status = self.add_child(Text2D(text="", position=(10, 40), font_scale=1.2))
99 self._last_ray: tuple[np.ndarray, np.ndarray, np.ndarray | None] | None = None
100 self._ray_timer = 0.0
101
102 def on_update(self, dt):
103 if Input.is_action_just_pressed("quit"):
104 self.app.quit()
105 return
106 if Input.is_action_just_pressed("fire"):
107 self._fire_ray()
108
109 # Decay flashes and restore colours
110 for k in list(self._flash):
111 cube, orig, t = self._flash[k]
112 t -= dt
113 if t <= 0:
114 cube.mesh.material.colour = orig
115 del self._flash[k]
116 else:
117 self._flash[k] = (cube, orig, t)
118
119 # Render the last ray for a short window
120 if self._last_ray is not None:
121 self._ray_timer -= dt
122 if self._ray_timer <= 0:
123 self._last_ray = None
124 else:
125 origin, end, hit_point = self._last_ray
126 DebugDraw.line(tuple(origin), tuple(end), colour=(0.2, 1.0, 0.2, 1.0))
127 if hit_point is not None:
128 DebugDraw.sphere(tuple(hit_point), 0.15, colour=(1.0, 0.9, 0.2, 1.0))
129
130 def _fire_ray(self):
131 w, h = self.app.width, self.app.height
132 origin, direction = screen_to_ray(
133 Input.mouse_position,
134 (w, h),
135 self._cam.view_matrix,
136 self._cam.projection_matrix(w / h),
137 )
138 o = np.asarray(origin, dtype=np.float32)
139 hit = self.physics.raycast(Vec3(*origin), Vec3(*direction), distance=RAY_LENGTH)
140 if hit:
141 cube = hit.node
142 if id(cube) not in self._flash:
143 self._flash[id(cube)] = (cube, cube.mesh.material.colour, FLASH_TIME)
144 cube.mesh.material.colour = (1.0, 0.9, 0.2, 1.0)
145 self._status.text = (
146 f"Hit at ({hit.point[0]:.2f}, {hit.point[1]:.2f}, {hit.point[2]:.2f}) dist={hit.distance:.2f}"
147 )
148 self._last_ray = (o, np.asarray(hit.point, dtype=np.float32), np.asarray(hit.point, dtype=np.float32))
149 else:
150 d = np.asarray(direction, dtype=np.float32)
151 self._status.text = "No hit"
152 self._last_ray = (o, o + d * RAY_LENGTH, None)
153 self._ray_timer = RAY_HOLD
154
155
156def _selftest() -> bool:
157 """Headless: cast the demo's own ray with the demo's own click.
158
159 The click goes through the ``fire`` action at a screen position, so
160 ``screen_to_ray`` and the physics query are both reached the way the demo
161 reaches them. The camera looks at the origin, so a click at the centre of the
162 viewport must hit the centre cube and nothing else.
163 """
164 from simvx.core.testing import InputSimulator
165 from simvx.graphics.testing import assert_not_blank, save_png
166
167 WIDTH, HEIGHT = 1280, 720
168 HIT = 10 # click the centre of the viewport, which the camera aims at the origin
169 MISS = 40 # click the top-left corner, which looks past the grid at the sky
170 FADED = MISS + int(FLASH_TIME * 60) + 10
171 FRAMES = FADED + 10
172
173 app = App(title="Raycast Demo", width=WIDTH, height=HEIGHT, visible=False)
174 scene = RaycastScene(name="RaycastScene")
175 sim = InputSimulator()
176 seen: dict[str, object] = {}
177
178 def centre_cube() -> RayCube:
179 """The cube at the origin: the one a ray through the screen centre meets."""
180 return min(
181 (c for c in scene.children if isinstance(c, RayCube)),
182 key=lambda c: float((c.position - Vec3(0, 0, 0)).length()),
183 )
184
185 def on_frame(idx: int, _t: float) -> bool:
186 if idx == HIT:
187 sim.press_mouse(MouseButton.LEFT, (WIDTH / 2, HEIGHT / 2))
188 elif idx == HIT + 1:
189 sim.release_mouse(MouseButton.LEFT)
190 seen["hit_status"] = scene._status.text
191 seen["flashing"] = tuple(centre_cube().mesh.material.colour)
192 seen["ray"] = scene._last_ray
193 elif idx == MISS:
194 sim.press_mouse(MouseButton.LEFT, (4.0, 4.0))
195 elif idx == MISS + 1:
196 sim.release_mouse(MouseButton.LEFT)
197 seen["miss_status"] = scene._status.text
198 seen["miss_ray"] = scene._last_ray
199 elif idx == FADED:
200 seen["faded"] = tuple(centre_cube().mesh.material.colour)
201 seen["base"] = tuple(centre_cube().base_colour)
202 return True
203
204 frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[HIT + 2])
205 assert_not_blank(frames[0])
206 save_png(frames[0], "/tmp/raycast_test.png")
207
208 ok = True
209
210 def check(label: str, passed: bool, detail: str) -> None:
211 nonlocal ok
212 ok = ok and passed
213 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
214
215 # The centre cube is a unit box at the origin, so a hit on it lands on one of
216 # its faces: every coordinate inside +/-HALF, and one of them exactly on it.
217 origin, end, point = seen["ray"]
218 on_face = point is not None and abs(max(abs(float(v)) for v in point) - HALF) < 1e-3
219 check(
220 "a click at the viewport centre hits the cube the camera is aimed at",
221 on_face and all(abs(float(v)) <= HALF + 1e-3 for v in point),
222 f"hit at ({point[0]:.2f}, {point[1]:.2f}, {point[2]:.2f})" if point is not None else "no hit",
223 )
224 check("and the status line reports where", str(seen["hit_status"]).startswith("Hit at ("), seen["hit_status"])
225 check(
226 "the ray drawn is the one that was cast, stopping at the hit",
227 float(((end - origin) - (point - origin)).max()) == 0.0,
228 f"drawn from ({origin[0]:.1f}, {origin[1]:.1f}, {origin[2]:.1f}) to the hit point",
229 )
230 check(
231 "the cube it hit flashes",
232 seen["flashing"] == (1.0, 0.9, 0.2, 1.0),
233 f"colour went to {seen['flashing']}",
234 )
235 check(
236 "and goes back to its own colour when the flash expires",
237 max(abs(a - b) for a, b in zip(seen["faded"], seen["base"], strict=True)) < 1e-3,
238 f"back to {tuple(round(c, 3) for c in seen['faded'])}, " f"its own {tuple(round(c, 3) for c in seen['base'])}",
239 )
240
241 # A ray past the grid must return nothing rather than the nearest anything:
242 # the demo says "No hit" and draws the full-length ray with no hit marker.
243 _, miss_end, miss_point = seen["miss_ray"]
244 check(
245 "a click past the grid reports no hit and draws the ray full length",
246 seen["miss_status"] == "No hit" and miss_point is None,
247 f"{seen['miss_status']}, ray ends at ({miss_end[0]:.1f}, {miss_end[1]:.1f}, {miss_end[2]:.1f})",
248 )
249
250 print("screenshot: /tmp/raycast_test.png")
251 print("SELFTEST:", "PASS" if ok else "FAIL")
252 return ok
253
254
255if __name__ == "__main__":
256 import sys
257
258 if "--test" in sys.argv:
259 sys.exit(0 if _selftest() else 1)
260 App(title="Raycast Demo", width=1280, height=720).run(RaycastScene())