khora_core/math/cube.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Cubemap face primitives.
16//!
17//! [`CubeFace`] enumerates the six faces of an axis-aligned cube in the
18//! order wgpu expects when indexing a `texture_cube` / `texture_cube_array`:
19//!
20//! ```text
21//! 0: +X 1: -X 2: +Y 3: -Y 4: +Z 5: -Z
22//! ```
23//!
24//! It is the foundation for omnidirectional shadow mapping (point-light
25//! shadows), reflection probes, IBL, and any other code path that needs to
26//! enumerate or sample a cube's faces. The "up" vector returned by
27//! [`CubeFace::up`] matches the cubemap convention used by wgpu / Vulkan /
28//! D3D11 — the +Y / −Y faces use ±Z as their up vector to avoid the
29//! singularity where `look_at_rh(eye, eye + Y, Y)` is degenerate.
30
31use crate::math::Vec3;
32
33/// One of the six faces of an axis-aligned cube.
34///
35/// The discriminants are stable (`u8`) and match the wgpu cubemap face
36/// ordering, so [`CubeFace::index`] can be used directly to address a
37/// `texture_cube_array` layer set.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39#[repr(u8)]
40pub enum CubeFace {
41 /// `+X` face.
42 PosX = 0,
43 /// `-X` face.
44 NegX = 1,
45 /// `+Y` face.
46 PosY = 2,
47 /// `-Y` face.
48 NegY = 3,
49 /// `+Z` face.
50 PosZ = 4,
51 /// `-Z` face.
52 NegZ = 5,
53}
54
55impl CubeFace {
56 /// All six faces in wgpu cubemap order: `[+X, -X, +Y, -Y, +Z, -Z]`.
57 ///
58 /// Use this with `Iterator::map` or array `.map()` to produce per-face
59 /// data structures (view matrices, render passes, atlas slots…) in a
60 /// stable order that matches the GPU's expectation.
61 pub const ALL: [CubeFace; 6] = [
62 CubeFace::PosX,
63 CubeFace::NegX,
64 CubeFace::PosY,
65 CubeFace::NegY,
66 CubeFace::PosZ,
67 CubeFace::NegZ,
68 ];
69
70 /// Returns the layer index this face occupies inside a
71 /// `texture_cube_array` slice (`0..=5`).
72 #[inline]
73 pub const fn index(self) -> usize {
74 self as usize
75 }
76
77 /// World-space direction the rendering camera looks toward when
78 /// rasterising this face.
79 #[inline]
80 pub const fn forward(self) -> Vec3 {
81 match self {
82 CubeFace::PosX => Vec3::X,
83 CubeFace::NegX => Vec3::NEG_X,
84 CubeFace::PosY => Vec3::Y,
85 CubeFace::NegY => Vec3::NEG_Y,
86 CubeFace::PosZ => Vec3::Z,
87 CubeFace::NegZ => Vec3::NEG_Z,
88 }
89 }
90
91 /// World-space "up" direction matching the wgpu / Vulkan / D3D11
92 /// cubemap convention. The ±Y faces use ±Z as their up vector to keep
93 /// `look_at_rh(eye, eye + face.forward(), face.up())` non-degenerate.
94 #[inline]
95 pub const fn up(self) -> Vec3 {
96 match self {
97 CubeFace::PosX | CubeFace::NegX => Vec3::NEG_Y,
98 CubeFace::PosY => Vec3::Z,
99 CubeFace::NegY => Vec3::NEG_Z,
100 CubeFace::PosZ | CubeFace::NegZ => Vec3::NEG_Y,
101 }
102 }
103
104 /// Picks the face whose [`forward`](Self::forward) axis is most aligned
105 /// with `dir` (largest absolute component wins, sign chooses the
106 /// half-axis).
107 ///
108 /// This is the major-axis selection used in CPU-side cubemap sampling
109 /// debug paths and unit tests; the GPU equivalent runs in hardware
110 /// during `textureSampleCompareLevel(cube, ...)`.
111 pub fn from_direction(dir: Vec3) -> CubeFace {
112 let abs = dir.abs();
113 if abs.x >= abs.y && abs.x >= abs.z {
114 if dir.x >= 0.0 {
115 CubeFace::PosX
116 } else {
117 CubeFace::NegX
118 }
119 } else if abs.y >= abs.z {
120 if dir.y >= 0.0 {
121 CubeFace::PosY
122 } else {
123 CubeFace::NegY
124 }
125 } else if dir.z >= 0.0 {
126 CubeFace::PosZ
127 } else {
128 CubeFace::NegZ
129 }
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::math::EPSILON;
137
138 #[test]
139 fn all_faces_iterate_in_wgpu_order() {
140 let names: Vec<&str> = CubeFace::ALL
141 .iter()
142 .map(|f| match f {
143 CubeFace::PosX => "+X",
144 CubeFace::NegX => "-X",
145 CubeFace::PosY => "+Y",
146 CubeFace::NegY => "-Y",
147 CubeFace::PosZ => "+Z",
148 CubeFace::NegZ => "-Z",
149 })
150 .collect();
151 assert_eq!(names, vec!["+X", "-X", "+Y", "-Y", "+Z", "-Z"]);
152 }
153
154 #[test]
155 fn face_indices_match_wgpu_convention() {
156 assert_eq!(CubeFace::PosX.index(), 0);
157 assert_eq!(CubeFace::NegX.index(), 1);
158 assert_eq!(CubeFace::PosY.index(), 2);
159 assert_eq!(CubeFace::NegY.index(), 3);
160 assert_eq!(CubeFace::PosZ.index(), 4);
161 assert_eq!(CubeFace::NegZ.index(), 5);
162 // The ALL array's position must also match.
163 for (i, face) in CubeFace::ALL.iter().enumerate() {
164 assert_eq!(face.index(), i);
165 }
166 }
167
168 #[test]
169 fn forward_and_up_are_orthogonal() {
170 for face in CubeFace::ALL {
171 let f = face.forward();
172 let u = face.up();
173 assert!(
174 f.dot(u).abs() < EPSILON,
175 "face {face:?} has non-orthogonal forward/up: dot = {}",
176 f.dot(u)
177 );
178 // Both must be unit length.
179 assert!((f.length() - 1.0).abs() < EPSILON);
180 assert!((u.length() - 1.0).abs() < EPSILON);
181 }
182 }
183
184 #[test]
185 fn from_direction_round_trips_with_forward() {
186 for face in CubeFace::ALL {
187 assert_eq!(CubeFace::from_direction(face.forward()), face);
188 }
189 }
190
191 #[test]
192 fn from_direction_picks_major_axis() {
193 // Slightly off-axis vectors still pick the dominant face.
194 assert_eq!(
195 CubeFace::from_direction(Vec3::new(0.9, 0.1, 0.05)),
196 CubeFace::PosX
197 );
198 assert_eq!(
199 CubeFace::from_direction(Vec3::new(-0.9, 0.05, -0.1)),
200 CubeFace::NegX
201 );
202 assert_eq!(
203 CubeFace::from_direction(Vec3::new(0.1, 0.9, 0.05)),
204 CubeFace::PosY
205 );
206 assert_eq!(
207 CubeFace::from_direction(Vec3::new(0.05, -0.9, 0.1)),
208 CubeFace::NegY
209 );
210 assert_eq!(
211 CubeFace::from_direction(Vec3::new(0.1, 0.05, 0.9)),
212 CubeFace::PosZ
213 );
214 assert_eq!(
215 CubeFace::from_direction(Vec3::new(0.05, 0.1, -0.9)),
216 CubeFace::NegZ
217 );
218 }
219
220 #[test]
221 fn forward_directions_cover_all_six_axes() {
222 // Sanity: the six forward vectors are pairwise distinct.
223 let mut seen = std::collections::HashSet::new();
224 for face in CubeFace::ALL {
225 let f = face.forward();
226 // Encode as fixed-point so HashSet works on f32.
227 let key = (
228 (f.x * 1_000_000.0) as i64,
229 (f.y * 1_000_000.0) as i64,
230 (f.z * 1_000_000.0) as i64,
231 );
232 assert!(seen.insert(key), "duplicate forward direction for {face:?}");
233 }
234 assert_eq!(seen.len(), 6);
235 }
236}