Current section

Files

Jump to
bb lib bb collision primitives.ex
Raw

lib/bb/collision/primitives.ex

# SPDX-FileCopyrightText: 2025 James Harton
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.Collision.Primitives do
@moduledoc """
Collision detection algorithms for primitive geometry pairs.
All functions take world-space geometry (position + orientation applied)
and return either `{:collision, penetration_depth}` or `:no_collision`.
Penetration depth is the estimated overlap distance - how far the geometries
would need to be separated to no longer collide.
## Supported Geometry Types
- Sphere: `{:sphere, centre :: Vec3.t(), radius :: float()}`
- Capsule: `{:capsule, point_a :: Vec3.t(), point_b :: Vec3.t(), radius :: float()}`
- Box (OBB): `{:box, centre :: Vec3.t(), half_extents :: Vec3.t(), axes :: {Vec3.t(), Vec3.t(), Vec3.t()}}`
Cylinders are converted to capsules internally for simpler, more conservative collision detection.
"""
alias BB.Math.Vec3
@type sphere :: {:sphere, centre :: Vec3.t(), radius :: float()}
@type capsule :: {:capsule, point_a :: Vec3.t(), point_b :: Vec3.t(), radius :: float()}
@type box ::
{:box, centre :: Vec3.t(), half_extents :: Vec3.t(),
axes :: {Vec3.t(), Vec3.t(), Vec3.t()}}
@type geometry :: sphere() | capsule() | box()
@type collision_result :: {:collision, penetration_depth :: float()} | :no_collision
# ============================================================================
# Public API
# ============================================================================
@doc """
Test two geometries for collision.
Dispatches to the appropriate collision test based on geometry types.
Order of arguments doesn't matter - the function handles symmetry internally.
## Examples
iex> sphere1 = {:sphere, Vec3.new(0, 0, 0), 1.0}
iex> sphere2 = {:sphere, Vec3.new(1.5, 0, 0), 1.0}
iex> BB.Collision.Primitives.test(sphere1, sphere2)
{:collision, 0.5}
iex> sphere1 = {:sphere, Vec3.new(0, 0, 0), 1.0}
iex> sphere2 = {:sphere, Vec3.new(3.0, 0, 0), 1.0}
iex> BB.Collision.Primitives.test(sphere1, sphere2)
:no_collision
"""
@spec test(geometry(), geometry()) :: collision_result()
def test({:sphere, _, _} = a, {:sphere, _, _} = b), do: sphere_sphere(a, b)
def test({:capsule, _, _, _} = a, {:capsule, _, _, _} = b), do: capsule_capsule(a, b)
def test({:box, _, _, _} = a, {:box, _, _, _} = b), do: box_box(a, b)
def test({:sphere, _, _} = a, {:capsule, _, _, _} = b), do: sphere_capsule(a, b)
def test({:capsule, _, _, _} = a, {:sphere, _, _} = b), do: sphere_capsule(b, a)
def test({:sphere, _, _} = a, {:box, _, _, _} = b), do: sphere_box(a, b)
def test({:box, _, _, _} = a, {:sphere, _, _} = b), do: sphere_box(b, a)
def test({:capsule, _, _, _} = a, {:box, _, _, _} = b), do: capsule_box(a, b)
def test({:box, _, _, _} = a, {:capsule, _, _, _} = b), do: capsule_box(b, a)
@doc """
Test two geometries with an additional margin/padding.
The margin is added to both geometries, effectively expanding them.
Useful for detecting "near misses" or adding safety buffers.
"""
@spec test_with_margin(geometry(), geometry(), margin :: float()) :: collision_result()
def test_with_margin(a, b, margin) when margin > 0 do
a_expanded = expand_geometry(a, margin)
b_expanded = expand_geometry(b, margin)
test(a_expanded, b_expanded)
end
def test_with_margin(a, b, _margin), do: test(a, b)
# ============================================================================
# Sphere-Sphere Collision
# ============================================================================
@doc """
Test collision between two spheres.
Two spheres collide if the distance between their centres is less than
the sum of their radii.
"""
@spec sphere_sphere(sphere(), sphere()) :: collision_result()
def sphere_sphere({:sphere, c1, r1}, {:sphere, c2, r2}) do
distance = Vec3.distance(c1, c2)
sum_radii = r1 + r2
if distance < sum_radii do
{:collision, sum_radii - distance}
else
:no_collision
end
end
# ============================================================================
# Capsule-Capsule Collision
# ============================================================================
@doc """
Test collision between two capsules.
Two capsules collide if the closest distance between their line segments
is less than the sum of their radii.
"""
@spec capsule_capsule(capsule(), capsule()) :: collision_result()
def capsule_capsule({:capsule, a1, b1, r1}, {:capsule, a2, b2, r2}) do
{_closest1, _closest2, distance} = closest_points_segments(a1, b1, a2, b2)
sum_radii = r1 + r2
if distance < sum_radii do
{:collision, sum_radii - distance}
else
:no_collision
end
end
# ============================================================================
# Sphere-Capsule Collision
# ============================================================================
@doc """
Test collision between a sphere and a capsule.
A sphere and capsule collide if the closest distance from the sphere's
centre to the capsule's line segment is less than the sum of their radii.
"""
@spec sphere_capsule(sphere(), capsule()) :: collision_result()
def sphere_capsule({:sphere, centre, r_sphere}, {:capsule, cap_a, cap_b, r_capsule}) do
{_closest, distance} = closest_point_on_segment(centre, cap_a, cap_b)
sum_radii = r_sphere + r_capsule
if distance < sum_radii do
{:collision, sum_radii - distance}
else
:no_collision
end
end
# ============================================================================
# Sphere-Box (OBB) Collision
# ============================================================================
@doc """
Test collision between a sphere and an oriented bounding box.
The sphere collides with the box if the closest point on the box
to the sphere's centre is within the sphere's radius.
"""
@spec sphere_box(sphere(), box()) :: collision_result()
def sphere_box({:sphere, centre, radius}, {:box, box_centre, half_extents, axes}) do
{_closest, distance} = closest_point_on_box(centre, box_centre, half_extents, axes)
if distance < radius do
{:collision, radius - distance}
else
:no_collision
end
end
# ============================================================================
# Capsule-Box Collision
# ============================================================================
@doc """
Test collision between a capsule and an oriented bounding box.
Finds the closest distance between the capsule's line segment and the box,
then checks if it's less than the capsule's radius.
"""
@spec capsule_box(capsule(), box()) :: collision_result()
def capsule_box({:capsule, cap_a, cap_b, radius}, {:box, box_centre, half_extents, axes}) do
distance = closest_distance_segment_box(cap_a, cap_b, box_centre, half_extents, axes)
if distance < radius do
{:collision, radius - distance}
else
:no_collision
end
end
# ============================================================================
# Box-Box (OBB) Collision using Separating Axis Theorem
# ============================================================================
@doc """
Test collision between two oriented bounding boxes using the Separating Axis Theorem.
Two convex shapes are separated if there exists an axis along which their
projections don't overlap. For two OBBs, we need to test 15 potential
separating axes:
- 3 face normals from box A
- 3 face normals from box B
- 9 cross products of edges from A and B
"""
@spec box_box(box(), box()) :: collision_result()
def box_box({:box, c1, h1, {a1x, a1y, a1z}}, {:box, c2, h2, {a2x, a2y, a2z}}) do
# Vector from centre of box1 to centre of box2
t = Vec3.subtract(c2, c1)
# Half extents as floats
{h1x, h1y, h1z} = {Vec3.x(h1), Vec3.y(h1), Vec3.z(h1)}
{h2x, h2y, h2z} = {Vec3.x(h2), Vec3.y(h2), Vec3.z(h2)}
# All axes to test
axes = [
# Face normals of box 1
a1x,
a1y,
a1z,
# Face normals of box 2
a2x,
a2y,
a2z,
# Cross products of edges
Vec3.cross(a1x, a2x),
Vec3.cross(a1x, a2y),
Vec3.cross(a1x, a2z),
Vec3.cross(a1y, a2x),
Vec3.cross(a1y, a2y),
Vec3.cross(a1y, a2z),
Vec3.cross(a1z, a2x),
Vec3.cross(a1z, a2y),
Vec3.cross(a1z, a2z)
]
box1_axes = {a1x, a1y, a1z}
box2_axes = {a2x, a2y, a2z}
box1_half = {h1x, h1y, h1z}
box2_half = {h2x, h2y, h2z}
# Find minimum penetration across all axes
min_penetration =
Enum.reduce_while(axes, :infinity, fn axis, min_pen ->
check_box_axis(axis, t, box1_axes, box1_half, box2_axes, box2_half, min_pen)
end)
case min_penetration do
:separated -> :no_collision
:infinity -> :no_collision
pen when is_float(pen) -> {:collision, pen}
end
end
# ============================================================================
# Helper Functions - Line Segment Operations
# ============================================================================
@doc """
Find the closest point on a line segment to a given point.
Returns `{closest_point, distance}`.
"""
@spec closest_point_on_segment(Vec3.t(), Vec3.t(), Vec3.t()) :: {Vec3.t(), float()}
def closest_point_on_segment(point, seg_a, seg_b) do
ab = Vec3.subtract(seg_b, seg_a)
ap = Vec3.subtract(point, seg_a)
ab_len_sq = Vec3.magnitude_squared(ab)
# Degenerate segment (point)
if ab_len_sq < 1.0e-10 do
{seg_a, Vec3.distance(point, seg_a)}
else
t = Vec3.dot(ap, ab) / ab_len_sq
t_clamped = max(0.0, min(1.0, t))
closest = Vec3.add(seg_a, Vec3.scale(ab, t_clamped))
{closest, Vec3.distance(point, closest)}
end
end
@doc """
Find the closest points between two line segments.
Returns `{closest_on_seg1, closest_on_seg2, distance}`.
Uses the algorithm from "Real-Time Collision Detection" by Christer Ericson.
"""
@spec closest_points_segments(Vec3.t(), Vec3.t(), Vec3.t(), Vec3.t()) ::
{Vec3.t(), Vec3.t(), float()}
def closest_points_segments(a1, b1, a2, b2) do
d1 = Vec3.subtract(b1, a1)
d2 = Vec3.subtract(b2, a2)
r = Vec3.subtract(a1, a2)
a = Vec3.dot(d1, d1)
e = Vec3.dot(d2, d2)
f = Vec3.dot(d2, r)
# Check for degenerate segments
cond do
a < 1.0e-10 and e < 1.0e-10 ->
# Both segments are points
{a1, a2, Vec3.distance(a1, a2)}
a < 1.0e-10 ->
# First segment is a point
{closest, dist} = closest_point_on_segment(a1, a2, b2)
{a1, closest, dist}
e < 1.0e-10 ->
# Second segment is a point
{closest, dist} = closest_point_on_segment(a2, a1, b1)
{closest, a2, dist}
true ->
closest_points_general_case(a1, d1, a2, d2, r, a, e, f)
end
end
defp closest_points_general_case(a1, d1, a2, d2, r, a, e, f) do
c = Vec3.dot(d1, r)
b = Vec3.dot(d1, d2)
denom = a * e - b * b
s = compute_initial_s(denom, b, f, c, e)
t = (b * s + f) / e
{s, t} = clamp_segment_params(s, t, a, b, c)
closest1 = Vec3.add(a1, Vec3.scale(d1, s))
closest2 = Vec3.add(a2, Vec3.scale(d2, t))
{closest1, closest2, Vec3.distance(closest1, closest2)}
end
defp compute_initial_s(denom, b, f, c, e) do
if abs(denom) < 1.0e-10 do
0.0
else
clamp((b * f - c * e) / denom, 0.0, 1.0)
end
end
defp clamp_segment_params(s, t, a, b, c) do
cond do
t < 0.0 -> {clamp(-c / a, 0.0, 1.0), 0.0}
t > 1.0 -> {clamp((b - c) / a, 0.0, 1.0), 1.0}
true -> {s, t}
end
end
# ============================================================================
# Helper Functions - Box Operations
# ============================================================================
@doc """
Find the closest point on an OBB to a given point.
Returns `{closest_point, distance}`.
"""
@spec closest_point_on_box(Vec3.t(), Vec3.t(), Vec3.t(), {Vec3.t(), Vec3.t(), Vec3.t()}) ::
{Vec3.t(), float()}
def closest_point_on_box(point, box_centre, half_extents, {ax, ay, az}) do
# Vector from box centre to point
d = Vec3.subtract(point, box_centre)
# Project onto each axis and clamp
{hx, hy, hz} = {Vec3.x(half_extents), Vec3.y(half_extents), Vec3.z(half_extents)}
dx = clamp(Vec3.dot(d, ax), -hx, hx)
dy = clamp(Vec3.dot(d, ay), -hy, hy)
dz = clamp(Vec3.dot(d, az), -hz, hz)
# Reconstruct closest point
closest =
box_centre
|> Vec3.add(Vec3.scale(ax, dx))
|> Vec3.add(Vec3.scale(ay, dy))
|> Vec3.add(Vec3.scale(az, dz))
{closest, Vec3.distance(point, closest)}
end
# ============================================================================
# Helper Functions - Capsule-Box
# ============================================================================
# Find the closest distance between a line segment and an OBB
defp closest_distance_segment_box(seg_a, seg_b, box_centre, half_extents, axes) do
# Sample points along the segment and find minimum distance to box
# This is an approximation - exact solution is more complex
num_samples = 8
0..num_samples
|> Enum.map(fn i ->
t = i / num_samples
point = Vec3.lerp(seg_a, seg_b, t)
{_closest, distance} = closest_point_on_box(point, box_centre, half_extents, axes)
distance
end)
|> Enum.min()
end
# ============================================================================
# Helper Functions - SAT (Separating Axis Theorem)
# ============================================================================
defp check_box_axis(axis, t, box1_axes, box1_half, box2_axes, box2_half, current_min) do
if Vec3.magnitude_squared(axis) < 1.0e-10 do
{:cont, current_min}
else
axis_normalized = Vec3.normalise(axis)
update_min_penetration(
axis_normalized,
t,
box1_axes,
box1_half,
box2_axes,
box2_half,
current_min
)
end
end
defp update_min_penetration(axis, t, box1_axes, box1_half, box2_axes, box2_half, current_min) do
case test_axis(axis, t, box1_axes, box1_half, box2_axes, box2_half) do
:separated -> {:halt, :separated}
{:overlap, pen} -> {:cont, min(current_min, pen)}
end
end
defp test_axis(axis, t, {a1x, a1y, a1z}, {h1x, h1y, h1z}, {a2x, a2y, a2z}, {h2x, h2y, h2z}) do
# Project the translation vector onto the axis
t_proj = abs(Vec3.dot(t, axis))
# Project box 1's half-extents onto the axis
r1 =
h1x * abs(Vec3.dot(a1x, axis)) +
h1y * abs(Vec3.dot(a1y, axis)) +
h1z * abs(Vec3.dot(a1z, axis))
# Project box 2's half-extents onto the axis
r2 =
h2x * abs(Vec3.dot(a2x, axis)) +
h2y * abs(Vec3.dot(a2y, axis)) +
h2z * abs(Vec3.dot(a2z, axis))
# Check for separation
if t_proj > r1 + r2 do
:separated
else
{:overlap, r1 + r2 - t_proj}
end
end
# ============================================================================
# Helper Functions - Geometry Expansion
# ============================================================================
defp expand_geometry({:sphere, centre, radius}, margin) do
{:sphere, centre, radius + margin}
end
defp expand_geometry({:capsule, a, b, radius}, margin) do
{:capsule, a, b, radius + margin}
end
defp expand_geometry({:box, centre, half_extents, axes}, margin) do
# Expand each half-extent by the margin
expanded =
Vec3.new(
Vec3.x(half_extents) + margin,
Vec3.y(half_extents) + margin,
Vec3.z(half_extents) + margin
)
{:box, centre, expanded, axes}
end
# ============================================================================
# Utility Functions
# ============================================================================
defp clamp(value, min_val, max_val) do
value |> max(min_val) |> min(max_val)
end
end