Skip to main content

concinnity_core/render/decal/
record.rs

1//! The per-decal record the backends consume, and the matrix math behind it.
2//! A record is a unit cube spanning `[-0.5, 0.5]^3` placed by a TRS model
3//! matrix; the fragment shader pulls each reconstructed world-space sample
4//! back through the inverse and tests it against that box.
5
6use crate::components::Decal;
7use crate::gfx::transform::trs_matrix;
8use crate::math::sqrt;
9use alloc::vec::Vec;
10
11/// Per-decal data the renderer consumes each frame. Built once at
12/// `GraphicsSystem` init from the world's `Decal` components.
13///
14/// `model` is the local→world transform of a unit cube spanning `[-0.5, 0.5]^3`
15/// in local space. `inv_model` is its inverse; the fragment shader uses it to
16/// pull a reconstructed world-space sample point back into decal-local space
17/// and test it against the unit box. `texture_slot` indexes the renderer's
18/// albedo texture pool; `tint` is RGB×alpha applied to every projected sample.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct DecalRecord {
21    /// Model matrix, column-major.
22    pub model: [[f32; 4]; 4],
23    /// Inverse model matrix, column-major.
24    pub inv_model: [[f32; 4]; 4],
25    /// Index into the shared texture pool for the decal's image.
26    pub texture_slot: usize,
27    /// Linear RGBA tint multiplied into the sampled image.
28    pub tint: [f32; 4],
29}
30
31impl DecalRecord {
32    /// World-space AABB enclosing the decal's unit-cube volume. Cached by
33    /// [`DecalSet`](super::DecalSet) at insert and tested against the camera
34    /// frustum each frame, so a decal fully outside it costs no draw call. The
35    /// AABB is the transform of
36    /// `[-0.5, 0.5]^3` by `model`; for a non-rotated decal this exactly
37    /// matches the authored `size`, and for a rotated decal it is the
38    /// minimum AABB enclosing the rotated box.
39    pub fn aabb(&self) -> ([f32; 3], [f32; 3]) {
40        crate::gfx::frustum::transform_aabb([-0.5; 3], [0.5; 3], self.model)
41    }
42}
43
44/// Build the world-space `model` matrix for a decal: `T(position) * R_yxz *
45/// S(size)`. Column-major; the inner index is the row. Matches the rotation
46/// convention used by `Prop::model_matrix`.
47pub fn decal_model_matrix(
48    position: [f32; 3],
49    rotation_deg: [f32; 3],
50    size: [f32; 3],
51) -> [[f32; 4]; 4] {
52    trs_matrix(position, rotation_deg, size)
53}
54
55/// Invert an affine TRS matrix of the form built by [`decal_model_matrix`].
56/// The 3×3 linear part is `R * diag(size)`; its inverse is
57/// `diag(1/size) * R_transpose`. The translation flips into the inverted
58/// frame: `-inv_linear * translation`.
59///
60/// Returns `None` when any size component is non-finite or zero, a degenerate
61/// decal whose volume has collapsed. The renderer skips such decals.
62pub fn invert_decal_model(model: [[f32; 4]; 4]) -> Option<[[f32; 4]; 4]> {
63    // Columns of the 3×3 are scaled rotation basis vectors; their lengths are
64    // |size_x|, |size_y|, |size_z|.
65    let col0 = [model[0][0], model[0][1], model[0][2]];
66    let col1 = [model[1][0], model[1][1], model[1][2]];
67    let col2 = [model[2][0], model[2][1], model[2][2]];
68    let s0 = sqrt(col0[0] * col0[0] + col0[1] * col0[1] + col0[2] * col0[2]);
69    let s1 = sqrt(col1[0] * col1[0] + col1[1] * col1[1] + col1[2] * col1[2]);
70    let s2 = sqrt(col2[0] * col2[0] + col2[1] * col2[1] + col2[2] * col2[2]);
71    if !(s0.is_finite() && s1.is_finite() && s2.is_finite()) || s0 == 0.0 || s1 == 0.0 || s2 == 0.0
72    {
73        return None;
74    }
75    // Orthonormal rotation columns recovered from the scaled columns.
76    let r0 = [col0[0] / s0, col0[1] / s0, col0[2] / s0];
77    let r1 = [col1[0] / s1, col1[1] / s1, col1[2] / s1];
78    let r2 = [col2[0] / s2, col2[1] / s2, col2[2] / s2];
79    // inverse_linear = diag(1/size) * R^T. Stored column-major as the 3×3
80    // upper-left of the inverse matrix.
81    let inv_lin = [
82        // column 0
83        [r0[0] / s0, r1[0] / s1, r2[0] / s2],
84        // column 1
85        [r0[1] / s0, r1[1] / s1, r2[1] / s2],
86        // column 2
87        [r0[2] / s0, r1[2] / s1, r2[2] / s2],
88    ];
89    let t = [model[3][0], model[3][1], model[3][2]];
90    let inv_t = [
91        -(inv_lin[0][0] * t[0] + inv_lin[1][0] * t[1] + inv_lin[2][0] * t[2]),
92        -(inv_lin[0][1] * t[0] + inv_lin[1][1] * t[1] + inv_lin[2][1] * t[2]),
93        -(inv_lin[0][2] * t[0] + inv_lin[1][2] * t[1] + inv_lin[2][2] * t[2]),
94    ];
95    Some([
96        [inv_lin[0][0], inv_lin[0][1], inv_lin[0][2], 0.0],
97        [inv_lin[1][0], inv_lin[1][1], inv_lin[1][2], 0.0],
98        [inv_lin[2][0], inv_lin[2][1], inv_lin[2][2], 0.0],
99        [inv_t[0], inv_t[1], inv_t[2], 1.0],
100    ])
101}
102
103/// Resolve a list of `Decal` components into `DecalRecord`s the backend can
104/// consume. Skips decals whose texture reference is missing, invisible decals,
105/// and decals whose size is degenerate (any non-positive component).
106///
107/// A decal's `texture` carries its cook-assigned `TextureHandle`, whose value is
108/// the texture's slot in the backend's albedo texture pool; `texture_count` is
109/// that pool's size and bounds the handle. A decal whose handle is out of range
110/// is logged and dropped. A decal with no `texture` falls back to texture slot 0
111/// (the renderer's white fallback) so the tint colour still stamps.
112pub fn build_decal_records(decals: &[&Decal], texture_count: usize) -> Vec<DecalRecord> {
113    let mut out = Vec::new();
114    for d in decals {
115        if !d.visible {
116            continue;
117        }
118        if !(d.size[0] > 0.0 && d.size[1] > 0.0 && d.size[2] > 0.0) {
119            continue;
120        }
121        let slot = match d.texture {
122            None => 0,
123            Some(handle) => {
124                let slot = handle.index();
125                if slot >= texture_count {
126                    continue;
127                }
128                slot
129            }
130        };
131        let model = decal_model_matrix(d.position, d.rotation_deg, d.size);
132        let inv_model = match invert_decal_model(model) {
133            Some(m) => m,
134            None => continue,
135        };
136        out.push(DecalRecord {
137            model,
138            inv_model,
139            texture_slot: slot,
140            tint: d.tint,
141        });
142    }
143    out
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::gfx::transform::{IDENTITY, mat4_mul};
150
151    fn near(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> bool {
152        a.iter().zip(b.iter()).all(|(ac, bc)| {
153            ac.iter()
154                .zip(bc.iter())
155                .all(|(av, bv)| (av - bv).abs() < 1e-4)
156        })
157    }
158
159    #[test]
160    fn unit_decal_has_identity_model() {
161        let m = decal_model_matrix([0.0; 3], [0.0; 3], [1.0; 3]);
162        assert!(near(m, IDENTITY));
163    }
164
165    #[test]
166    fn translation_scale_compose_into_model() {
167        let m = decal_model_matrix([2.0, 3.0, -4.0], [0.0; 3], [0.5, 1.0, 2.0]);
168        // diag(0.5, 1.0, 2.0) translated by (2, 3, -4).
169        let expected: [[f32; 4]; 4] = [
170            [0.5, 0.0, 0.0, 0.0],
171            [0.0, 1.0, 0.0, 0.0],
172            [0.0, 0.0, 2.0, 0.0],
173            [2.0, 3.0, -4.0, 1.0],
174        ];
175        assert!(near(m, expected));
176    }
177
178    #[test]
179    fn inverse_round_trips_through_model() {
180        let m = decal_model_matrix([1.0, -2.0, 0.5], [30.0, -15.0, 45.0], [0.4, 1.2, 0.7]);
181        let inv = invert_decal_model(m).expect("non-degenerate model is invertible");
182        assert!(near(mat4_mul(m, inv), IDENTITY));
183        assert!(near(mat4_mul(inv, m), IDENTITY));
184    }
185
186    #[test]
187    fn degenerate_size_rejects_inverse() {
188        let m = decal_model_matrix([0.0; 3], [0.0; 3], [0.0, 1.0, 1.0]);
189        assert!(invert_decal_model(m).is_none());
190    }
191
192    #[test]
193    fn invisible_decal_is_skipped_in_records() {
194        let d = Decal {
195            visible: false,
196            ..Default::default()
197        };
198        assert!(build_decal_records(&[&d], 0).is_empty());
199    }
200
201    #[test]
202    fn degenerate_size_is_skipped_in_records() {
203        let d = Decal {
204            size: [1.0, 0.0, 1.0],
205            ..Default::default()
206        };
207        assert!(build_decal_records(&[&d], 0).is_empty());
208    }
209
210    #[test]
211    fn decal_without_texture_uses_fallback_slot() {
212        let d = Decal::default();
213        let recs = build_decal_records(&[&d], 0);
214        assert_eq!(recs.len(), 1);
215        assert_eq!(recs[0].texture_slot, 0);
216    }
217
218    #[test]
219    fn decal_texture_handle_is_used_directly_as_the_slot() {
220        // The cook-assigned handle value is the albedo pool slot; an in-range
221        // handle passes through, an out-of-range one drops the decal.
222        let d = Decal {
223            texture: Some(crate::ecs::TextureHandle(3)),
224            ..Default::default()
225        };
226        let recs = build_decal_records(&[&d], 5);
227        assert_eq!(recs.len(), 1);
228        assert_eq!(recs[0].texture_slot, 3);
229
230        let past = Decal {
231            texture: Some(crate::ecs::TextureHandle(9)),
232            ..Default::default()
233        };
234        assert!(build_decal_records(&[&past], 5).is_empty());
235    }
236
237    #[test]
238    fn aabb_of_unit_decal_at_origin_is_half_unit_box() {
239        let d = Decal::default();
240        let recs = build_decal_records(&[&d], 0);
241        let (mn, mx) = recs[0].aabb();
242        assert!((mn[0] + 0.5).abs() < 1e-5 && (mx[0] - 0.5).abs() < 1e-5);
243        assert!((mn[1] + 0.5).abs() < 1e-5 && (mx[1] - 0.5).abs() < 1e-5);
244        assert!((mn[2] + 0.5).abs() < 1e-5 && (mx[2] - 0.5).abs() < 1e-5);
245    }
246
247    #[test]
248    fn aabb_translates_with_decal_position() {
249        let d = Decal {
250            position: [10.0, 5.0, -3.0],
251            ..Default::default()
252        };
253        let recs = build_decal_records(&[&d], 0);
254        let (mn, mx) = recs[0].aabb();
255        // size = [1,1,1] → half-extents 0.5 in every axis.
256        assert!((mn[0] - 9.5).abs() < 1e-5 && (mx[0] - 10.5).abs() < 1e-5);
257        assert!((mn[1] - 4.5).abs() < 1e-5 && (mx[1] - 5.5).abs() < 1e-5);
258        assert!((mn[2] + 3.5).abs() < 1e-5 && (mx[2] + 2.5).abs() < 1e-5);
259    }
260
261    #[test]
262    fn aabb_expands_under_size() {
263        let d = Decal {
264            size: [4.0, 0.5, 8.0],
265            ..Default::default()
266        };
267        let recs = build_decal_records(&[&d], 0);
268        let (mn, mx) = recs[0].aabb();
269        assert!((mx[0] - mn[0] - 4.0).abs() < 1e-5);
270        assert!((mx[1] - mn[1] - 0.5).abs() < 1e-5);
271        assert!((mx[2] - mn[2] - 8.0).abs() < 1e-5);
272    }
273
274    #[test]
275    fn aabb_includes_rotated_decal_extents() {
276        // 45° yaw about Y on a 2×1×2 box: the local X-Z corners (±1, ±1)
277        // rotate to (0, ±√2) and (±√2, 0), so the AABB extends ±√2 along
278        // each of X and Z, a span of 2√2 ≈ 2.828.
279        let d = Decal {
280            size: [2.0, 1.0, 2.0],
281            rotation_deg: [0.0, 45.0, 0.0],
282            ..Default::default()
283        };
284        let recs = build_decal_records(&[&d], 0);
285        let (mn, mx) = recs[0].aabb();
286        let span_x = mx[0] - mn[0];
287        let span_z = mx[2] - mn[2];
288        let expected = 2.0 * core::f32::consts::SQRT_2;
289        assert!((span_x - expected).abs() < 1e-4);
290        assert!((span_z - expected).abs() < 1e-4);
291        // Y axis is unaffected by yaw; still 1 unit tall.
292        assert!((mx[1] - mn[1] - 1.0).abs() < 1e-5);
293    }
294}