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