When building 3D graphics applications, particularly when interfacing with GPU APIs like WGPU or OpenGL, having robust mathematical utilities is essential. This article examines the math utilities used by Mirador, covering matrix operations, vector mathematics, and coordinate system transformations specifically tailored for maze-based applications.
The mathematical structures in this codebase are designed with GPU compatibility as a primary concern. The Mat4
struct uses the #[repr(transparent)]
attribute and implements bytemuck::Pod
and bytemuck::Zeroable
traits, ensuring that the memory layout is predictable and can be safely transmitted to GPU buffers.
The matrix storage follows column-major ordering, which aligns with WGSL (WebGPU Shading Language) expectations. This means that what might intuitively be thought of as the first row is actually stored as the first column in memory:
Mat4([
[m00, m10, m20, m30], // First column
[m01, m11, m21, m31], // Second column
[m02, m12, m22, m32], // Third column
[m03, m13, m23, m33], // Fourth column
])
The Mat4
implementation provides several fundamental matrix constructors. The identity matrix serves as the mathematical neutral element for matrix multiplication:
Mat4([
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
])
The codebase implements both orthographic and perspective projection matrices, which are fundamental for 3D rendering pipelines.
The orthographic projection creates a parallel projection where objects maintain their size regardless of distance from the camera. The implementation maps a 3D rectangular region to the normalized device coordinate space:
Mat4([
[2.0 / (right - left), 0.0, 0.0, 0.0],
[0.0, 2.0 / (top - bottom), 0.0, 0.0],
[0.0, 0.0, 1.0 / (near - far), 0.0],
[
(right + left) / (left - right),
(top + bottom) / (bottom - top),
near / (near - far),
1.0,
],
])
The scaling factors 2.0 / (right - left)
and 2.0 / (top - bottom)
normalize the x and y coordinates to the range [-1, 1]. The z-coordinate scaling 1.0 / (near - far)
maps the depth range, while the translation components center the projection volume at the origin.
The perspective projection creates a more realistic view where distant objects appear smaller. The implementation uses the field of view approach:
let f = 1.0 / (field_of_view_y_in_radians * 0.5).tan();
let range_reciprocal = 1.0 / (z_near - z_far);
Mat4([
[f / aspect, 0.0, 0.0, 0.0],
[0.0, f, 0.0, 0.0],
[0.0, 0.0, z_far * range_reciprocal, -1.0],
[0.0, 0.0, z_far * z_near * range_reciprocal, 0.0],
])
The focal length f
is calculated from the field of view, and the aspect ratio correction ensures that the projection maintains proper proportions across different screen dimensions. The depth mapping follows OpenGL conventions with a range from -1 to 1.
Translation matrices move objects in 3D space without changing their orientation or scale. The implementation places the translation values in the fourth column:
Mat4([
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[tx, ty, tz, 1.0],
])
Scaling matrices modify the size of objects along each axis. The scaling factors are placed along the main diagonal:
Mat4([
[sx, 0.0, 0.0, 0.0],
[0.0, sy, 0.0, 0.0],
[0.0, 0.0, sz, 0.0],
[0.0, 0.0, 0.0, 1.0],
])
The rotation matrices implement rotations around each primary axis using trigonometric functions. Each rotation follows the right-hand rule, where positive angles represent counter-clockwise rotation when viewed from the positive axis direction.
For X-axis rotation:
let c = (deg_to_rad(angle_in_radians)).cos();
let s = (deg_to_rad(angle_in_radians)).sin();
Mat4([
[1.0, 0.0, 0.0, 0.0],
[0.0, c, -s, 0.0],
[0.0, s, c, 0.0],
[0.0, 0.0, 0.0, 1.0],
])
The Y-axis and Z-axis rotations follow similar patterns but with the trigonometric values positioned differently to affect the appropriate coordinate planes.
The matrix multiplication implementation uses the standard algorithm where each element of the result matrix is computed as the dot product of the corresponding row and column:
for (i, row) in result.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = (0..4).map(|k| b.0[i][k] * self.0[k][j]).sum();
}
}
The matrix inversion is optimized specifically for affine transformations, which are common in 3D graphics. The method extracts the 3x3 linear transformation part and the translation vector, then computes the inverse using the determinant and adjugate matrix approach. This optimization is valid because most graphics transformations preserve the affine structure where the last row remains [0, 0, 0, 1].
The Vec3
struct represents 3D vectors with similar GPU-compatible memory layout considerations. The implementation provides essential vector operations including dot product, cross product, normalization, and length calculation.
The dot product implementation follows the standard mathematical definition:
self.x() * other.x() + self.y() * other.y() + self.z() * other.z()
This operation is fundamental for calculating angles between vectors, projections, and lighting calculations in graphics programming.
The cross product creates a vector perpendicular to both input vectors, following the right-hand rule:
Vec3([
self.y() * other.z() - self.z() * other.y(),
self.z() * other.x() - self.x() * other.z(),
self.x() * other.y() - self.y() * other.x(),
])
This operation is essential for calculating surface normals, creating coordinate systems, and determining the orientation of three points in space.
Vector normalization converts a vector to unit length while preserving its direction. The implementation includes safety checks for zero-length vectors:
let length = self.length();
if length <= f32::EPSILON {
return Self([0.0, 0.0, 0.0]);
}
Self([self.x() / length, self.y() / length, self.z() / length])
The coordinate transformation system bridges the gap between discrete maze grid coordinates and continuous 3D world coordinates. The transformation accounts for maze centering and proper scaling.
The core transformation calculates the world position by first determining the cell size based on the largest maze dimension:
let max_dimension = maze_width.max(maze_height) as f32;
let cell_size = FLOOR_SIZE / max_dimension;
This approach ensures that the maze fits within a predefined floor size regardless of its actual grid dimensions. The origin offset centers the maze in world space:
let origin_x = -(maze_width as f32 * cell_size) / 2.0;
let origin_z = -(maze_height as f32 * cell_size) / 2.0;
The final world coordinates place each cell at its center rather than at grid intersections:
let world_x = origin_x + (cell.col as f32 + 0.5) * cell_size;
let world_z = origin_z + (cell.row as f32 + 0.5) * cell_size;
The inverse transformation converts world coordinates back to maze grid cells. This is essential for collision detection, pathfinding, and game logic that needs to determine which maze cell corresponds to a given world position.
The implementation reverses the transformation process:
let relative_x = position[0] - origin_x;
let relative_z = position[2] - origin_z;
let col = (relative_x / cell_size).floor() as usize;
let row = (relative_z / cell_size).floor() as usize;
Boundary clamping ensures that world positions outside the maze bounds are mapped to the nearest valid cell:
let col = col.min(maze_width - 1);
let row = row.min(maze_height - 1);
The direction system provides a clean abstraction for maze navigation. The Direction
enum represents the four cardinal directions, and the conversion functions bridge between different representations of orientation.
The yaw angle conversion normalizes arbitrary rotation angles to the 0-360 degree range and maps them to cardinal directions:
let normalized_yaw = ((yaw % 360.0) + 360.0) % 360.0;
match normalized_yaw as u32 {
315..=359 | 0..=45 => Direction::North,
46..=135 => Direction::East,
136..=225 => Direction::South,
226..=314 => Direction::West,
_ => Direction::North,
}
This approach provides 90-degree sectors for each cardinal direction, with North covering both the 315-360 and 0-45 degree ranges to handle the wraparound at 0/360 degrees.
The system can determine the primary direction between any two cells by comparing the magnitude of row and column differences:
let row_diff = to.row as isize - from.row as isize;
let col_diff = to.col as isize - from.col as isize;
if row_diff.abs() > col_diff.abs() {
if row_diff < 0 {
Some(Direction::North)
} else {
Some(Direction::South)
}
} else if col_diff.abs() > row_diff.abs() {
if col_diff > 0 {
Some(Direction::East)
} else {
Some(Direction::West)
}
} else {
None
}
This implementation prioritizes the axis with the larger displacement, returning None
for diagonal or identical positions where no clear cardinal direction applies.
The positioning utilities provide functions to identify specific maze locations such as corners and boundaries. These functions are particularly useful for maze generation algorithms, spawn point determination, and navigation logic.
The corner detection functions follow a consistent pattern, calculating positions based on maze dimensions:
fn get_bottom_right_cell(maze_dimensions: (usize, usize)) -> Cell {
let (maze_width, maze_height) = maze_dimensions;
Cell::new(maze_height - 1, maze_width - 1)
}
The adjacent cell calculation provides safe navigation within maze boundaries:
match direction {
Direction::North if row > 0 => Some(Cell::new(row - 1, col)),
Direction::South if row < maze_height - 1 => Some(Cell::new(row + 1, col)),
Direction::East if col < maze_width - 1 => Some(Cell::new(row, col + 1)),
Direction::West if col > 0 => Some(Cell::new(row, col - 1)),
_ => None,
}
This implementation combines direction-specific movement with boundary checking, returning None
for movements that would exceed maze boundaries rather than panicking or producing invalid coordinates.
These mathematical utilities form the foundation for a complete 3D maze navigation system. The matrix operations handle camera transformations and rendering pipeline setup, while the vector mathematics supports lighting calculations, collision detection, and physics simulations.
The coordinate transformation system enables seamless integration between game logic that operates on discrete maze cells and rendering systems that work with continuous world coordinates. This separation of concerns allows for clean code organization where maze generation algorithms can work with simple grid coordinates while rendering and physics systems operate in proper 3D space.
The directional navigation system provides the necessary abstractions for implementing maze-solving algorithms, player movement controls, and AI pathfinding. By standardizing on cardinal directions and providing robust conversion functions, the system supports various input methods while maintaining consistent internal representation.
This mathematical foundation demonstrates how careful attention to memory layout, coordinate system conventions, and API design can create utilities that are both mathematically correct and practically useful for real-world graphics programming applications.