Raycast demo

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