Skip to main content

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}