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 queries it against the scene’s physics world (a
PhysicsRoot holding a grid of STATIC PhysicsBody3D boxes) via
self._world.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
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 queries it against the scene's physics world (a
5``PhysicsRoot`` holding a grid of STATIC ``PhysicsBody3D`` boxes) via
6``self._world.physics.raycast``. The ray and the closest hit are visualised with
7``DebugDraw`` lines; the hit cube flashes yellow for 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
17"""
18
19import numpy as np
20
21from simvx.core import (
22 BodyMode,
23 BoxShape3D,
24 Camera3D,
25 CollisionShape3D,
26 DirectionalLight3D,
27 Input,
28 InputMap,
29 Key,
30 Material,
31 Mesh,
32 MeshInstance3D,
33 MouseButton,
34 Node,
35 PhysicsBody3D,
36 Text2D,
37 Vec3,
38 WorldEnvironment,
39 screen_to_ray,
40)
41from simvx.core.physics.root import PhysicsRoot
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 in the scene's physics world.
86 self._world = self.add_child(PhysicsRoot(name="World"))
87 self._flash: dict[int, tuple[RayCube, tuple, float]] = {} # id -> (cube, orig_colour, time_left)
88 rng = np.random.default_rng(7)
89 offset = (GRID - 1) * SPACING * 0.5
90 for ix in range(GRID):
91 for iz in range(GRID):
92 c = (0.3 + rng.random() * 0.5, 0.3 + rng.random() * 0.5, 0.4 + rng.random() * 0.5, 1.0)
93 cube = RayCube(colour=c, position=Vec3(ix * SPACING - offset, 0.0, iz * SPACING - offset))
94 self._world.add_child(cube)
95
96 self.add_child(Text2D(text="Left click to cast a ray | Esc quit", position=(10, 10), font_scale=1.4))
97 self._status = self.add_child(Text2D(text="", position=(10, 40), font_scale=1.2))
98 self._last_ray: tuple[np.ndarray, np.ndarray, np.ndarray | None] | None = None
99 self._ray_timer = 0.0
100
101 def on_update(self, dt):
102 if Input.is_action_just_pressed("quit"):
103 self.app.quit()
104 return
105 if Input.is_action_just_pressed("fire"):
106 self._fire_ray()
107
108 # Decay flashes and restore colours
109 for k in list(self._flash):
110 cube, orig, t = self._flash[k]
111 t -= dt
112 if t <= 0:
113 cube.mesh.material.colour = orig
114 del self._flash[k]
115 else:
116 self._flash[k] = (cube, orig, t)
117
118 # Render the last ray for a short window
119 if self._last_ray is not None:
120 self._ray_timer -= dt
121 if self._ray_timer <= 0:
122 self._last_ray = None
123 else:
124 origin, end, hit_point = self._last_ray
125 DebugDraw.line(tuple(origin), tuple(end), colour=(0.2, 1.0, 0.2, 1.0))
126 if hit_point is not None:
127 DebugDraw.sphere(tuple(hit_point), 0.15, colour=(1.0, 0.9, 0.2, 1.0))
128
129 def _fire_ray(self):
130 w, h = self.app.width, self.app.height
131 origin, direction = screen_to_ray(
132 Input.mouse_position, (w, h), self._cam.view_matrix, self._cam.projection_matrix(w / h),
133 )
134 o = np.asarray(origin, dtype=np.float32)
135 # Query the cubes' own world: ``self.physics`` resolves to the nearest
136 # PhysicsRoot *ancestor* (here none -> empty tree default), but the cubes
137 # live in ``self._world``, a child PhysicsRoot, so we query through it.
138 hit = self._world.physics.raycast(Vec3(*origin), Vec3(*direction), distance=RAY_LENGTH)
139 if hit:
140 cube = hit.node
141 if id(cube) not in self._flash:
142 self._flash[id(cube)] = (cube, cube.mesh.material.colour, FLASH_TIME)
143 cube.mesh.material.colour = (1.0, 0.9, 0.2, 1.0)
144 self._status.text = (
145 f"Hit at ({hit.point[0]:.2f}, {hit.point[1]:.2f}, {hit.point[2]:.2f}) dist={hit.distance:.2f}"
146 )
147 self._last_ray = (o, np.asarray(hit.point, dtype=np.float32), np.asarray(hit.point, dtype=np.float32))
148 else:
149 d = np.asarray(direction, dtype=np.float32)
150 self._status.text = "No hit"
151 self._last_ray = (o, o + d * RAY_LENGTH, None)
152 self._ray_timer = RAY_HOLD
153
154
155if __name__ == "__main__":
156 App(title="Raycast Demo", width=1280, height=720).run(RaycastScene())