Raycast

click a cube to cast a ray through the pointer and see where it lands.

▶ Run in browser

Tags: 3d

A raycast asks the physics world what a line meets first. Two calls cover the common case of “what is under the pointer”:

ray = camera.ray_at(event.position)           # world-space Ray through a screen point
hit = self.physics.raycast(ray.origin, ray.direction, distance=40.0)

Camera3D.ray_at turns a screen position into a Ray starting on the camera’s near plane. self.physics is the physics world of the nearest PhysicsRoot ancestor; with none in the scene, as here, it is the tree’s default world, where every body lands. raycast returns the nearest RayHit (node, point, normal, distance) or None on a miss.

Each cube is a static PhysicsBody3D whose shape is a BoxShape3D. A primary press (left mouse, a tap, a pen tip) casts the ray: the cube it hits flashes yellow and fades back to its own colour with Tween.colour, and the status line reports the hit point and distance. DebugDraw is immediate mode, so the scene redraws the last ray every frame for a moment and a sphere marks the hit; a miss draws the ray at full length.

This is the manual approach. For the engine’s built-in pointer picking (CollisionShape3D(pickable=True) and on_picked), see picking.py.

Source

  1"""Raycast: click a cube to cast a ray through the pointer and see where it lands.
  2
  3A raycast asks the physics world what a line meets first. Two calls cover the
  4common case of "what is under the pointer":
  5
  6    ray = camera.ray_at(event.position)           # world-space Ray through a screen point
  7    hit = self.physics.raycast(ray.origin, ray.direction, distance=40.0)
  8
  9``Camera3D.ray_at`` turns a screen position into a ``Ray`` starting on the
 10camera's near plane. ``self.physics`` is the physics world of the nearest
 11``PhysicsRoot`` ancestor; with none in the scene, as here, it is the tree's
 12default world, where every body lands. ``raycast`` returns the nearest
 13``RayHit`` (``node``, ``point``, ``normal``, ``distance``) or ``None`` on a miss.
 14
 15Each cube is a static ``PhysicsBody3D`` whose ``shape`` is a ``BoxShape3D``. A
 16primary press (left mouse, a tap, a pen tip) casts the ray: the cube it hits
 17flashes yellow and fades back to its own colour with ``Tween.colour``, and the
 18status line reports the hit point and distance. ``DebugDraw`` is immediate
 19mode, so the scene redraws the last ray every frame for a moment and a sphere
 20marks the hit; a miss draws the ray at full length.
 21
 22This is the manual approach. For the engine's built-in pointer picking
 23(``CollisionShape3D(pickable=True)`` and ``on_picked``), see ``picking.py``.
 24"""
 25
 26import numpy as np
 27
 28from simvx.core import (
 29    Anchor,
 30    BodyMode,
 31    BoxShape3D,
 32    Camera3D,
 33    CanvasLayer,
 34    DirectionalLight3D,
 35    Label,
 36    Material,
 37    Mesh,
 38    MeshInstance3D,
 39    Node,
 40    PhysicsBody3D,
 41    Pointer,
 42    Tween,
 43    on_input,
 44)
 45from simvx.graphics import App
 46from simvx.graphics.debug_draw import DebugDraw
 47
 48RAY_LENGTH = 40.0
 49RAY_HOLD = 1.5  # seconds the last ray stays drawn
 50FLASH_TIME = 0.5
 51YELLOW = (1.0, 0.9, 0.2, 1.0)
 52GREEN = (0.2, 1.0, 0.2, 1.0)
 53
 54
 55class RayCube(PhysicsBody3D):
 56    """A static unit box: the body's shape is what the ray hits, the mesh child is what you see."""
 57
 58    def __init__(self, colour, **kwargs):
 59        super().__init__(mode=BodyMode.STATIC, shape=BoxShape3D(half_extents=(0.5, 0.5, 0.5)), **kwargs)
 60        self.colour = colour
 61        self.fade = None
 62
 63    def on_ready(self):
 64        paint = Material(colour=self.colour, roughness=0.5, metallic=0.1)
 65        self.mesh = self.add_child(MeshInstance3D(mesh=Mesh.box(), material=paint))
 66
 67    def flash(self):
 68        if self.fade:
 69            self.fade.cancel()
 70        self.mesh.material.colour = YELLOW
 71        self.fade = self.start_coroutine(Tween.colour(self.mesh.material, self.colour, FLASH_TIME, attr="colour"))
 72
 73
 74class RaycastScene(Node):
 75    def on_ready(self):
 76        self.camera = self.add_child(Camera3D(position=(8, 10, 14), fov=55, look_at=(0, 0, 0)))
 77        self.add_child(DirectionalLight3D(intensity=1.4, colour=(1.0, 0.96, 0.88), direction=(-0.4, -1.0, -0.6)))
 78        ground = Material(colour=(0.25, 0.26, 0.28, 1.0), roughness=0.9)
 79        self.add_child(MeshInstance3D(mesh=Mesh.box((40, 0.1, 40)), material=ground, position=(0, -1.1, 0)))
 80
 81        rng = np.random.default_rng(7)
 82        for x in range(-4, 5, 2):
 83            for z in range(-4, 5, 2):
 84                r, g, b = rng.uniform((0.3, 0.3, 0.4), (0.8, 0.8, 0.9))
 85                self.add_child(RayCube(colour=(r, g, b, 1.0), position=(x, 0, z)))
 86
 87        hud = self.add_child(CanvasLayer())
 88        hud.add_child(Label("Click or tap to cast a ray", anchor=Anchor.TOP_LEFT, inset=10, font_size=22))
 89        self.status = hud.add_child(Label("No ray cast yet", anchor=Anchor.BOTTOM_LEFT, inset=10, font_size=18))
 90
 91        self.ray_start = self.ray_end = self.hit_point = None
 92        self.ray_left = 0.0
 93
 94    @on_input(Pointer.PRIMARY)
 95    def cast(self, event):
 96        ray = self.camera.ray_at(event.position)
 97        hit = self.physics.raycast(ray.origin, ray.direction, distance=RAY_LENGTH)
 98        self.ray_start, self.ray_left = ray.origin, RAY_HOLD
 99        if hit:
100            hit.node.flash()
101            self.ray_end = self.hit_point = hit.point
102            x, y, z = hit.point
103            self.status.text = f"Hit at ({x:.2f}, {y:.2f}, {z:.2f})  distance {hit.distance:.2f}"
104        else:
105            self.ray_end, self.hit_point = ray.at(RAY_LENGTH), None
106            self.status.text = "No hit"
107
108    def on_update(self, dt):
109        self.ray_left -= dt
110        if self.ray_left > 0:
111            DebugDraw.line(self.ray_start, self.ray_end, colour=GREEN)
112            if self.hit_point is not None:
113                DebugDraw.sphere(self.hit_point, 0.15, colour=YELLOW)
114
115
116if __name__ == "__main__":
117    App(title="Raycast", width=1280, height=720).run(RaycastScene())