1use crate::components::Decal;
9use alloc::vec::Vec;
10use concinnity_core::math::{cos, sin, sqrt};
11
12#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct DecalRecord {
22 pub model: [[f32; 4]; 4],
24 pub inv_model: [[f32; 4]; 4],
26 pub texture_slot: usize,
28 pub tint: [f32; 4],
30}
31
32impl DecalRecord {
33 pub fn aabb(&self) -> ([f32; 3], [f32; 3]) {
40 crate::frustum::transform_aabb([-0.5; 3], [0.5; 3], self.model)
41 }
42}
43
44pub 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 [
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
79pub fn invert_decal_model(model: [[f32; 4]; 4]) -> Option<[[f32; 4]; 4]> {
87 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 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 let inv_lin = [
106 [r0[0] / s0, r1[0] / s1, r2[0] / s2],
108 [r0[1] / s0, r1[1] / s1, r2[1] / s2],
110 [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
127pub 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 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 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 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 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 assert!((mx[1] - mn[1] - 1.0).abs() < 1e-5);
323 }
324}