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                    tracing::error!(
128                        "GraphicsSystem: Decal {} references out-of-range texture handle {} (only {} textures)",
129                        d.asset_id,
130                        handle.index(),
131                        texture_count
132                    );
133                    continue;
134                }
135                slot
136            }
137        };
138        let model = decal_model_matrix(d.position, d.rotation_deg, d.size);
139        let inv_model = match invert_decal_model(model) {
140            Some(m) => m,
141            None => continue,
142        };
143        out.push(DecalRecord {
144            model,
145            inv_model,
146            texture_slot: slot,
147            tint: d.tint,
148        });
149    }
150    out
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use concinnity_core::gfx::transform::{IDENTITY, mat4_mul};
157
158    fn near(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> bool {
159        a.iter().zip(b.iter()).all(|(ac, bc)| {
160            ac.iter()
161                .zip(bc.iter())
162                .all(|(av, bv)| (av - bv).abs() < 1e-4)
163        })
164    }
165
166    #[test]
167    fn unit_decal_has_identity_model() {
168        let m = decal_model_matrix([0.0; 3], [0.0; 3], [1.0; 3]);
169        assert!(near(m, IDENTITY));
170    }
171
172    #[test]
173    fn translation_scale_compose_into_model() {
174        let m = decal_model_matrix([2.0, 3.0, -4.0], [0.0; 3], [0.5, 1.0, 2.0]);
175        // diag(0.5, 1.0, 2.0) translated by (2, 3, -4).
176        let expected: [[f32; 4]; 4] = [
177            [0.5, 0.0, 0.0, 0.0],
178            [0.0, 1.0, 0.0, 0.0],
179            [0.0, 0.0, 2.0, 0.0],
180            [2.0, 3.0, -4.0, 1.0],
181        ];
182        assert!(near(m, expected));
183    }
184
185    #[test]
186    fn inverse_round_trips_through_model() {
187        let m = decal_model_matrix([1.0, -2.0, 0.5], [30.0, -15.0, 45.0], [0.4, 1.2, 0.7]);
188        let inv = invert_decal_model(m).expect("non-degenerate model is invertible");
189        assert!(near(mat4_mul(m, inv), IDENTITY));
190        assert!(near(mat4_mul(inv, m), IDENTITY));
191    }
192
193    #[test]
194    fn degenerate_size_rejects_inverse() {
195        let m = decal_model_matrix([0.0; 3], [0.0; 3], [0.0, 1.0, 1.0]);
196        assert!(invert_decal_model(m).is_none());
197    }
198
199    #[test]
200    fn invisible_decal_is_skipped_in_records() {
201        let d = Decal {
202            visible: false,
203            ..Default::default()
204        };
205        assert!(build_decal_records(&[&d], 0).is_empty());
206    }
207
208    #[test]
209    fn degenerate_size_is_skipped_in_records() {
210        let d = Decal {
211            size: [1.0, 0.0, 1.0],
212            ..Default::default()
213        };
214        assert!(build_decal_records(&[&d], 0).is_empty());
215    }
216
217    #[test]
218    fn decal_without_texture_uses_fallback_slot() {
219        let d = Decal::default();
220        let recs = build_decal_records(&[&d], 0);
221        assert_eq!(recs.len(), 1);
222        assert_eq!(recs[0].texture_slot, 0);
223    }
224
225    #[test]
226    fn decal_texture_handle_is_used_directly_as_the_slot() {
227        // The cook-assigned handle value is the albedo pool slot; an in-range
228        // handle passes through, an out-of-range one drops the decal.
229        let d = Decal {
230            texture: Some(crate::ecs::TextureHandle(3)),
231            ..Default::default()
232        };
233        let recs = build_decal_records(&[&d], 5);
234        assert_eq!(recs.len(), 1);
235        assert_eq!(recs[0].texture_slot, 3);
236
237        let past = Decal {
238            texture: Some(crate::ecs::TextureHandle(9)),
239            ..Default::default()
240        };
241        assert!(build_decal_records(&[&past], 5).is_empty());
242    }
243
244    #[test]
245    fn aabb_of_unit_decal_at_origin_is_half_unit_box() {
246        let d = Decal::default();
247        let recs = build_decal_records(&[&d], 0);
248        let (mn, mx) = recs[0].aabb();
249        assert!((mn[0] + 0.5).abs() < 1e-5 && (mx[0] - 0.5).abs() < 1e-5);
250        assert!((mn[1] + 0.5).abs() < 1e-5 && (mx[1] - 0.5).abs() < 1e-5);
251        assert!((mn[2] + 0.5).abs() < 1e-5 && (mx[2] - 0.5).abs() < 1e-5);
252    }
253
254    #[test]
255    fn aabb_translates_with_decal_position() {
256        let d = Decal {
257            position: [10.0, 5.0, -3.0],
258            ..Default::default()
259        };
260        let recs = build_decal_records(&[&d], 0);
261        let (mn, mx) = recs[0].aabb();
262        // size = [1,1,1] → half-extents 0.5 in every axis.
263        assert!((mn[0] - 9.5).abs() < 1e-5 && (mx[0] - 10.5).abs() < 1e-5);
264        assert!((mn[1] - 4.5).abs() < 1e-5 && (mx[1] - 5.5).abs() < 1e-5);
265        assert!((mn[2] + 3.5).abs() < 1e-5 && (mx[2] + 2.5).abs() < 1e-5);
266    }
267
268    #[test]
269    fn aabb_expands_under_size() {
270        let d = Decal {
271            size: [4.0, 0.5, 8.0],
272            ..Default::default()
273        };
274        let recs = build_decal_records(&[&d], 0);
275        let (mn, mx) = recs[0].aabb();
276        assert!((mx[0] - mn[0] - 4.0).abs() < 1e-5);
277        assert!((mx[1] - mn[1] - 0.5).abs() < 1e-5);
278        assert!((mx[2] - mn[2] - 8.0).abs() < 1e-5);
279    }
280
281    #[test]
282    fn aabb_includes_rotated_decal_extents() {
283        // 45° yaw about Y on a 2×1×2 box: the local X-Z corners (±1, ±1)
284        // rotate to (0, ±√2) and (±√2, 0), so the AABB extends ±√2 along
285        // each of X and Z, a span of 2√2 ≈ 2.828.
286        let d = Decal {
287            size: [2.0, 1.0, 2.0],
288            rotation_deg: [0.0, 45.0, 0.0],
289            ..Default::default()
290        };
291        let recs = build_decal_records(&[&d], 0);
292        let (mn, mx) = recs[0].aabb();
293        let span_x = mx[0] - mn[0];
294        let span_z = mx[2] - mn[2];
295        let expected = 2.0 * core::f32::consts::SQRT_2;
296        assert!((span_x - expected).abs() < 1e-4);
297        assert!((span_z - expected).abs() < 1e-4);
298        // Y axis is unaffected by yaw; still 1 unit tall.
299        assert!((mx[1] - mn[1] - 1.0).abs() < 1e-5);
300    }
301}