18. 3d Collision Response
In our previous lesson, we built a debug drawing system to visualise collision shapes and cross-sections. We can see when our sphere intersects with oriented bounding boxes, but we haven't implemented any actual collision response. The sphere just passes through everything!
Now it's time to make our collision detection meaningful by implementing proper collision response.
The Fractal Code Cycle Applied to Collision Response
Recall our fundamental pattern:
Input → Processing → Output
For collision response:
- Input: Current position, desired movement, collision geometry
- Processing: Detect collisions and calculate valid positions
- Output: Final position that respects collision boundaries
From Detection to Response
Our previous work gave us check_sphere_vs_obb3 which tells us if a collision occurs. Now we need to determine what to do about it.
The naive approach would be to simply prevent movement when a collision is detected. But this creates terrible gameplay - players get "stuck" on corners and can't slide along walls.
The 2D Foundation
Remember our 2D circle_vs_rect_response function from earlier lessons? It calculates the minimum vector needed to push a circle out of a rectangle. We'll use this as the foundation for our 3D response.
The key insight: a sphere colliding with a box in 3D can be decomposed into three 2D circle-vs-rectangle problems.
Planar Decomposition Strategy
We handle collision response by checking three perpendicular planes:
- XY Plane: Top-down view (horizontal movement)
- XZ Plane: Side view (forward/back with vertical walls)
- ZY Plane: Front view (sideways with vertical walls)
For each plane, we:
- Transform the sphere position to the OBB's local space
- Create a 2D cross-section of the OBB
- Apply 2D circle-vs-rectangle collision response
- Transform the result back to world space
The Iterative Solution
Here's the problem: resolving one collision might push us into another. Imagine being pushed into a corner - fixing collision with one wall pushes you into the adjacent wall.
The solution is iterative refinement:
MAX_COLLISION_PASSES :: 8
do_obb_collisions_xy :: proc(center: Vec3, radius: f32, obbs: []OBB3) -> Vec3 {
result := center
did_collide := false
for _ in 0..<MAX_COLLISION_PASSES {
for obb in obbs {
if check_sphere_vs_obb3(result, radius, obb) {
// Transform to local space
rotation_matrix := rl.MatrixRotateXYZ(obb.rotation)
inverse_matrix := rl.MatrixTranspose(rotation_matrix)
local := result - obb.center
local = rl.Vector3Transform(local, inverse_matrix)
// Create 2D cross-section
cross_section := Rect{
-obb.size.x / 2,
-obb.size.y / 2,
obb.size.x,
obb.size.y,
}
// Skip if we're inside (shouldn't happen)
is_inside := rl.CheckCollisionPointRec(local.xy, cross_section)
if is_inside do continue
// Apply 2D collision response
local_xy := circle_vs_rect_response(local.xy, radius, cross_section)
local = Vec3{local_xy[0], local_xy[1], local.z}
// Transform back to world space
world := rl.Vector3Transform(local, rotation_matrix)
world += obb.center
if world != result {
result = world
did_collide = true
}
}
}
if !did_collide do break // No more collisions
}
return result
}
Handling All Three Dimensions
We implement similar functions for the XZ and ZY planes. The only differences are which components we use for the cross-section:
- XY: Uses
local.xyandobb.size.xy - XZ: Uses
local.xzandobb.size.xz - ZY: Uses
local.zyandobb.size.zy
Then we apply them in sequence:
physics_calculate_next_position :: proc(center: Vec3, radius: f32) -> Vec3 {
result := center
result = do_obb_collisions_xy(result, radius, gs.physics.static_geometry[:])
result = do_obb_collisions_xz(result, radius, gs.physics.static_geometry[:])
result = do_obb_collisions_zy(result, radius, gs.physics.static_geometry[:])
return result
}
Testing in Three Dimensions
To properly test our collision response, we've added vertical movement:
// In input struct
debug_y_up, debug_y_down: bool,
// In input()
if rl.IsKeyDown(.SPACE) do gs.input.debug_y_up = true
if rl.IsKeyDown(.LEFT_SHIFT) do gs.input.debug_y_down = true
// In update()
vert: f32
if gs.input.debug_y_up do vert -= 1
if gs.input.debug_y_down do vert += 1
next_player_position := player.position + Vec3{
player_movement.x,
vert * gs.time.delta,
player_movement.y
}
Now we can move our sphere in all three dimensions to test collision response from any angle.
Integrating with the Physics System
The physics system now actively modifies entity positions:
physics_update :: proc() {
for &entity in gs.entity.entities[1:] {
entity.position = physics_calculate_next_position(
entity.position,
entity.collider_radius
)
}
}
This ensures all entities (except the nil entity at index 0) have proper collision response.
Why This Approach Works
- Simplicity: We reuse our 2D collision algorithm rather than implementing complex 3D maths
- Robustness: Iterative refinement handles corners and multiple collisions naturally
- Performance: With a max iteration number, we can keep the performance costs relatively low
- Debugging: Each plane can be tested and debugged independently
Performance Considerations
The iterative approach might seem expensive, but:
- Early exit means most frames use minimal iterations
- MAX_COLLISION_PASSES prevents infinite loops
- For our scale (dozens of entities, handful of obstacles), the cost is negligible
The Big Idea
By decomposing 3D collision response into three 2D problems and applying iterative refinement, we've created a robust physics system using simple, understandable code.
Our spheres now slide smoothly along surfaces, handle corners gracefully, and never get stuck - essential for the exploration experience in our RPG. The collision system has evolved from mere detection to meaningful physical interaction with the game world.