Raycast demo¶
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 intersects it against a CollisionWorld populated
with a grid of boxes. 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 GPU object-picking
buffer (shape.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
Source¶
1"""Raycast demo -- 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 intersects it against a ``CollisionWorld`` populated
5with a grid of boxes. The ray and the closest hit are visualised with
6``DebugDraw`` lines; the hit cube flashes yellow for a moment.
7
8This is the manual-ray approach. For the engine's built-in GPU object-picking
9buffer (``shape.pickable`` + ``on_picked``) see ``picking.py``.
10
11Controls:
12 Left mouse - Cast a ray through the cursor
13 Escape - Quit
14
15Run: uv run python examples/features/3d/raycast.py
16"""
17
18import numpy as np
19
20from simvx.core import (
21 BoxShape,
22 Camera3D,
23 CollisionWorld,
24 DirectionalLight3D,
25 Input,
26 InputMap,
27 Key,
28 Material,
29 Mesh,
30 MeshInstance3D,
31 MouseButton,
32 Node,
33 Text2D,
34 WorldEnvironment,
35 screen_to_ray,
36)
37from simvx.graphics import App
38from simvx.graphics.debug_draw import DebugDraw
39
40GRID = 5
41SPACING = 2.0
42HALF = 0.5
43RAY_LENGTH = 40.0
44FLASH_TIME = 0.4
45RAY_HOLD = 1.5
46
47
48class RaycastScene(Node):
49 def on_ready(self):
50 InputMap.add_action("fire", [MouseButton.LEFT])
51 InputMap.add_action("quit", [Key.ESCAPE])
52
53 self.add_child(WorldEnvironment(name="Env"))
54
55 self._cam = Camera3D(position=(8, 10, 14), fov=55, near=0.1, far=200.0)
56 self._cam.look_at((0, 0, 0))
57 self.add_child(self._cam)
58
59 sun = DirectionalLight3D(name="Sun", intensity=1.4, colour=(1.0, 0.96, 0.88))
60 sun.look_at((-0.4, -1.0, -0.6))
61 self.add_child(sun)
62
63 ground = MeshInstance3D(mesh=Mesh.cube(), material=Material(colour=(0.25, 0.26, 0.28), roughness=0.9))
64 ground.scale = (40.0, 0.1, 40.0)
65 ground.position = (0, -1.1, 0)
66 self.add_child(ground)
67
68 # Grid of cubes; each also lives in the CollisionWorld as a BoxShape body.
69 self._cworld = CollisionWorld()
70 self._flash: dict[int, tuple[MeshInstance3D, tuple, float]] = {} # id -> (cube, orig_colour, time_left)
71 mesh, rng = Mesh.cube(), np.random.default_rng(7)
72 offset = (GRID - 1) * SPACING * 0.5
73 for ix in range(GRID):
74 for iz in range(GRID):
75 c = (0.3 + rng.random() * 0.5, 0.3 + rng.random() * 0.5, 0.4 + rng.random() * 0.5, 1.0)
76 cube = MeshInstance3D(mesh=mesh, material=Material(colour=c, roughness=0.5, metallic=0.1))
77 cube.position = (ix * SPACING - offset, 0.0, iz * SPACING - offset)
78 self.add_child(cube)
79 self._cworld.add_body(cube, BoxShape(half_extents=(HALF, HALF, HALF)),
80 position=np.asarray(cube.position, dtype=np.float32))
81
82 self.add_child(Text2D(text="Left click to cast a ray | Esc quit", x=10, y=10, font_scale=1.4))
83 self._status = self.add_child(Text2D(text="", x=10, y=40, font_scale=1.2))
84 self._last_ray: tuple[np.ndarray, np.ndarray, np.ndarray | None] | None = None
85 self._ray_timer = 0.0
86
87 def on_process(self, dt):
88 if Input.is_action_just_pressed("quit"):
89 self.app.quit()
90 return
91 if Input.is_action_just_pressed("fire"):
92 self._fire_ray()
93
94 # Decay flashes and restore colours
95 for k in list(self._flash):
96 cube, orig, t = self._flash[k]
97 t -= dt
98 if t <= 0:
99 cube.material.colour = orig
100 del self._flash[k]
101 else:
102 self._flash[k] = (cube, orig, t)
103
104 # Render the last ray for a short window
105 if self._last_ray is not None:
106 self._ray_timer -= dt
107 if self._ray_timer <= 0:
108 self._last_ray = None
109 else:
110 origin, end, hit_point = self._last_ray
111 DebugDraw.line(tuple(origin), tuple(end), colour=(0.2, 1.0, 0.2, 1.0))
112 if hit_point is not None:
113 DebugDraw.sphere(tuple(hit_point), 0.15, colour=(1.0, 0.9, 0.2, 1.0))
114
115 def _fire_ray(self):
116 w, h = self.app.width, self.app.height
117 origin, direction = screen_to_ray(
118 Input.mouse_position, (w, h), self._cam.view_matrix, self._cam.projection_matrix(w / h),
119 )
120 o = np.asarray(origin, dtype=np.float32)
121 d = np.asarray(direction, dtype=np.float32)
122 hits = self._cworld.raycast(o, d, max_dist=RAY_LENGTH)
123 if hits:
124 hit = hits[0]
125 cube = hit.body
126 if id(cube) not in self._flash:
127 self._flash[id(cube)] = (cube, cube.material.colour, FLASH_TIME)
128 cube.material.colour = (1.0, 0.9, 0.2, 1.0)
129 self._status.text = (
130 f"Hit at ({hit.point[0]:.2f}, {hit.point[1]:.2f}, {hit.point[2]:.2f}) dist={hit.distance:.2f}"
131 )
132 self._last_ray = (o, hit.point, hit.point)
133 else:
134 self._status.text = "No hit"
135 self._last_ray = (o, o + d * RAY_LENGTH, None)
136 self._ray_timer = RAY_HOLD
137
138
139if __name__ == "__main__":
140 App(title="Raycast Demo", width=1280, height=720).run(RaycastScene())