PVG
18. 3d Collision Response — Program Video Games

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:

  1. XY Plane: Top-down view (horizontal movement)
  2. XZ Plane: Side view (forward/back with vertical walls)
  3. ZY Plane: Front view (sideways with vertical walls)

For each plane, we:

  1. Transform the sphere position to the OBB's local space
  2. Create a 2D cross-section of the OBB
  3. Apply 2D circle-vs-rectangle collision response
  4. 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.xy and obb.size.xy
  • XZ: Uses local.xz and obb.size.xz
  • ZY: Uses local.zy and obb.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

  1. Simplicity: We reuse our 2D collision algorithm rather than implementing complex 3D maths
  2. Robustness: Iterative refinement handles corners and multiple collisions naturally
  3. Performance: With a max iteration number, we can keep the performance costs relatively low
  4. 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.