14. The Third Dimension
Our 2D collision system, entity management, and movement logic work perfectly within their constraints. But extending these systems to operate in 3D space opens up new possibilities for level design, visual presentation, and spatial gameplay mechanics.
Today we're taking our existing 2D architecture and beginning the systematic extension to handle 3D coordinates, 3D collision detection, and 3D rendering whilst preserving the core logic we've already built.
The Fractal Code Cycle Applied
Recall our fundamental pattern:
Input → Processing → Output
For extending our systems to 3D:
- Input: 2D movement input mapped to 3D space, 3D entity positions, 3D collision geometry
- Processing: Extend existing algorithms to handle Vec3 instead of Vec2, adapt collision detection for 3D shapes (next)
- Output: 3D positioned entities, 3D collision responses, 3D rendered output
Coordinate System Extension: Vec2 to Vec3
The fundamental change is extending our coordinate system from 2D to 3D:
// Before: 2D coordinates
Entity :: struct {
position: Vec2,
// ...
}
// After: 3D coordinates
Entity :: struct {
position: Vec3,
// ...
}
This change propagates through our entire system, but in predictable and manageable ways due to our modular architecture.
Movement System Extension
Input Mapping to 3D Space
Our 2D input system extends naturally to 3D by mapping screen-relative directions to world axes (note the reversed signs):
// 2D movement (before)
if gs.input.up do move_dir.y -= 1
if gs.input.down do move_dir.y += 1
if gs.input.left do move_dir.x -= 1
if gs.input.right do move_dir.x += 1
// 3D movement (after)
if gs.input.up do move_dir.y += 1 // Maps to positive Z
if gs.input.down do move_dir.y -= 1 // Maps to negative Z
if gs.input.left do move_dir.x += 1 // Maps to positive X
if gs.input.right do move_dir.x -= 1 // Maps to negative X
Movement Vector Application
The movement calculation extends from 2D to 3D by mapping the 2D input vector to the XZ plane:
player_movement := rl.Vector2Normalize(move_dir) * player.movement_speed * gs.time.delta
next_player_position := player.position + Vec3{player_movement.x, 0, player_movement.y}
This preserves the Y coordinate (height) whilst applying movement in the horizontal plane.
Camera System Extension
From 2D to 3D Camera
The camera system transitions from 2D screen mapping to 3D perspective projection:
// Before: 2D camera
camera: rl.Camera2D,
// After: 3D camera with fixed perspective
camera: rl.Camera3D,
camera_base_offset: Vec3,
camera_rotation_y: f32,
Fixed Perspective Implementation
The 3D camera maintains a consistent viewing angle:
gs.camera_base_offset = Vec3{0, 4, -5}
gs.camera.up = Vec3{0, 1, 0}
gs.camera.projection = .PERSPECTIVE
gs.camera.fovy = 45
Camera Following Logic
The camera follows the player using 3D position calculations:
Camera rotation is for our debug purposes only. The game design dictates a fixed-angle camera.
rotation_matrix := rl.MatrixRotateY(gs.camera_rotation_y)
rotated_offset := rl.Vector3Transform(gs.camera_base_offset, rotation_matrix)
gs.camera.target = player.position
gs.camera.position = gs.camera.target + rotated_offset
This demonstrates matrix transformation for 3D positioning whilst maintaining the follow camera behaviour from our 2D system.
Collision System Extension: 2D to 3D
Collider Evolution
Player collision extends from 2D circles to 3D spheres:
// Collision detection conceptually unchanged
// Circle becomes sphere, but distance-based calculations remain the same
collider_radius: f32, // Same field, now represents sphere radius
Static Geometry Extension
Our OBB (Oriented Bounding Box) system extends to OBB3:
Keeping
OBBas a 2D version ofOBB3in case we want it for UI.
// 2D OBB (before)
OBB :: struct {
position: Vec2,
size: Vec2,
rotation: f32,
debug_color: rl.Color,
}
// 3D OBB (after)
OBB3 :: struct {
center: Vec3,
size: Vec3,
rotation: Vec3, // Now supports rotation around all three axes
}
This demonstrates systematic extension: each 2D concept gains a third dimension whilst preserving the underlying structure.
Entity Positioning in 3D
Ground Plane Positioning
Entities now require proper positioning relative to the ground plane:
At the moment, the player sits just at the edge of the ground plane.
A solution to sloped movement will be incorporated in a future lesson.
player.movement_speed = 4 // Scaled for 3D units
player.collider_radius = 0.25 // Appropriate for 3D scale
player.position = Vec3{0, player.collider_radius * 0.5, 0} // Sits on ground
The Y coordinate represents height, with entities positioned so their collision sphere rests on the Y=0 ground plane.
Rendering System Extension
3D Debug Rendering
Our debug rendering extends to handle 3D geometry:
// 3D grid for spatial reference
rl.DrawGrid(10, 1)
// 3D collision sphere
rl.DrawSphereWires(player.position, player.collider_radius, 8, 8, rl.GREEN)
// 3D bounding boxes
for obb in gs.physics.static_geometry {
draw_obb3(obb, rl.GRAY)
}
OBB3 Rendering
The system optimises rendering based on box orientation:
is_obb3_axis_aligned :: proc(obb: OBB3) -> bool {
EPSILON :: 0.001
x_aligned := abs(math.mod(obb.rotation.x, math.PI / 2)) < EPSILON
y_aligned := abs(math.mod(obb.rotation.y, math.PI / 2)) < EPSILON
z_aligned := abs(math.mod(obb.rotation.z, math.PI / 2)) < EPSILON
return x_aligned && y_aligned && z_aligned
}
Axis-aligned boxes use the existing raylib procedure rl.DrawBoundingBox, whilst arbitrary orientations require custom corner calculation and line drawing.
System Architecture Benefits
Modular Extension
Our system stack architecture allows clean extension:
- Base Layer: Entity coordinates extend to Vec3
- Rendering Layer: Camera system rebuilt for 3D
- World Layer: Collision detection extended to 3D shapes
- Higher Layers: Remain unchanged due to abstraction
Code Reuse
The extension leverages existing patterns:
- Movement logic structure preserved
- Collision detection concepts maintained
- Debug rendering patterns extended
- Input handling architecture unchanged
Debug Controls for Development
Development controls allow system verification:
if gs.input.debug_rotate_cw {
gs.camera_rotation_y -= ROTATION_SPEED * gs.time.delta
}
if gs.input.debug_rotate_ccw {
gs.camera_rotation_y += ROTATION_SPEED * gs.time.delta
}
if gs.input.debug_rotate_reset {
gs.camera_rotation_y = 0
}
These controls enable verification of 3D positioning and collision detection from multiple viewpoints.
Input Changes
New fields are added to the input struct:
debug_rotate_cw, debug_rotate_ccw, debug_rotate_reset: bool,
The input procedure is rewritten to use a procedure from core:mem to zero the inputs, setting them to false:
input :: proc() {
mem.zero_item(&gs.input)
if rl.IsKeyDown(.W) do gs.input.up = true
if rl.IsKeyDown(.S) do gs.input.down = true
if rl.IsKeyDown(.A) do gs.input.left = true
if rl.IsKeyDown(.D) do gs.input.right = true
if rl.IsKeyDown(.Q) do gs.input.debug_rotate_cw = true
if rl.IsKeyDown(.E) do gs.input.debug_rotate_ccw = true
if rl.IsKeyDown(.R) do gs.input.debug_rotate_reset = true
}
Extension vs Rewrite Philosophy
Extension isn't always possible, though when it is, the benefits are many:
- Preserve working logic: Keep movement calculations, just add dimensions
- Extend data structures: Vec2 becomes Vec3, OBB becomes OBB3
- Maintain interfaces: Higher-level systems see same access patterns
- Add capabilities: 3D rendering and collision without breaking existing features
The Big Idea
Systematic extension of 2D systems to 3D demonstrates the power of good architecture. By building modular systems with clear interfaces, we can add entire new dimensions (literally) without rewriting our core game logic.
The coordinate system change from Vec2 to Vec3 propagates predictably through our codebase, and our collision detection concepts translate directly from circles and rectangles to spheres and boxes.
This extension approach allows us to leverage everything we've built whilst opening up new possibilities for spatial gameplay and visual presentation.