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