Skip to main content

concinnity_render/
decal.rs

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