Raycast¶
click a cube to cast a ray through the pointer and see where it lands.
▶ Run in browserTags: 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())