Raycast

click a cube in the grid to highlight the ray hit.

▶ Run in browser

Tags: 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())