1use roxlap_formats::kv6::Kv6;
23use roxlap_formats::material::{material_for_color, BlendMode, MaterialTable};
24use roxlap_formats::sprite::{
25 Sprite, SPRITE_FLAG_INVISIBLE, SPRITE_FLAG_LIGHT_AMBIENT_ONLY, SPRITE_FLAG_LIGHT_WORLD_UP,
26 SPRITE_FLAG_NO_Z,
27};
28use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
29
30use crate::camera_math::CameraState;
31use crate::dda::{
32 dda_setup, intersect_aabb, min_axis, pixel_ray, shade, shade_dynamic, CpuLights, ShadowTester,
33 WorldOccluder, WorldShadow, WorldShadowCtx,
34};
35use crate::opticast::OpticastSettings;
36use crate::raster_target::RasterTarget;
37
38const NEAR_Z: f32 = 1.0;
41
42#[inline]
48fn full_bright(col: u32) -> u32 {
49 (col & 0x00ff_ffff) | 0x8000_0000
50}
51
52#[derive(Clone)]
61pub struct SpriteDense {
62 dims: [i32; 3],
63 occ: Vec<bool>,
64 col: Vec<u32>,
65 mat: Vec<u8>,
71 pivot: [f32; 3],
72}
73
74impl SpriteDense {
75 #[must_use]
77 #[allow(clippy::cast_possible_wrap)]
78 pub fn from_kv6(kv6: &Kv6) -> Self {
79 let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
80 let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
81 let mut occ = vec![false; n];
82 let mut col = vec![0u32; n];
83 let mut vi = 0usize;
84 for x in 0..kv6.xsiz as usize {
85 for y in 0..kv6.ysiz as usize {
86 let cnt = usize::from(kv6.ylen[x][y]);
87 for _ in 0..cnt {
88 let v = kv6.voxels[vi];
89 vi += 1;
90 let z = i32::from(v.z);
91 if z >= 0 && z < dims[2] {
92 let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
93 occ[idx] = true;
94 col[idx] = full_bright(v.col);
95 }
96 }
97 }
98 }
99 Self {
100 dims,
101 occ,
102 col,
103 mat: Vec::new(),
104 pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
105 }
106 }
107
108 #[must_use]
116 #[allow(clippy::cast_possible_wrap)]
117 pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(u32, u8)]) -> Self {
118 let mut dense = Self::from_kv6(kv6);
119 if !material_map.is_empty() {
120 let n = dense.col.len();
121 let mut mat = vec![0u8; n];
122 for (idx, slot) in mat.iter_mut().enumerate() {
123 if dense.occ[idx] {
124 *slot = material_for_color(material_map, dense.col[idx]);
125 }
126 }
127 dense.mat = mat;
128 }
129 dense
130 }
131
132 #[must_use]
138 #[allow(clippy::cast_possible_wrap)]
139 pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
140 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
141 let owpc = mz.div_ceil(32).max(1) as usize;
142 let n = (mx * my * mz) as usize;
143 let mut occ = vec![false; n];
144 let mut col = vec![0u32; n];
145 for col_idx in 0..(mx * my) as usize {
146 let x = col_idx as u32 % mx;
147 let y = col_idx as u32 / mx;
148 let run_start = frame.color_offsets[col_idx] as usize;
149 let mut k = 0usize;
150 for z in 0..mz {
151 let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
152 if (word >> (z & 31)) & 1 != 0 {
153 let idx = (((x * my + y) * mz) + z) as usize;
154 occ[idx] = true;
155 col[idx] = full_bright(frame.colors[run_start + k]);
156 k += 1;
157 }
158 }
159 }
160 Self {
161 dims: [mx as i32, my as i32, mz as i32],
162 occ,
163 col,
164 mat: Vec::new(),
165 pivot,
166 }
167 }
168
169 #[must_use]
175 pub fn from_voxel_frame_with_materials(
176 frame: &VoxelFrame,
177 dims: [u32; 3],
178 pivot: [f32; 3],
179 material_map: &[(u32, u8)],
180 ) -> Self {
181 let mut dense = Self::from_voxel_frame(frame, dims, pivot);
182 if !material_map.is_empty() {
183 let n = dense.col.len();
184 let mut mat = vec![0u8; n];
185 for (idx, slot) in mat.iter_mut().enumerate() {
186 if dense.occ[idx] {
187 *slot = material_for_color(material_map, dense.col[idx]);
188 }
189 }
190 dense.mat = mat;
191 }
192 dense
193 }
194
195 #[inline]
196 #[allow(clippy::cast_sign_loss)]
197 fn idx_of(&self, c: [i32; 3]) -> usize {
198 ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
199 }
200
201 #[inline]
202 fn at(&self, c: [i32; 3]) -> Option<u32> {
203 let idx = self.idx_of(c);
204 self.occ[idx].then(|| self.col[idx])
205 }
206}
207
208fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
211 let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
212 + f[0] * (s[1] * h[2] - h[1] * s[2]);
213 if det.abs() < 1e-12 {
214 return None;
215 }
216 let inv = 1.0 / det;
217 Some([
218 [
219 (h[1] * f[2] - f[1] * h[2]) * inv,
220 -(h[0] * f[2] - f[0] * h[2]) * inv,
221 (h[0] * f[1] - f[0] * h[1]) * inv,
222 ],
223 [
224 -(s[1] * f[2] - f[1] * s[2]) * inv,
225 (s[0] * f[2] - f[0] * s[2]) * inv,
226 -(s[0] * f[1] - f[0] * s[1]) * inv,
227 ],
228 [
229 (s[1] * h[2] - h[1] * s[2]) * inv,
230 -(s[0] * h[2] - h[0] * s[2]) * inv,
231 (s[0] * h[1] - h[0] * s[1]) * inv,
232 ],
233 ])
234}
235
236#[inline]
237fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
238 [
239 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
240 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
241 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
242 ]
243}
244
245#[allow(clippy::cast_possible_truncation)]
249fn cast_local(
255 dense: &SpriteDense,
256 origin: [f32; 3],
257 dir: [f32; 3],
258) -> Option<(u32, f32, [f32; 3], [i32; 3])> {
259 #[allow(clippy::cast_precision_loss)]
260 let hi = [
261 dense.dims[0] as f32,
262 dense.dims[1] as f32,
263 dense.dims[2] as f32,
264 ];
265 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
266 let start = t0 + 1e-4;
267 let p = [
268 origin[0] + dir[0] * start,
269 origin[1] + dir[1] * start,
270 origin[2] + dir[2] * start,
271 ];
272 let mut cell = [
273 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
274 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
275 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
276 ];
277 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
278 let mut t_curr = t0;
279 let mut normal = [0.0f32; 3];
282 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
283 for _ in 0..max_steps {
284 if cell[0] < 0
285 || cell[0] >= dense.dims[0]
286 || cell[1] < 0
287 || cell[1] >= dense.dims[1]
288 || cell[2] < 0
289 || cell[2] >= dense.dims[2]
290 || t_curr > t1
291 {
292 return None;
293 }
294 if let Some(color) = dense.at(cell) {
295 return Some((color, t_curr, normal, cell));
296 }
297 let axis = min_axis(t_max);
298 t_curr = t_max[axis];
299 cell[axis] += step[axis];
300 t_max[axis] += t_delta[axis];
301 normal = [0.0; 3];
302 normal[axis] = -(step[axis] as f32);
303 }
304 None
305}
306
307struct SpriteOccEntry {
311 dense: SpriteDense,
312 pos: [f32; 3],
313 pivot: [f32; 3],
314 minv: [[f32; 3]; 3],
315}
316
317#[derive(Default)]
327pub struct SpriteOccluder {
328 entries: Vec<SpriteOccEntry>,
329}
330
331impl SpriteOccluder {
332 #[must_use]
333 pub fn new() -> Self {
334 Self::default()
335 }
336
337 #[must_use]
339 pub fn is_empty(&self) -> bool {
340 self.entries.is_empty()
341 }
342
343 pub fn push(
347 &mut self,
348 dense: SpriteDense,
349 pos: [f32; 3],
350 s: [f32; 3],
351 h: [f32; 3],
352 f: [f32; 3],
353 ) {
354 let Some(minv) = invert_basis(s, h, f) else {
355 return;
356 };
357 let pivot = dense.pivot;
358 self.entries.push(SpriteOccEntry {
359 dense,
360 pos,
361 pivot,
362 minv,
363 });
364 }
365}
366
367impl WorldOccluder for SpriteOccluder {
368 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
369 self.entries
370 .iter()
371 .any(|e| sprite_entry_occluded(e, origin, dir, max_t))
372 }
373}
374
375#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
378fn sprite_entry_occluded(e: &SpriteOccEntry, ow: [f32; 3], dw: [f32; 3], max_t: f32) -> bool {
379 let rel = [ow[0] - e.pos[0], ow[1] - e.pos[1], ow[2] - e.pos[2]];
381 let ol = mat_apply(&e.minv, rel);
382 let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
383 let dir = mat_apply(&e.minv, dw);
384
385 let hi = [
386 e.dense.dims[0] as f32,
387 e.dense.dims[1] as f32,
388 e.dense.dims[2] as f32,
389 ];
390 let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
391 return false;
392 };
393 let t_enter = t0.max(0.0);
394 let t_exit = t1.min(max_t);
395 if t_enter > t_exit {
396 return false;
397 }
398 let start = t_enter + 1e-4;
399 let p = [
400 origin[0] + dir[0] * start,
401 origin[1] + dir[1] * start,
402 origin[2] + dir[2] * start,
403 ];
404 let mut cell = [
405 (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
406 (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
407 (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
408 ];
409 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
410 let mut t_curr = t_enter;
411 let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
412 for _ in 0..max_steps {
413 if cell[0] < 0
414 || cell[0] >= e.dense.dims[0]
415 || cell[1] < 0
416 || cell[1] >= e.dense.dims[1]
417 || cell[2] < 0
418 || cell[2] >= e.dense.dims[2]
419 || t_curr > t_exit
420 {
421 return false;
422 }
423 if e.dense.occ[e.dense.idx_of(cell)] {
424 return true;
425 }
426 let a = min_axis(t_max);
427 t_curr = t_max[a];
428 cell[a] += step[a];
429 t_max[a] += t_delta[a];
430 }
431 false
432}
433
434#[derive(Clone, Copy)]
441pub struct SpriteShade<'a> {
442 pub materials: &'a MaterialTable,
444 pub material: u8,
447 pub alpha_mul: u8,
450 pub tint: u32,
453 pub lights: CpuLights<'a>,
457 pub shadow: Option<&'a dyn WorldOccluder>,
462}
463
464struct LayerAccum {
466 rgb: [f32; 3],
469 trans: f32,
471 opaque: Option<(u32, f32)>,
476}
477
478#[inline]
482fn tint_packed(color: u32, tint: u32) -> u32 {
483 if tint & 0x00FF_FFFF == 0x00FF_FFFF {
484 return color;
485 }
486 let mul = |shift: u32| {
487 let c = (color >> shift) & 0xff;
488 let t = (tint >> shift) & 0xff;
489 ((c * t) / 255) & 0xff
490 };
491 (color & 0xff00_0000) | (mul(16) << 16) | (mul(8) << 8) | mul(0)
492}
493
494#[inline]
497fn rgb_to_f32(c: u32) -> [f32; 3] {
498 [
499 ((c >> 16) & 0xff) as f32 / 255.0,
500 ((c >> 8) & 0xff) as f32 / 255.0,
501 (c & 0xff) as f32 / 255.0,
502 ]
503}
504
505#[inline]
508#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
509fn f32_to_rgb(c: [f32; 3]) -> u32 {
510 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
511 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
512}
513
514const SPRITE_WORLD_UP: [f32; 3] = [0.0, 0.0, -1.0];
517
518#[derive(Clone, Copy, PartialEq, Eq)]
523pub enum SpriteLightMode {
524 FaceNormal,
526 WorldUp,
528 AmbientOnly,
530 FullBright,
533}
534
535impl SpriteLightMode {
536 #[must_use]
537 pub fn from_flags(flags: u32) -> Self {
538 let world_up = flags & SPRITE_FLAG_LIGHT_WORLD_UP != 0;
539 let ambient_only = flags & SPRITE_FLAG_LIGHT_AMBIENT_ONLY != 0;
540 match (ambient_only, world_up) {
541 (true, true) => Self::FullBright, (true, false) => Self::AmbientOnly,
543 (false, true) => Self::WorldUp,
544 (false, false) => Self::FaceNormal,
545 }
546 }
547}
548
549fn shade_dynamic_mode(
554 mode: SpriteLightMode,
555 albedo: [f32; 3],
556 n_world: [f32; 3],
557 center: [f32; 3],
558 lights: &CpuLights<'_>,
559 tester: Option<&mut dyn ShadowTester>,
560) -> u32 {
561 match mode {
562 SpriteLightMode::FaceNormal => shade_dynamic(albedo, 1.0, n_world, center, lights, tester),
563 SpriteLightMode::WorldUp => {
564 shade_dynamic(albedo, 1.0, SPRITE_WORLD_UP, center, lights, tester)
565 }
566 SpriteLightMode::AmbientOnly => {
567 let mut amb = *lights;
568 amb.sun = false;
569 amb.points = &[];
570 amb.bands = 0; shade_dynamic(albedo, 1.0, n_world, center, &amb, None)
572 }
573 SpriteLightMode::FullBright => f32_to_rgb(albedo),
575 }
576}
577
578#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
585fn cast_local_layers(
586 dense: &SpriteDense,
587 origin: [f32; 3],
588 dir: [f32; 3],
589 fwd_dot: f32,
590 max_t: f32,
591 shade_ctx: SpriteShade,
592 s: [f32; 3],
596 h: [f32; 3],
597 f: [f32; 3],
598 pos: [f32; 3],
599 light_mode: SpriteLightMode,
600) -> Option<LayerAccum> {
601 #[allow(clippy::cast_precision_loss)]
602 let hi = [
603 dense.dims[0] as f32,
604 dense.dims[1] as f32,
605 dense.dims[2] as f32,
606 ];
607 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
608 let start = t0 + 1e-4;
609 let p = [
610 origin[0] + dir[0] * start,
611 origin[1] + dir[1] * start,
612 origin[2] + dir[2] * start,
613 ];
614 let mut cell = [
615 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
616 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
617 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
618 ];
619 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
620 let mut t_curr = t0;
621 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
622
623 let mut acc = LayerAccum {
624 rgb: [0.0; 3],
625 trans: 1.0,
626 opaque: None,
627 };
628 let mut touched = false;
629 let mut prev_solid = false;
640 let mut prev_mat = 0u8;
641 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
644 let mut normal = [0.0f32; 3];
648
649 let lights = shade_ctx.lights;
655 let tint = shade_ctx.tint;
656 let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
657 ctx: WorldShadowCtx::identity(occ),
658 });
659 let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3]| -> u32 {
660 if !lights.enabled {
661 return tint_packed(shade(dense.col[idx], 0), tint);
662 }
663 let to_world = |v: [f32; 3]| {
664 [
665 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
666 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
667 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
668 ]
669 };
670 let n_world = to_world(n_local);
671 let rel = [
672 cell[0] as f32 + 0.5 - dense.pivot[0],
673 cell[1] as f32 + 0.5 - dense.pivot[1],
674 cell[2] as f32 + 0.5 - dense.pivot[2],
675 ];
676 let wc = to_world(rel);
677 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
678 let albedo = [
679 ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
680 ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
681 (dense.col[idx] & 0xff) as f32 / 255.0,
682 ];
683 let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
684 tint_packed(
685 shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
686 tint,
687 )
688 };
689
690 for _ in 0..max_steps {
691 if cell[0] < 0
692 || cell[0] >= dense.dims[0]
693 || cell[1] < 0
694 || cell[1] >= dense.dims[1]
695 || cell[2] < 0
696 || cell[2] >= dense.dims[2]
697 || t_curr > t1
698 {
699 break;
700 }
701 let depth = t_curr * fwd_dot;
704 if depth >= max_t {
705 break;
706 }
707 let exit_axis = min_axis(t_max);
710 let t_exit = t_max[exit_axis];
711 let idx = dense.idx_of(cell);
712 let solid_here = dense.occ[idx];
713 if solid_here && depth >= NEAR_Z {
714 let mat_id = if dense.mat.is_empty() {
715 shade_ctx.material
716 } else {
717 dense.mat[idx]
718 };
719 let m = shade_ctx.materials.get(mat_id);
720 if m.is_opaque() {
721 acc.opaque = Some((shade_layer(idx, cell, normal), t_curr));
722 touched = true;
723 break;
724 }
725 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
726 if m.mode == BlendMode::Volumetric {
727 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
731 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
732 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
733 acc.rgb[0] += acc.trans * eff_a * lit[0];
734 acc.rgb[1] += acc.trans * eff_a * lit[1];
735 acc.rgb[2] += acc.trans * eff_a * lit[2];
736 acc.trans *= 1.0 - eff_a;
737 touched = true;
738 prev_mat = mat_id;
739 if acc.trans < 1.0 / 256.0 {
740 break;
741 }
742 } else if !prev_solid || mat_id != prev_mat {
743 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
746 acc.rgb[0] += acc.trans * a * lit[0];
747 acc.rgb[1] += acc.trans * a * lit[1];
748 acc.rgb[2] += acc.trans * a * lit[2];
749 if m.mode == BlendMode::AlphaBlend {
750 acc.trans *= 1.0 - a; }
752 touched = true;
753 prev_mat = mat_id;
754 if acc.trans < 1.0 / 256.0 {
755 break;
756 }
757 }
758 }
759 prev_solid = solid_here;
760 t_curr = t_exit;
761 cell[exit_axis] += step[exit_axis];
762 t_max[exit_axis] += t_delta[exit_axis];
763 normal = [0.0; 3];
764 normal[exit_axis] = -(step[exit_axis] as f32);
765 }
766
767 touched.then_some(acc)
768}
769
770#[allow(
780 clippy::too_many_arguments,
781 clippy::cast_possible_truncation,
782 clippy::cast_sign_loss
783)]
784#[must_use]
785pub fn draw_sprite_dda(
786 fb: &mut [u32],
787 zb: &mut [f32],
788 pitch_pixels: usize,
789 width: u32,
790 height: u32,
791 cam: &CameraState,
792 settings: &OpticastSettings,
793 sprite: &Sprite,
794) -> u32 {
795 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
796 return 0;
797 }
798 draw_sprite_dda_shaded(
799 fb,
800 zb,
801 pitch_pixels,
802 width,
803 height,
804 cam,
805 settings,
806 sprite,
807 None,
808 )
809}
810
811#[allow(clippy::too_many_arguments)]
816#[must_use]
817pub fn draw_sprite_dda_shaded(
818 fb: &mut [u32],
819 zb: &mut [f32],
820 pitch_pixels: usize,
821 width: u32,
822 height: u32,
823 cam: &CameraState,
824 settings: &OpticastSettings,
825 sprite: &Sprite,
826 shade_ctx: Option<SpriteShade>,
827) -> u32 {
828 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
829 return 0;
830 }
831 let dense = if sprite.material_map.is_empty() {
836 SpriteDense::from_kv6(&sprite.kv6)
837 } else {
838 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
839 };
840 draw_sprite_dense_shaded(
841 fb,
842 zb,
843 pitch_pixels,
844 width,
845 height,
846 cam,
847 settings,
848 &dense,
849 sprite.p,
850 sprite.s,
851 sprite.h,
852 sprite.f,
853 sprite.flags,
854 shade_ctx,
855 )
856}
857
858#[allow(clippy::too_many_arguments)]
867#[must_use]
868pub fn draw_sprite_dense(
869 fb: &mut [u32],
870 zb: &mut [f32],
871 pitch_pixels: usize,
872 width: u32,
873 height: u32,
874 cam: &CameraState,
875 settings: &OpticastSettings,
876 dense: &SpriteDense,
877 pos: [f32; 3],
878 s: [f32; 3],
879 h: [f32; 3],
880 f: [f32; 3],
881 flags: u32,
882) -> u32 {
883 draw_sprite_dense_shaded(
884 fb,
885 zb,
886 pitch_pixels,
887 width,
888 height,
889 cam,
890 settings,
891 dense,
892 pos,
893 s,
894 h,
895 f,
896 flags,
897 None,
898 )
899}
900
901#[allow(
913 clippy::too_many_arguments,
914 clippy::cast_possible_truncation,
915 clippy::cast_sign_loss
916)]
917#[must_use]
918pub fn draw_sprite_dense_shaded(
919 fb: &mut [u32],
920 zb: &mut [f32],
921 pitch_pixels: usize,
922 width: u32,
923 height: u32,
924 cam: &CameraState,
925 settings: &OpticastSettings,
926 dense: &SpriteDense,
927 pos: [f32; 3],
928 s: [f32; 3],
929 h: [f32; 3],
930 f: [f32; 3],
931 flags: u32,
932 shade_ctx: Option<SpriteShade>,
933) -> u32 {
934 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
935 return 0;
936 }
937 let Some(minv) = invert_basis(s, h, f) else {
938 return 0;
939 };
940 let pivot = dense.pivot;
941 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
942 let light_mode = SpriteLightMode::from_flags(flags);
944
945 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
947 return 0;
948 };
949
950 let layers =
956 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
957
958 debug_assert_eq!(fb.len(), zb.len());
959 let target = RasterTarget::new(fb, zb);
960 let mut written = 0u32;
961 for py in rect.1..rect.3 {
962 let row = py as usize * pitch_pixels;
963 for px in rect.0..rect.2 {
964 let (origin, dir) = pixel_ray(cam, settings, px, py);
965 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
967 let ol = mat_apply(&minv, rel);
968 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
969 let dir_local = mat_apply(&minv, dir);
970 let fwd_dot =
971 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
972 let idx = row + px as usize;
973
974 if let Some(shade_ctx) = layers {
975 if fwd_dot <= 1e-6 {
977 continue;
978 }
979 let max_t = if no_z {
984 f32::INFINITY
985 } else {
986 unsafe { target.read_depth(idx) }
987 };
988 let Some(acc) = cast_local_layers(
989 dense,
990 origin_local,
991 dir_local,
992 fwd_dot,
993 max_t,
994 shade_ctx,
995 s,
996 h,
997 f,
998 pos,
999 light_mode,
1000 ) else {
1001 continue;
1002 };
1003 let wrote = unsafe {
1005 match acc.opaque {
1006 Some((bg_color, t)) => {
1007 let bg = rgb_to_f32(bg_color);
1010 let out = f32_to_rgb([
1011 acc.rgb[0] + acc.trans * bg[0],
1012 acc.rgb[1] + acc.trans * bg[1],
1013 acc.rgb[2] + acc.trans * bg[2],
1014 ]);
1015 let depth = t * fwd_dot;
1016 if no_z {
1017 target.write_color(idx, out);
1018 target.write_depth(idx, depth);
1019 true
1020 } else {
1021 target.z_test_write(idx, out, depth)
1022 }
1023 }
1024 None => {
1025 let bg = rgb_to_f32(target.read_color(idx));
1030 let out = f32_to_rgb([
1031 acc.rgb[0] + acc.trans * bg[0],
1032 acc.rgb[1] + acc.trans * bg[1],
1033 acc.rgb[2] + acc.trans * bg[2],
1034 ]);
1035 target.write_color(idx, out);
1036 true
1037 }
1038 }
1039 };
1040 written += u32::from(wrote);
1041 } else {
1042 let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1044 else {
1045 continue;
1046 };
1047 let depth = t * fwd_dot;
1048 if depth < NEAR_Z {
1049 continue;
1050 }
1051 let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1056 let lit = if dl.enabled {
1057 let to_world = |v: [f32; 3]| {
1058 [
1059 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1060 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1061 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1062 ]
1063 };
1064 let n_world = to_world(n_local);
1065 let rel = [
1066 cell[0] as f32 + 0.5 - pivot[0],
1067 cell[1] as f32 + 0.5 - pivot[1],
1068 cell[2] as f32 + 0.5 - pivot[2],
1069 ];
1070 let wc = to_world(rel);
1071 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1072 let albedo = [
1073 ((color >> 16) & 0xff) as f32 / 255.0,
1074 ((color >> 8) & 0xff) as f32 / 255.0,
1075 (color & 0xff) as f32 / 255.0,
1076 ];
1077 let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1081 ctx: WorldShadowCtx::identity(occ),
1082 });
1083 let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1084 shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1085 } else {
1086 shade(color, 0)
1087 };
1088 let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1090 let wrote = unsafe {
1093 if no_z {
1094 target.write_color(idx, lit);
1095 target.write_depth(idx, depth);
1096 true
1097 } else {
1098 target.z_test_write(idx, lit, depth)
1099 }
1100 };
1101 written += u32::from(wrote);
1102 }
1103 }
1104 }
1105 written
1106}
1107
1108#[allow(
1112 clippy::cast_possible_truncation,
1113 clippy::cast_sign_loss,
1114 clippy::cast_precision_loss
1115)]
1116fn project_screen_rect(
1117 dense: &SpriteDense,
1118 pos: [f32; 3],
1119 s: [f32; 3],
1120 h: [f32; 3],
1121 f: [f32; 3],
1122 cam: &CameraState,
1123 settings: &OpticastSettings,
1124 width: u32,
1125 height: u32,
1126) -> Option<(u32, u32, u32, u32)> {
1127 let (xs, ys, zs) = (
1128 dense.dims[0] as f32,
1129 dense.dims[1] as f32,
1130 dense.dims[2] as f32,
1131 );
1132 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1133 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1134 let mut all_front = true;
1135 for &cx in &[0.0, xs] {
1136 for &cy in &[0.0, ys] {
1137 for &cz in &[0.0, zs] {
1138 let lx = cx - xp;
1140 let ly = cy - yp;
1141 let lz = cz - zp;
1142 let world = [
1143 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1144 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1145 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1146 ];
1147 let rel = [
1148 world[0] - cam.pos[0],
1149 world[1] - cam.pos[1],
1150 world[2] - cam.pos[2],
1151 ];
1152 let cz_cam =
1153 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1154 if cz_cam < NEAR_Z {
1155 all_front = false;
1156 continue;
1157 }
1158 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1159 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1160 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1161 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1162 x0 = x0.min(sx);
1163 y0 = y0.min(sy);
1164 x1 = x1.max(sx);
1165 y1 = y1.max(sy);
1166 }
1167 }
1168 }
1169 let (w, h) = (width as f32, height as f32);
1170 let (rx0, ry0, rx1, ry1) = if all_front {
1171 (
1172 (x0 - 1.0).max(0.0),
1173 (y0 - 1.0).max(0.0),
1174 (x1 + 1.0).min(w),
1175 (y1 + 1.0).min(h),
1176 )
1177 } else {
1178 (0.0, 0.0, w, h)
1180 };
1181 if rx0 >= rx1 || ry0 >= ry1 {
1182 return None;
1183 }
1184 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1185}
1186
1187pub struct ClipFlipbook {
1194 frames: Vec<SpriteDense>,
1195}
1196
1197impl ClipFlipbook {
1198 #[must_use]
1201 pub fn empty() -> Self {
1202 Self { frames: Vec::new() }
1203 }
1204
1205 #[must_use]
1207 pub fn from_decoded(clip: &DecodedClip) -> Self {
1208 Self::from_decoded_with_materials(clip, &[])
1209 }
1210
1211 #[must_use]
1217 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(u32, u8)]) -> Self {
1218 let frames = clip
1219 .frames
1220 .iter()
1221 .map(|frame| {
1222 SpriteDense::from_voxel_frame_with_materials(
1223 frame,
1224 clip.dims,
1225 clip.pivot,
1226 material_map,
1227 )
1228 })
1229 .collect();
1230 Self { frames }
1231 }
1232
1233 #[must_use]
1234 pub fn frame_count(&self) -> usize {
1235 self.frames.len()
1236 }
1237
1238 #[must_use]
1240 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1241 self.frames.get(frame)
1242 }
1243
1244 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1248 match self.frames.get_mut(frame) {
1249 Some(slot) => {
1250 *slot = dense;
1251 true
1252 }
1253 None => false,
1254 }
1255 }
1256
1257 #[allow(clippy::too_many_arguments)]
1261 #[must_use]
1262 pub fn draw_frame(
1263 &self,
1264 fb: &mut [u32],
1265 zb: &mut [f32],
1266 pitch_pixels: usize,
1267 width: u32,
1268 height: u32,
1269 cam: &CameraState,
1270 settings: &OpticastSettings,
1271 frame: usize,
1272 pos: [f32; 3],
1273 s: [f32; 3],
1274 h: [f32; 3],
1275 f: [f32; 3],
1276 flags: u32,
1277 ) -> u32 {
1278 self.draw_frame_shaded(
1279 fb,
1280 zb,
1281 pitch_pixels,
1282 width,
1283 height,
1284 cam,
1285 settings,
1286 frame,
1287 pos,
1288 s,
1289 h,
1290 f,
1291 flags,
1292 None,
1293 )
1294 }
1295
1296 #[allow(clippy::too_many_arguments)]
1301 #[must_use]
1302 pub fn draw_frame_shaded(
1303 &self,
1304 fb: &mut [u32],
1305 zb: &mut [f32],
1306 pitch_pixels: usize,
1307 width: u32,
1308 height: u32,
1309 cam: &CameraState,
1310 settings: &OpticastSettings,
1311 frame: usize,
1312 pos: [f32; 3],
1313 s: [f32; 3],
1314 h: [f32; 3],
1315 f: [f32; 3],
1316 flags: u32,
1317 shade_ctx: Option<SpriteShade>,
1318 ) -> u32 {
1319 let Some(dense) = self.frames.get(frame) else {
1320 return 0;
1321 };
1322 draw_sprite_dense_shaded(
1323 fb,
1324 zb,
1325 pitch_pixels,
1326 width,
1327 height,
1328 cam,
1329 settings,
1330 dense,
1331 pos,
1332 s,
1333 h,
1334 f,
1335 flags,
1336 shade_ctx,
1337 )
1338 }
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343 use super::*;
1344 use crate::camera_math;
1345 use crate::Camera;
1346 use roxlap_formats::kv6::Kv6;
1347 use roxlap_formats::material::{Material, MaterialTable};
1348
1349 #[test]
1352 fn sprite_light_mode_world_up_and_ambient_only() {
1353 let lights = CpuLights {
1354 enabled: true,
1355 sun: true,
1356 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0, 1.0, 1.0],
1358 sun_intensity: 1.0,
1359 sun_casts_shadow: false,
1360 points: &[],
1361 ambient: [0.2, 0.2, 0.2],
1362 bands: 0,
1363 shadow_tint: [0.0; 3],
1364 shadow_strength: 0.0,
1365 shadow_bias: 0.0,
1366 shadow_max_dist: 0.0,
1367 };
1368 let a = [1.0, 1.0, 1.0];
1369 let c = [0.0, 0.0, 0.0];
1370 let g = |packed: u32| (packed >> 8) & 0xff; let up_n = [0.0, 0.0, -1.0];
1372 let side_n = [1.0, 0.0, 0.0];
1373 let face_up = g(shade_dynamic_mode(
1374 SpriteLightMode::FaceNormal,
1375 a,
1376 up_n,
1377 c,
1378 &lights,
1379 None,
1380 ));
1381 let face_side = g(shade_dynamic_mode(
1382 SpriteLightMode::FaceNormal,
1383 a,
1384 side_n,
1385 c,
1386 &lights,
1387 None,
1388 ));
1389 let amb = g(shade_dynamic_mode(
1390 SpriteLightMode::AmbientOnly,
1391 a,
1392 up_n,
1393 c,
1394 &lights,
1395 None,
1396 ));
1397 let world_up = g(shade_dynamic_mode(
1398 SpriteLightMode::WorldUp,
1399 a,
1400 side_n,
1401 c,
1402 &lights,
1403 None,
1404 ));
1405 assert!(
1406 face_up > face_side,
1407 "a sun-facing face is brighter than a side face"
1408 );
1409 assert!(amb < face_up, "ambient-only drops the sun term");
1410 assert_eq!(
1411 world_up, face_up,
1412 "world-up shades a side-facing billboard as if it faced up"
1413 );
1414 let full = g(shade_dynamic_mode(
1415 SpriteLightMode::FullBright,
1416 a,
1417 side_n,
1418 c,
1419 &lights,
1420 None,
1421 ));
1422 assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1425 assert!(full > amb, "full-bright glow is brighter than ambient-only");
1426 }
1427
1428 #[test]
1433 fn cast_local_reports_face_normal() {
1434 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| (z >= 4).then_some(0x80_C0_40_20));
1436 let dense = SpriteDense::from_kv6(&kv6);
1437 let (_c, _t, n, cell) =
1439 cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1440 assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1441 assert!(
1442 n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1443 "z-crossing face normal points back toward the ray (-z): {n:?}",
1444 );
1445 }
1446 use roxlap_formats::sprite::Sprite;
1447 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1448
1449 fn settings(w: u32, h: u32) -> OpticastSettings {
1450 OpticastSettings::for_oracle_framebuffer(w, h)
1451 }
1452
1453 fn cam_looking_y() -> Camera {
1455 Camera {
1456 pos: [0.0, 0.0, 0.0],
1457 right: [1.0, 0.0, 0.0],
1458 down: [0.0, 0.0, 1.0],
1459 forward: [0.0, 1.0, 0.0],
1460 }
1461 }
1462
1463 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1465 let owpc = dims[2].div_ceil(32).max(1) as usize;
1466 let cols = (dims[0] * dims[1]) as usize;
1467 let mut occupancy = vec![0u32; cols * owpc];
1468 let mut color_offsets = vec![0u32; cols + 1];
1469 let mut colors = Vec::new();
1470 for y in 0..dims[1] {
1471 for x in 0..dims[0] {
1472 let col = (x + y * dims[0]) as usize;
1473 color_offsets[col] = colors.len() as u32;
1474 for z in 0..dims[2] {
1475 if let Some(c) = fill(x, y, z) {
1476 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1477 colors.push(c);
1478 }
1479 }
1480 }
1481 }
1482 color_offsets[cols] = colors.len() as u32;
1483 VoxelFrame {
1484 occupancy,
1485 colors,
1486 color_offsets,
1487 }
1488 }
1489
1490 #[test]
1495 fn clip_flipbook_frames_render_differently() {
1496 let dims = [8u32, 8, 8];
1497 let f0 = clip_frame(dims, |_x, _y, z| (z < 4).then_some(0x00FF_0000)); let f1 = clip_frame(dims, |_x, _y, z| (z >= 4).then_some(0x0000_FF00)); let clip = VoxelClip::from_frames(
1500 dims,
1501 [4.0, 4.0, 4.0],
1502 1.0,
1503 LoopMode::Loop,
1504 &[f0, f1],
1505 &[],
1506 33,
1507 0,
1508 );
1509 let decoded = clip.decode().expect("decode");
1510 let book = ClipFlipbook::from_decoded(&decoded);
1511 assert_eq!(book.frame_count(), 2);
1512 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1513
1514 let (w, h) = (64u32, 64u32);
1515 let n = (w * h) as usize;
1516 let cam = cam_looking_y();
1517 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1518 let cfg = settings(w, h);
1519 let pose = [0.0, 40.0, 0.0];
1520 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1521
1522 let render = |frame: usize| -> Vec<u32> {
1523 let mut fb = vec![0u32; n];
1524 let mut zb = vec![f32::INFINITY; n];
1525 let wrote = book.draw_frame(
1526 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1527 );
1528 assert!(wrote > 0, "frame {frame} should draw some pixels");
1529 fb
1530 };
1531 let fb0 = render(0);
1532 let fb1 = render(1);
1533 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1534 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1537 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1538 let mut fb = vec![0u32; n];
1540 let mut zb = vec![f32::INFINITY; n];
1541 assert_eq!(
1542 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1543 0
1544 );
1545 }
1546
1547 #[test]
1548 fn clip_flipbook_set_frame_replaces_one_frame() {
1549 let dims = [8u32, 8, 8];
1552 let f0 = clip_frame(dims, |_, _, z| (z < 4).then_some(0x00FF_0000)); let f1 = clip_frame(dims, |_, _, z| (z >= 4).then_some(0x0000_FF00)); let clip =
1555 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1556 let decoded = clip.decode().unwrap();
1557 let mut book = ClipFlipbook::from_decoded(&decoded);
1558
1559 let (w, h) = (64u32, 64u32);
1560 let n = (w * h) as usize;
1561 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1562 let cfg = settings(w, h);
1563 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1564 let mut fb = vec![0u32; n];
1565 let mut zb = vec![f32::INFINITY; n];
1566 let _ = b.draw_frame(
1567 &mut fb,
1568 &mut zb,
1569 w as usize,
1570 w,
1571 h,
1572 &cs,
1573 &cfg,
1574 0,
1575 [0.0, 40.0, 0.0],
1576 [1.0, 0.0, 0.0],
1577 [0.0, 1.0, 0.0],
1578 [0.0, 0.0, 1.0],
1579 0,
1580 );
1581 fb
1582 };
1583
1584 let before = render0(&book);
1585 assert!(
1586 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1587 "frame 0 is red"
1588 );
1589
1590 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1592 assert!(book.set_frame(0, replacement));
1593 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1594 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1595
1596 let after = render0(&book);
1597 assert!(
1598 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1599 "frame 0 now green"
1600 );
1601 assert_ne!(before, after);
1602 }
1603
1604 #[test]
1607 fn cube_sprite_renders() {
1608 let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
1609 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1610 let (w, h) = (64u32, 64u32);
1611 let n = (w * h) as usize;
1612 let mut fb = vec![0u32; n];
1613 let mut zb = vec![f32::INFINITY; n];
1614 let cam = cam_looking_y();
1615 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1616 let wrote = draw_sprite_dda(
1617 &mut fb,
1618 &mut zb,
1619 w as usize,
1620 w,
1621 h,
1622 &cs,
1623 &settings(w, h),
1624 &sprite,
1625 );
1626
1627 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1628 let centre = (h / 2 * w + w / 2) as usize;
1629 assert_eq!(
1630 fb[centre] & 0x00ff_ffff,
1631 0x00_C0_40_20,
1632 "got {:08x}",
1633 fb[centre]
1634 );
1635 assert!(
1637 (zb[centre] - 36.0).abs() < 3.0,
1638 "centre depth {} not ≈ 36",
1639 zb[centre]
1640 );
1641 }
1642
1643 #[test]
1648 fn zero_high_byte_sprite_not_black() {
1649 let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
1650 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1651 let (w, h) = (64u32, 64u32);
1652 let n = (w * h) as usize;
1653 let mut fb = vec![0u32; n];
1654 let mut zb = vec![f32::INFINITY; n];
1655 let cam = cam_looking_y();
1656 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1657 let wrote = draw_sprite_dda(
1658 &mut fb,
1659 &mut zb,
1660 w as usize,
1661 w,
1662 h,
1663 &cs,
1664 &settings(w, h),
1665 &sprite,
1666 );
1667 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1668 let centre = (h / 2 * w + w / 2) as usize;
1669 assert_eq!(
1670 fb[centre] & 0x00ff_ffff,
1671 0x00_C0_40_20,
1672 "zero-high-byte sprite rendered as {:08x} (black bug)",
1673 fb[centre]
1674 );
1675 }
1676
1677 #[test]
1680 fn sprite_respects_zbuffer() {
1681 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1682 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1683 let (w, h) = (32u32, 32u32);
1684 let n = (w * h) as usize;
1685 let cam = cam_looking_y();
1686 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1687 let centre = (h / 2 * w + w / 2) as usize;
1688
1689 let mut fb = vec![0u32; n];
1691 let mut zb = vec![f32::INFINITY; n];
1692 fb[centre] = 0x80_11_22_33;
1693 zb[centre] = 10.0;
1694 let _ = draw_sprite_dda(
1695 &mut fb,
1696 &mut zb,
1697 w as usize,
1698 w,
1699 h,
1700 &cs,
1701 &settings(w, h),
1702 &sprite,
1703 );
1704 assert_eq!(
1705 fb[centre], 0x80_11_22_33,
1706 "near terrain must occlude sprite"
1707 );
1708
1709 let mut fb2 = vec![0u32; n];
1711 let mut zb2 = vec![f32::INFINITY; n];
1712 fb2[centre] = 0x80_11_22_33;
1713 zb2[centre] = 100.0;
1714 let _ = draw_sprite_dda(
1715 &mut fb2,
1716 &mut zb2,
1717 w as usize,
1718 w,
1719 h,
1720 &cs,
1721 &settings(w, h),
1722 &sprite,
1723 );
1724 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1725 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1726 }
1727
1728 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1731 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1732 for py in 0..h {
1733 for px in 0..w {
1734 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1735 x0 = x0.min(px);
1736 y0 = y0.min(py);
1737 x1 = x1.max(px);
1738 y1 = y1.max(py);
1739 }
1740 }
1741 }
1742 (x0, y0, x1, y1)
1743 }
1744
1745 #[test]
1750 fn posed_basis_reorients_silhouette() {
1751 let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
1754 let (w, h) = (64u32, 64u32);
1755 let n = (w * h) as usize;
1756 let cam = cam_looking_y();
1757 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1758
1759 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1761 let mut fb = vec![0u32; n];
1762 let mut zb = vec![f32::INFINITY; n];
1763 let _ = draw_sprite_dda(
1764 &mut fb,
1765 &mut zb,
1766 w as usize,
1767 w,
1768 h,
1769 &cs,
1770 &settings(w, h),
1771 &aa,
1772 );
1773 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1774 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1775 assert!(
1776 aa_wide > 4,
1777 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1778 );
1779
1780 let mut posed = aa.clone();
1783 posed.s = [0.0, 0.0, 1.0]; posed.h = [0.0, 1.0, 0.0]; posed.f = [1.0, 0.0, 0.0]; let mut fb2 = vec![0u32; n];
1787 let mut zb2 = vec![f32::INFINITY; n];
1788 let _ = draw_sprite_dda(
1789 &mut fb2,
1790 &mut zb2,
1791 w as usize,
1792 w,
1793 h,
1794 &cs,
1795 &settings(w, h),
1796 &posed,
1797 );
1798 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1799 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1800 assert!(
1801 posed_tall > 4,
1802 "posed box should be taller than wide (got h-w={posed_tall})"
1803 );
1804 }
1805
1806 #[test]
1809 fn degenerate_basis_draws_nothing() {
1810 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1811 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1812 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1814 let n = (w * h) as usize;
1815 let mut fb = vec![0u32; n];
1816 let mut zb = vec![f32::INFINITY; n];
1817 let cam = cam_looking_y();
1818 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1819 let wrote = draw_sprite_dda(
1820 &mut fb,
1821 &mut zb,
1822 w as usize,
1823 w,
1824 h,
1825 &cs,
1826 &settings(w, h),
1827 &sprite,
1828 );
1829 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1830 }
1831
1832 #[test]
1834 fn invisible_sprite_skipped() {
1835 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1836 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1837 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1838 let (w, h) = (32u32, 32u32);
1839 let n = (w * h) as usize;
1840 let mut fb = vec![0u32; n];
1841 let mut zb = vec![f32::INFINITY; n];
1842 let cam = cam_looking_y();
1843 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1844 let wrote = draw_sprite_dda(
1845 &mut fb,
1846 &mut zb,
1847 w as usize,
1848 w,
1849 h,
1850 &cs,
1851 &settings(w, h),
1852 &sprite,
1853 );
1854 assert_eq!(wrote, 0);
1855 }
1856
1857 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1863 let mut table = MaterialTable::new();
1864 table.set(1, mat);
1865 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1866 let (w, h) = (64u32, 64u32);
1867 let n = (w * h) as usize;
1868 let mut fb = vec![bg; n];
1869 let mut zb = vec![zb_v; n];
1870 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1871 let sh = SpriteShade {
1872 materials: &table,
1873 lights: CpuLights::default(),
1874 material: 1,
1875 alpha_mul,
1876 tint: 0x00FF_FFFF,
1877 shadow: None,
1878 };
1879 let _ = draw_sprite_dense_shaded(
1880 &mut fb,
1881 &mut zb,
1882 w as usize,
1883 w,
1884 h,
1885 &cs,
1886 &settings(w, h),
1887 &dense,
1888 [0.0, 40.0, 0.0],
1889 [1.0, 0.0, 0.0],
1890 [0.0, 1.0, 0.0],
1891 [0.0, 0.0, 1.0],
1892 0,
1893 Some(sh),
1894 );
1895 (fb[(h / 2 * w + w / 2) as usize], fb)
1896 }
1897
1898 #[test]
1901 fn additive_sprite_brightens_background() {
1902 let bg = 0x80_20_20_20;
1903 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1904 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1905 assert!(
1906 cr > 0x20 && cg > 0x20 && cb >= 0x20,
1907 "centre {centre:08x} should be brighter than bg"
1908 );
1909 assert!(
1911 cr >= cg && cr >= cb,
1912 "additive of a red-dominant cube stays red-dominant"
1913 );
1914 }
1915
1916 #[test]
1919 fn alpha_blend_sprite_between_bg_and_color() {
1920 let bg = 0x80_20_20_20;
1921 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
1922 let cr = (centre >> 16) & 0xff;
1923 assert!(
1924 cr > 0x20,
1925 "blended red must rise above bg 0x20 (got {cr:02x})"
1926 );
1927 assert!(
1928 cr < 0xC0,
1929 "blended red must stay below opaque 0xC0 (got {cr:02x})"
1930 );
1931 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
1933 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
1934 }
1935
1936 #[test]
1939 fn alpha_mul_scales_opacity() {
1940 let bg = 0x80_20_20_20;
1941 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
1942 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
1943 let r_full = (full >> 16) & 0xff;
1944 let r_faded = (faded >> 16) & 0xff;
1945 assert!(
1947 r_full > r_faded,
1948 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
1949 );
1950 assert!(r_faded > 0x20, "even faded lifts above bg");
1951 }
1952
1953 #[test]
1957 fn opaque_shade_ctx_matches_plain_path() {
1958 let table = MaterialTable::new();
1959 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1960 let (w, h) = (64u32, 64u32);
1961 let n = (w * h) as usize;
1962 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1963 let pose = (
1964 [0.0, 40.0, 0.0],
1965 [1.0, 0.0, 0.0],
1966 [0.0, 1.0, 0.0],
1967 [0.0, 0.0, 1.0],
1968 );
1969
1970 let mut fb_plain = vec![0u32; n];
1971 let mut zb_plain = vec![f32::INFINITY; n];
1972 let _ = draw_sprite_dense(
1973 &mut fb_plain,
1974 &mut zb_plain,
1975 w as usize,
1976 w,
1977 h,
1978 &cs,
1979 &settings(w, h),
1980 &dense,
1981 pose.0,
1982 pose.1,
1983 pose.2,
1984 pose.3,
1985 0,
1986 );
1987
1988 let mut fb_sh = vec![0u32; n];
1989 let mut zb_sh = vec![f32::INFINITY; n];
1990 let sh = SpriteShade {
1991 materials: &table,
1992 lights: CpuLights::default(),
1993 material: 0, alpha_mul: 255,
1995 tint: 0x00FF_FFFF,
1996 shadow: None,
1997 };
1998 let _ = draw_sprite_dense_shaded(
1999 &mut fb_sh,
2000 &mut zb_sh,
2001 w as usize,
2002 w,
2003 h,
2004 &cs,
2005 &settings(w, h),
2006 &dense,
2007 pose.0,
2008 pose.1,
2009 pose.2,
2010 pose.3,
2011 0,
2012 Some(sh),
2013 );
2014
2015 assert_eq!(
2016 fb_plain, fb_sh,
2017 "opaque shade-ctx must match the plain path bit-for-bit"
2018 );
2019 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2020 }
2021
2022 #[test]
2026 fn translucent_sprite_occluded_by_near_terrain() {
2027 let bg = 0x80_20_20_20;
2028 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2029 assert_eq!(
2030 centre, bg,
2031 "near terrain (z=5) must occlude the sprite at y≈36"
2032 );
2033 }
2034
2035 #[test]
2041 fn per_span_thickness_independent() {
2042 fn centre(ysiz: u32) -> u32 {
2043 let mut table = MaterialTable::new();
2044 table.set(1, Material::alpha_blend(128));
2045 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, 0x80_C0_40_20));
2046 let (w, h) = (64u32, 64u32);
2047 let n = (w * h) as usize;
2048 let mut fb = vec![0x80_10_10_10u32; n];
2049 let mut zb = vec![f32::INFINITY; n];
2050 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2051 let sh = SpriteShade {
2052 materials: &table,
2053 lights: CpuLights::default(),
2054 material: 1,
2055 alpha_mul: 255,
2056 tint: 0x00FF_FFFF,
2057 shadow: None,
2058 };
2059 let _ = draw_sprite_dense_shaded(
2060 &mut fb,
2061 &mut zb,
2062 w as usize,
2063 w,
2064 h,
2065 &cs,
2066 &settings(w, h),
2067 &dense,
2068 [0.0, 40.0, 0.0],
2069 [1.0, 0.0, 0.0],
2070 [0.0, 1.0, 0.0],
2071 [0.0, 0.0, 1.0],
2072 0,
2073 Some(sh),
2074 );
2075 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2076 }
2077 assert_eq!(
2081 centre(1),
2082 centre(2),
2083 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2084 );
2085 }
2086
2087 #[test]
2092 fn volumetric_thickness_deepens_opacity() {
2093 fn red_at(depth: u32) -> u32 {
2096 let mut table = MaterialTable::new();
2097 table.set(1, Material::volumetric(128));
2098 let kv6 =
2103 Kv6::from_fn_keep_interior(8, depth, 8, |_, _, _| Some(0x80_C0_20_20), |_| true);
2104 let dense = SpriteDense::from_kv6(&kv6);
2105 let (w, h) = (64u32, 64u32);
2106 let n = (w * h) as usize;
2107 let mut fb = vec![0x80_10_10_10u32; n];
2108 let mut zb = vec![f32::INFINITY; n];
2109 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2110 let sh = SpriteShade {
2111 materials: &table,
2112 lights: CpuLights::default(),
2113 material: 1,
2114 alpha_mul: 255,
2115 tint: 0x00FF_FFFF,
2116 shadow: None,
2117 };
2118 let _ = draw_sprite_dense_shaded(
2119 &mut fb,
2120 &mut zb,
2121 w as usize,
2122 w,
2123 h,
2124 &cs,
2125 &settings(w, h),
2126 &dense,
2127 [0.0, 40.0, 0.0],
2128 [1.0, 0.0, 0.0],
2129 [0.0, 1.0, 0.0],
2130 [0.0, 0.0, 1.0],
2131 0,
2132 Some(sh),
2133 );
2134 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2135 }
2136 let shallow = red_at(1);
2137 let deep = red_at(12);
2138 assert!(
2141 shallow > 0x10,
2142 "even a 1-deep volume tints (got {shallow:02x})"
2143 );
2144 assert!(
2145 deep > shallow,
2146 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2147 );
2148 }
2149
2150 #[test]
2155 fn sprite_occluder_blocks_ray_through_volume() {
2156 use crate::dda::WorldOccluder;
2157 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF));
2160 let mut occ = SpriteOccluder::new();
2161 occ.push(
2162 dense,
2163 [0.0, 0.0, 0.0],
2164 [1.0, 0.0, 0.0],
2165 [0.0, 1.0, 0.0],
2166 [0.0, 0.0, 1.0],
2167 );
2168 assert!(!occ.is_empty());
2169 assert!(
2171 occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2172 "a ray through the cube must be occluded"
2173 );
2174 assert!(
2176 !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2177 "a ray missing the cube must not be occluded"
2178 );
2179 assert!(
2181 !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2182 "max_t shorter than the distance to the cube ⇒ unoccluded"
2183 );
2184 }
2185
2186 #[test]
2191 fn sprite_receives_hard_shadow() {
2192 let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2198 let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2199 (dx * dx + dy * dy + dz * dz <= 49).then_some(0x80_C0_C0_C0)
2200 }));
2201 let mut occ = SpriteOccluder::new();
2202 occ.push(
2203 SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF)),
2204 [0.0, 25.0, 0.0],
2205 [1.0, 0.0, 0.0],
2206 [0.0, 1.0, 0.0],
2207 [0.0, 0.0, 1.0],
2208 );
2209 let table = MaterialTable::new();
2210 let base = CpuLights {
2211 enabled: true,
2212 sun: true,
2213 sun_dir: [0.0, -1.0, 0.0], sun_color: [1.0; 3],
2215 sun_intensity: 1.0,
2216 sun_casts_shadow: true,
2217 ambient: [0.3; 3],
2218 shadow_strength: 0.85,
2219 shadow_bias: 1.5,
2220 shadow_max_dist: 128.0,
2221 ..CpuLights::default()
2222 };
2223 let (w, h) = (64u32, 64u32);
2224 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2225 let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2226 let n = (w * h) as usize;
2227 let mut fb = vec![0u32; n];
2228 let mut zb = vec![f32::INFINITY; n];
2229 let sh = SpriteShade {
2230 materials: &table,
2231 lights: base,
2232 material: 0,
2233 alpha_mul: 255,
2234 tint: 0x00FF_FFFF,
2235 shadow,
2236 };
2237 let _ = draw_sprite_dense_shaded(
2238 &mut fb,
2239 &mut zb,
2240 w as usize,
2241 w,
2242 h,
2243 &cs,
2244 &settings(w, h),
2245 &target,
2246 [0.0, 40.0, 0.0],
2247 [1.0, 0.0, 0.0],
2248 [0.0, 1.0, 0.0],
2249 [0.0, 0.0, 1.0],
2250 0,
2251 Some(sh),
2252 );
2253 fb.iter()
2254 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2255 .sum()
2256 };
2257 let lit = sum_lum(None);
2258 let shadowed = sum_lum(Some(&occ));
2259 assert!(
2260 shadowed < lit,
2261 "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2262 );
2263 }
2264
2265 #[test]
2268 fn sprite_rgb_tint_recolours() {
2269 let table = MaterialTable::new();
2270 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF));
2271 let (w, h) = (64u32, 64u32);
2272 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2273 let centre = |tint: u32| -> u32 {
2274 let n = (w * h) as usize;
2275 let mut fb = vec![0u32; n];
2276 let mut zb = vec![f32::INFINITY; n];
2277 let sh = SpriteShade {
2278 materials: &table,
2279 lights: CpuLights::default(),
2280 material: 0,
2281 alpha_mul: 255,
2282 tint,
2283 shadow: None,
2284 };
2285 let _ = draw_sprite_dense_shaded(
2286 &mut fb,
2287 &mut zb,
2288 w as usize,
2289 w,
2290 h,
2291 &cs,
2292 &settings(w, h),
2293 &dense,
2294 [0.0, 40.0, 0.0],
2295 [1.0, 0.0, 0.0],
2296 [0.0, 1.0, 0.0],
2297 [0.0, 0.0, 1.0],
2298 0,
2299 Some(sh),
2300 );
2301 fb[(h / 2 * w + w / 2) as usize]
2302 };
2303 let r = |p: u32| (p >> 16) & 0xff;
2304 let g = |p: u32| (p >> 8) & 0xff;
2305 let b = |p: u32| p & 0xff;
2306 let white = centre(0x00FF_FFFF);
2307 let red = centre(0x00FF_0000);
2308 assert!(
2309 g(white) > 180 && b(white) > 180 && r(white) > 180,
2310 "white tint must be a no-op: {white:#08x}"
2311 );
2312 assert!(
2313 r(red) > 180 && g(red) < 20 && b(red) < 20,
2314 "red tint zeroes green/blue, keeps red: {red:#08x}"
2315 );
2316 }
2317
2318 #[test]
2323 fn translucent_sprite_layers_are_lit() {
2324 fn center_red(lights: CpuLights) -> u32 {
2325 let mut table = MaterialTable::new();
2326 table.set(1, Material::alpha_blend(160));
2327 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, 0x80_E0_30_30));
2328 let (w, h) = (64u32, 64u32);
2329 let n = (w * h) as usize;
2330 let mut fb = vec![0x80_10_10_10u32; n];
2331 let mut zb = vec![f32::INFINITY; n];
2332 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2333 let sh = SpriteShade {
2334 materials: &table,
2335 lights,
2336 material: 1,
2337 alpha_mul: 255,
2338 tint: 0x00FF_FFFF,
2339 shadow: None,
2340 };
2341 let _ = draw_sprite_dense_shaded(
2342 &mut fb,
2343 &mut zb,
2344 w as usize,
2345 w,
2346 h,
2347 &cs,
2348 &settings(w, h),
2349 &dense,
2350 [0.0, 40.0, 0.0],
2351 [1.0, 0.0, 0.0],
2352 [0.0, 1.0, 0.0],
2353 [0.0, 0.0, 1.0],
2354 0,
2355 Some(sh),
2356 );
2357 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2358 }
2359 let baked = center_red(CpuLights::default()); let dim = center_red(CpuLights {
2361 enabled: true,
2362 ambient: [0.3; 3], ..CpuLights::default()
2364 });
2365 assert!(
2366 dim < baked,
2367 "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2368 );
2369 }
2370
2371 #[test]
2376 fn translucent_sprite_tints_opaque_sprite_behind() {
2377 let mut table = MaterialTable::new();
2378 table.set(1, Material::alpha_blend(128));
2379 let (w, h) = (64u32, 64u32);
2380 let n = (w * h) as usize;
2381 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
2383 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2384 let cfg = settings(w, h);
2385 let id = [1.0, 0.0, 0.0];
2386 let up = [0.0, 1.0, 0.0];
2387 let fw = [0.0, 0.0, 1.0];
2388 let centre = (h / 2 * w + w / 2) as usize;
2389
2390 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_FF_00_00));
2392 let sh_op = SpriteShade {
2393 materials: &table,
2394 lights: CpuLights::default(),
2395 material: 0,
2396 alpha_mul: 255,
2397 tint: 0x00FF_FFFF,
2398 shadow: None,
2399 };
2400 let _ = draw_sprite_dense_shaded(
2401 &mut fb,
2402 &mut zb,
2403 w as usize,
2404 w,
2405 h,
2406 &cs,
2407 &cfg,
2408 &backdrop,
2409 [0.0, 80.0, 0.0],
2410 id,
2411 up,
2412 fw,
2413 0,
2414 Some(sh_op),
2415 );
2416 let after_backdrop = fb[centre];
2417 assert_eq!(
2418 after_backdrop & 0x00ff_ffff,
2419 0x00FF_0000,
2420 "backdrop red must be drawn first"
2421 );
2422
2423 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_00_FF_FF));
2425 let sh_gl = SpriteShade {
2426 materials: &table,
2427 lights: CpuLights::default(),
2428 material: 1,
2429 alpha_mul: 255,
2430 tint: 0x00FF_FFFF,
2431 shadow: None,
2432 };
2433 let wrote = draw_sprite_dense_shaded(
2434 &mut fb,
2435 &mut zb,
2436 w as usize,
2437 w,
2438 h,
2439 &cs,
2440 &cfg,
2441 &glass,
2442 [0.0, 40.0, 0.0],
2443 id,
2444 up,
2445 fw,
2446 0,
2447 Some(sh_gl),
2448 );
2449 let _ = wrote;
2450 let after_glass = fb[centre];
2451 assert_ne!(
2452 after_glass, after_backdrop,
2453 "glass must tint the backdrop (composite over it)"
2454 );
2455 assert!(
2457 (after_glass >> 16) & 0xff < 0xFF,
2458 "glass should reduce the backdrop's red (got {after_glass:08x})"
2459 );
2460 }
2461
2462 #[test]
2465 fn from_kv6_with_materials_classifies_by_color() {
2466 let col = 0x80_AA_BB_CC;
2467 let kv6 = Kv6::solid_cube(6, col);
2468 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(0x00AA_BBCC, 2)]);
2469 assert_eq!(
2470 dense.mat.len(),
2471 dense.col.len(),
2472 "per-voxel mat array sized"
2473 );
2474 let mut solids = 0;
2475 for idx in 0..dense.occ.len() {
2476 if dense.occ[idx] {
2477 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2478 solids += 1;
2479 }
2480 }
2481 assert!(solids > 0, "cube has solid voxels");
2482 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(0x0012_3456, 5)]);
2484 assert!(
2485 dense0.mat.iter().all(|&m| m == 0),
2486 "unmapped colour → material 0"
2487 );
2488 }
2489
2490 #[test]
2495 fn per_voxel_material_matches_uniform_when_homogeneous() {
2496 let mut table = MaterialTable::new();
2497 table.set(1, Material::alpha_blend(120));
2498 let col = 0x80_30_A0_F0;
2499 let kv6 = Kv6::solid_cube(10, col);
2500 let (w, h) = (64u32, 64u32);
2501 let n = (w * h) as usize;
2502 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2503 let cfg = settings(w, h);
2504 let (pos, s, hh, f) = (
2505 [0.0, 40.0, 0.0],
2506 [1.0, 0.0, 0.0],
2507 [0.0, 1.0, 0.0],
2508 [0.0, 0.0, 1.0],
2509 );
2510 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2511 let mut fb = vec![0x80_10_10_10u32; n];
2512 let mut zb = vec![f32::INFINITY; n];
2513 let sh = SpriteShade {
2514 materials: &table,
2515 lights: CpuLights::default(),
2516 material,
2517 alpha_mul: 255,
2518 tint: 0x00FF_FFFF,
2519 shadow: None,
2520 };
2521 let _ = draw_sprite_dense_shaded(
2522 &mut fb,
2523 &mut zb,
2524 w as usize,
2525 w,
2526 h,
2527 &cs,
2528 &cfg,
2529 dense,
2530 pos,
2531 s,
2532 hh,
2533 f,
2534 0,
2535 Some(sh),
2536 );
2537 fb
2538 };
2539 let pv = render(
2542 &SpriteDense::from_kv6_with_materials(&kv6, &[(col & 0xff_ffff, 1)]),
2543 0,
2544 );
2545 let un = render(&SpriteDense::from_kv6(&kv6), 1);
2547 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2548 let centre = (h / 2 * w + w / 2) as usize;
2550 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2551 }
2552
2553 #[test]
2558 fn clip_flipbook_with_materials_classifies_every_frame() {
2559 let dims = [6u32, 6, 6];
2560 let glass = 0x00AA_BBCC;
2561 let glass_lit = 0x80AA_BBCC;
2562 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2564 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2565 let clip = VoxelClip::from_frames(
2566 dims,
2567 [3.0, 3.0, 3.0],
2568 1.0,
2569 LoopMode::Loop,
2570 &[f0, f1],
2571 &[],
2572 33,
2573 0,
2574 );
2575 let decoded = clip.decode().expect("decode");
2576
2577 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2578 assert_eq!(book.frame_count(), 2);
2579 for fr in 0..2 {
2580 let dense = book.frame(fr).expect("frame in range");
2581 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2582 let mut solids = 0;
2583 for idx in 0..dense.occ.len() {
2584 if dense.occ[idx] {
2585 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2586 solids += 1;
2587 }
2588 }
2589 assert!(solids > 0, "frame {fr} has solid voxels");
2590 }
2591
2592 let plain = ClipFlipbook::from_decoded(&decoded);
2594 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2595 for fr in 0..2 {
2596 assert!(plain.frame(fr).unwrap().mat.is_empty());
2597 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2598 }
2599 }
2600}