bevy
bevy copied to clipboard
Ray Casting for Primitive Shapes
Objective
Closes #13618.
Add ray casting support for Bevy's primitive 2D and 3D shapes.
The scope here is to support the following:
- Test if a local ray intersects a given shape.
- Compute the distance to the closest point of intersection along a local ray.
- Compute the distance and normal at the point of intersection.
- World-space versions of the above, taking an isometry to transform the shape.
These should be supported for all of Bevy's primitive shapes, except where unreasonable, such as for 3D lines, which are infinitely thin.
The following are not in scope here:
- Cast a ray into the world to query all shapes for intersections.
- Speed up ray queries for a set of shapes using an acceleration structure such as a Bounding Volume Hierarchy (BVH).
- Get the feature ID of the hit facet (vertex, edge, or face). This could be added later for some polyhedral shapes if desired.
The goal is purely to provide the core tools and implementations for performing efficient ray casts on individual shapes. Abstractions can be built on top by users and third party crates, and eventually Bevy itself once it has first-party colliders and physics.
Solution
Add PrimitiveRayCast2d and PrimitiveRayCast3d traits with the following methods:
impl PrimitiveRayCast2d {
fn intersects_local_ray(&self, ray: Ray2d) -> bool;
fn intersects_ray(&self, iso: Isometry2d, ray: Ray2d) -> bool;
fn local_ray_distance(&self, ray: Ray2d, max_distance: f32, solid: bool) -> Option<f32>;
fn ray_distance(&self, iso: Isometry2d, ray: Ray2d, max_distance: f32, solid: bool) -> Option<f32>;
fn local_ray_cast(&self, ray: Ray2d, max_distance: f32, solid: bool) -> Option<RayHit2d>;
fn ray_cast(&self, iso: Isometry2d, ray: Ray2d, max_distance: f32, solid: bool) -> Option<RayHit2d>;
}
// ...and similarly for `PrimitiveRayCast3d`
where RayHit2d looks like this:
// Note: We could store the point of intersection too,
// but it is easily computed as `ray.get_point(distance)`.
pub struct RayHit2d {
pub distance: f32,
pub normal: Dir2,
}
// ...and similarly for `RayHit3d`
Usage then looks like this:
let ray = Ray3d::new(Vec3::new(-1.0, 0.0, 0.0), Vec3::X);
let sphere = Sphere::new(1.0);
let iso = Isometry3d::from_translation(Vec3::new(1.0, 0.0, 0.0));
let max_distance = f32::MAX;
let solid = true;
if let Some(hit) = sphere.ray_cast(iso, ray, max_distance, solid) {
assert_eq!(hit.distance, 1.0);
assert_eq!(hit.normal, Dir3::NEG_X);
assert_eq!(ray.get_point(hit.distance), Vec3::ZERO);
}
Names are open for bikeshedding. I chose PrimitiveRayCastNd because we already have RayCastNd structs, and these traits are intended to return only the most minimal data required to represent an intersection and its geometry efficiently. Other APIs could be built on top to return more data if desired.
Let's go over a few relevant features and implementation details.
Solid and Hollow Shapes
The ray casting methods (excluding intersection tests) have a solid boolean argument. It controls how rays cast from the interior of a shape behave. If true, the ray cast will terminate with a distance of zero as soon as the ray origin is detected to be inside of the shape. Otherwise, the ray will travel until it hits the boundary.
This feature has somewhat unclear utility. One valid use case is determining how far a shape extends in some given direction, which could be used to figure out e.g. how far away an object picked up by the player should be held. Or maybe you have a circular room, and want to cast rays against its walls from the inside without discretizing the circle to be formed out of multiple separate shapes.
Some hollow shapes can actually be handled in most cases without this built-in support, by simply performing another ray cast in the opposite direction from outside the shape if the ray origin is detected to be inside of the shape. However, for shapes like annuli and tori, the amount by which to offset the ray origin isn't obvious, and doing two ray casts is also more expensive than just having built-in support.
For prior art, Parry has the same boolean argument, and Box2D also supports hollow shapes, although in Box2D's case these are just handled by using chain (i.e. polyline) shapes.
Local and World Space Ray Casts
Each method has a local and world space variant. Practically all ray casts should be performed in local space, since it makes the algorithms significantly more straightforward.
The world-space versions are primarily a user-facing abstraction, which actually just transforms the ray into local space and then transform the results back to world space.
Discussion
Do we want this yet?
Bevy doesn't have built-in physics or collision detection yet. Should we have something like ray casting?
I believe having ray casting support for Bevy's primitive shapes could be amazing for users, even without first-party colliders. So far, the answer to "how do I do ray casts?" has basically been "try to use Parry, or just use Avian or bevy_rapier for nicer ergonomics and to avoid having to deal with Nalgebra". While this PR doesn't add APIs or tools to cast rays into the world like physics engines do, it does provide a foundation on top of which both users and crates could build their own lightweight APIs. We could even extend this to perform ray casts on meshes in the future (and as part of a mesh picking backend).
I don't think we should necessarily build an entire collision detection and geometry library in the background and upstream it all at once, but rather build it out incrementally in logical chunks. I think ray casting is a perfect candidate for this. This could be released in a third party crate too, but I think it's something that could have immediate upstream value to users.
Does it make sense to have this in bevy_math?
We don't have a better place yet. I expect us to add more similar queries (like point queries) over time. I think we should add this in bevy_math for now, and split things out as we reach critical mass for geometry functionality.
Naming
One potentially contentious part is the naming. Should we go for RayCast or Raycast?
If we do a little statistical research on popular physics and game engines for prior art (only ones I found clearly):
- Box2D uses
RayCast - Parry (and Avian and Rapier) uses
RayCast - Jolt uses
RayCast - Bepu uses
RayCast - Godot uses
RayCast - Unity uses
Raycast - Bullet uses
Raycast - PhysX uses
Raycast
There is no clear consensus, but in my experience, I've seen RayCast a lot more. That may be because I've seen Box2D, Rapier, and Godot more however. Many other people may be more familiar with Raycast because of Unity and its popularity.
Personally, I prefer RayCast especially in the context of other types of casts existing. In my opinion, shape casts look more strange when combined as one word, Shapecast, as opposed to ShapeCast. Bevy will eventually have shape casts as well, and I think we should be consistent in naming there.
It is also worth noting that the usage of "ray cast" vs. "raycast" vs. "ray-cast" is wildly inconsistent in many engines and literature, even if code uses one form consistently. Godot's docs have all three forms on one page, and so does Wikipedia. I would personally prefer if we followed Box2D's example here: code uses RayCast and ShapeCast, and documentation also uses this naming consistently as "ray cast" and "shape cast".
Split into smaller PRs
This is a very large PR with quite a lot of math-heavy code. I could split this up into several smaller PRs if it would help reviewers.
Maybe something like:
- Add core traits
- Implement for
Circle,Sphere,Ellipse, andAnnulus - Implement for
Arc2d,CircularSector, andCircularSegment - Implement for
RectangleandCuboid - Implement for
Capsule2dandCapsule3d - Implement for
Line2dandSegment2d - Implement for
Triangle2d,Rhombus, polygons, and polylines - Implement for
Triangle3d - Implement for
Tetrahedron - Implement for
Cone,ConicalFrustum, andCylinder - Implement for
Torus
That would be 11 PRs 😬
Or, I can just have this one mega PR. I'm fine with whatever reviewers would prefer.
Testing
Every primitive shape that supports ray casting has basic tests for various different cases like outside hits, inside hits, and missed hits. The shared world-space versions of the methods also have basic tests.
There are also new ray_cast_2d and ray_cast_3d examples to showcase ray casting for the various primitive shapes. These function as good hands-on tests. You can see videos of these in the "Showcase" section.
Performance
Below are mean times for 100,000 ray casts, with randomized shape definitions (within specified ranges) and rays. I am using the Parry collision detection library for comparison, as it is currently the most popular and feature-complete option in the Rust ecosystem. Pay attention to whether units are microseconds or milliseconds!
Shapes marked as "-" don't have a built-in ray casting implementation in Parry, and would require using e.g. convex hulls or compound shapes.
2D:
| Shape | Our implementation | Parry |
|---|---|---|
| Circle | 535.88 μs | 585.60 μs |
| Circular arc | 708.05 μs | - |
| Circular sector | 3.5025 ms | - |
| Circular segment | 3.3423 ms | - |
| Ellipse | 487.78 μs | - |
| Annulus | 631.67 μs | - |
| Capsule | 1.8968 ms | 10.729 ms |
| Rectangle | 566.97 μs | 1.7967 ms |
| Rhombus | 2.8950 ms | - |
| Line | 605.50 μs | - |
| Line segment | 721.86 μs | 1.7208 ms |
| Regular polygon | 6.1786 ms | - |
| Triangle | 3.4512 ms | 4.6280 ms |
3D:
| Shape | Our implementation | Parry |
|---|---|---|
| Sphere | 521.23 μs | 534.39 μs |
| Cuboid | 496.98 μs | 1.1455 ms |
| Cylinder | 1.5770 ms | 6.8190 ms |
| Cone | 1.9658 ms | 6.4109 ms |
| Conical frustum | 2.1117 ms | - |
| Capsule | 721.44 μs | 9.8425 ms |
| Triangle | 1.0066 ms | 1.7841 ms |
| Tetrahedron | 3.3739 ms | - |
| Torus | 506.93 μs | - |
As you can see, all of our implementations outperform those found in Parry, and we also have many more supported shapes. Of course Parry also has its own shapes that we don't have yet, like triangle meshes, heightfields, and half-spaces.
Why so much faster? For shapes like capsules, cylinders, and cones, Parry actually doesn't have custom analytic solutions. Instead, it uses a form of GJK-based ray casting. This works for arbitrary shapes with support maps, but is less efficient and robust than the analytic solutions I implemented. Sometimes Parry's approach even completely misses ray hits for shapes like capsules because of the algorithm failing to converge with the way it's configured in Parry.
For other shapes like balls and cuboids, it's harder to say, but I did find several simplifications and ways to make CPU go brrr in comparison to Parry, and spent time micro-optimizing common shapes like Triangle3d in particular. It could also just be that Glam is simply faster in some cases here.
Showcase
Below are the new ray_cast_2d and ray_cast_3d examples. Keep in mind that this does not involve mesh picking of any kind: each object here stores a component that stores the underlying primitive shape, and then a system performs ray casts.
https://github.com/user-attachments/assets/c871a501-54a5-4e5e-93b3-7c868a5267a6
https://github.com/user-attachments/assets/6a667c25-e40a-4a55-9e15-4bdd041f15c1
Acknowledgments
Thank you @Olle-Lukowski for creating initial versions of a lot of the 2D ray casting implementations in bevy_contrib_raycast! They worked wonderfully as a base to build on top of.
Related: #13618
This is my favorite PR for 0.16 :) thanks for implementing this, this is great!