1use roxlap_formats::kv6::Kv6;
23use roxlap_formats::material::{material_for_color, BlendMode, MaterialTable};
24use roxlap_formats::sprite::{Sprite, SPRITE_FLAG_INVISIBLE, SPRITE_FLAG_NO_Z};
25use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
26
27use crate::camera_math::CameraState;
28use crate::dda::{dda_setup, intersect_aabb, min_axis, pixel_ray, shade};
29use crate::opticast::OpticastSettings;
30use crate::raster_target::RasterTarget;
31
32const NEAR_Z: f32 = 1.0;
35
36#[inline]
42fn full_bright(col: u32) -> u32 {
43 (col & 0x00ff_ffff) | 0x8000_0000
44}
45
46pub struct SpriteDense {
55 dims: [i32; 3],
56 occ: Vec<bool>,
57 col: Vec<u32>,
58 mat: Vec<u8>,
64 pivot: [f32; 3],
65}
66
67impl SpriteDense {
68 #[must_use]
70 #[allow(clippy::cast_possible_wrap)]
71 pub fn from_kv6(kv6: &Kv6) -> Self {
72 let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
73 let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
74 let mut occ = vec![false; n];
75 let mut col = vec![0u32; n];
76 let mut vi = 0usize;
77 for x in 0..kv6.xsiz as usize {
78 for y in 0..kv6.ysiz as usize {
79 let cnt = usize::from(kv6.ylen[x][y]);
80 for _ in 0..cnt {
81 let v = kv6.voxels[vi];
82 vi += 1;
83 let z = i32::from(v.z);
84 if z >= 0 && z < dims[2] {
85 let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
86 occ[idx] = true;
87 col[idx] = full_bright(v.col);
88 }
89 }
90 }
91 }
92 Self {
93 dims,
94 occ,
95 col,
96 mat: Vec::new(),
97 pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
98 }
99 }
100
101 #[must_use]
109 #[allow(clippy::cast_possible_wrap)]
110 pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(u32, u8)]) -> Self {
111 let mut dense = Self::from_kv6(kv6);
112 if !material_map.is_empty() {
113 let n = dense.col.len();
114 let mut mat = vec![0u8; n];
115 for (idx, slot) in mat.iter_mut().enumerate() {
116 if dense.occ[idx] {
117 *slot = material_for_color(material_map, dense.col[idx]);
118 }
119 }
120 dense.mat = mat;
121 }
122 dense
123 }
124
125 #[must_use]
131 #[allow(clippy::cast_possible_wrap)]
132 pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
133 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
134 let owpc = mz.div_ceil(32).max(1) as usize;
135 let n = (mx * my * mz) as usize;
136 let mut occ = vec![false; n];
137 let mut col = vec![0u32; n];
138 for col_idx in 0..(mx * my) as usize {
139 let x = col_idx as u32 % mx;
140 let y = col_idx as u32 / mx;
141 let run_start = frame.color_offsets[col_idx] as usize;
142 let mut k = 0usize;
143 for z in 0..mz {
144 let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
145 if (word >> (z & 31)) & 1 != 0 {
146 let idx = (((x * my + y) * mz) + z) as usize;
147 occ[idx] = true;
148 col[idx] = full_bright(frame.colors[run_start + k]);
149 k += 1;
150 }
151 }
152 }
153 Self {
154 dims: [mx as i32, my as i32, mz as i32],
155 occ,
156 col,
157 mat: Vec::new(),
158 pivot,
159 }
160 }
161
162 #[must_use]
168 pub fn from_voxel_frame_with_materials(
169 frame: &VoxelFrame,
170 dims: [u32; 3],
171 pivot: [f32; 3],
172 material_map: &[(u32, u8)],
173 ) -> Self {
174 let mut dense = Self::from_voxel_frame(frame, dims, pivot);
175 if !material_map.is_empty() {
176 let n = dense.col.len();
177 let mut mat = vec![0u8; n];
178 for (idx, slot) in mat.iter_mut().enumerate() {
179 if dense.occ[idx] {
180 *slot = material_for_color(material_map, dense.col[idx]);
181 }
182 }
183 dense.mat = mat;
184 }
185 dense
186 }
187
188 #[inline]
189 #[allow(clippy::cast_sign_loss)]
190 fn idx_of(&self, c: [i32; 3]) -> usize {
191 ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
192 }
193
194 #[inline]
195 fn at(&self, c: [i32; 3]) -> Option<u32> {
196 let idx = self.idx_of(c);
197 self.occ[idx].then(|| self.col[idx])
198 }
199}
200
201fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
204 let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
205 + f[0] * (s[1] * h[2] - h[1] * s[2]);
206 if det.abs() < 1e-12 {
207 return None;
208 }
209 let inv = 1.0 / det;
210 Some([
211 [
212 (h[1] * f[2] - f[1] * h[2]) * inv,
213 -(h[0] * f[2] - f[0] * h[2]) * inv,
214 (h[0] * f[1] - f[0] * h[1]) * inv,
215 ],
216 [
217 -(s[1] * f[2] - f[1] * s[2]) * inv,
218 (s[0] * f[2] - f[0] * s[2]) * inv,
219 -(s[0] * f[1] - f[0] * s[1]) * inv,
220 ],
221 [
222 (s[1] * h[2] - h[1] * s[2]) * inv,
223 -(s[0] * h[2] - h[0] * s[2]) * inv,
224 (s[0] * h[1] - h[0] * s[1]) * inv,
225 ],
226 ])
227}
228
229#[inline]
230fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
231 [
232 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
233 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
234 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
235 ]
236}
237
238#[allow(clippy::cast_possible_truncation)]
242fn cast_local(dense: &SpriteDense, origin: [f32; 3], dir: [f32; 3]) -> Option<(u32, f32)> {
243 #[allow(clippy::cast_precision_loss)]
244 let hi = [
245 dense.dims[0] as f32,
246 dense.dims[1] as f32,
247 dense.dims[2] as f32,
248 ];
249 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
250 let start = t0 + 1e-4;
251 let p = [
252 origin[0] + dir[0] * start,
253 origin[1] + dir[1] * start,
254 origin[2] + dir[2] * start,
255 ];
256 let mut cell = [
257 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
258 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
259 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
260 ];
261 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
262 let mut t_curr = t0;
263 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
264 for _ in 0..max_steps {
265 if cell[0] < 0
266 || cell[0] >= dense.dims[0]
267 || cell[1] < 0
268 || cell[1] >= dense.dims[1]
269 || cell[2] < 0
270 || cell[2] >= dense.dims[2]
271 || t_curr > t1
272 {
273 return None;
274 }
275 if let Some(color) = dense.at(cell) {
276 return Some((color, t_curr));
277 }
278 let axis = min_axis(t_max);
279 t_curr = t_max[axis];
280 cell[axis] += step[axis];
281 t_max[axis] += t_delta[axis];
282 }
283 None
284}
285
286#[derive(Clone, Copy)]
293pub struct SpriteShade<'a> {
294 pub materials: &'a MaterialTable,
296 pub material: u8,
299 pub alpha_mul: u8,
302}
303
304struct LayerAccum {
306 rgb: [f32; 3],
309 trans: f32,
311 opaque: Option<(u32, f32)>,
316}
317
318#[inline]
321fn rgb_to_f32(c: u32) -> [f32; 3] {
322 [
323 ((c >> 16) & 0xff) as f32 / 255.0,
324 ((c >> 8) & 0xff) as f32 / 255.0,
325 (c & 0xff) as f32 / 255.0,
326 ]
327}
328
329#[inline]
332#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
333fn f32_to_rgb(c: [f32; 3]) -> u32 {
334 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
335 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
336}
337
338#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
345fn cast_local_layers(
346 dense: &SpriteDense,
347 origin: [f32; 3],
348 dir: [f32; 3],
349 fwd_dot: f32,
350 max_t: f32,
351 shade_ctx: SpriteShade,
352) -> Option<LayerAccum> {
353 #[allow(clippy::cast_precision_loss)]
354 let hi = [
355 dense.dims[0] as f32,
356 dense.dims[1] as f32,
357 dense.dims[2] as f32,
358 ];
359 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
360 let start = t0 + 1e-4;
361 let p = [
362 origin[0] + dir[0] * start,
363 origin[1] + dir[1] * start,
364 origin[2] + dir[2] * start,
365 ];
366 let mut cell = [
367 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
368 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
369 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
370 ];
371 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
372 let mut t_curr = t0;
373 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
374
375 let mut acc = LayerAccum {
376 rgb: [0.0; 3],
377 trans: 1.0,
378 opaque: None,
379 };
380 let mut touched = false;
381 let mut prev_solid = false;
392 let mut prev_mat = 0u8;
393 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
396
397 for _ in 0..max_steps {
398 if cell[0] < 0
399 || cell[0] >= dense.dims[0]
400 || cell[1] < 0
401 || cell[1] >= dense.dims[1]
402 || cell[2] < 0
403 || cell[2] >= dense.dims[2]
404 || t_curr > t1
405 {
406 break;
407 }
408 let depth = t_curr * fwd_dot;
411 if depth >= max_t {
412 break;
413 }
414 let exit_axis = min_axis(t_max);
417 let t_exit = t_max[exit_axis];
418 let idx = dense.idx_of(cell);
419 let solid_here = dense.occ[idx];
420 if solid_here && depth >= NEAR_Z {
421 let mat_id = if dense.mat.is_empty() {
422 shade_ctx.material
423 } else {
424 dense.mat[idx]
425 };
426 let m = shade_ctx.materials.get(mat_id);
427 if m.is_opaque() {
428 acc.opaque = Some((shade(dense.col[idx], 0), t_curr));
429 touched = true;
430 break;
431 }
432 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
433 if m.mode == BlendMode::Volumetric {
434 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
438 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
439 let lit = rgb_to_f32(shade(dense.col[idx], 0));
440 acc.rgb[0] += acc.trans * eff_a * lit[0];
441 acc.rgb[1] += acc.trans * eff_a * lit[1];
442 acc.rgb[2] += acc.trans * eff_a * lit[2];
443 acc.trans *= 1.0 - eff_a;
444 touched = true;
445 prev_mat = mat_id;
446 if acc.trans < 1.0 / 256.0 {
447 break;
448 }
449 } else if !prev_solid || mat_id != prev_mat {
450 let lit = rgb_to_f32(shade(dense.col[idx], 0));
453 acc.rgb[0] += acc.trans * a * lit[0];
454 acc.rgb[1] += acc.trans * a * lit[1];
455 acc.rgb[2] += acc.trans * a * lit[2];
456 if m.mode == BlendMode::AlphaBlend {
457 acc.trans *= 1.0 - a; }
459 touched = true;
460 prev_mat = mat_id;
461 if acc.trans < 1.0 / 256.0 {
462 break;
463 }
464 }
465 }
466 prev_solid = solid_here;
467 t_curr = t_exit;
468 cell[exit_axis] += step[exit_axis];
469 t_max[exit_axis] += t_delta[exit_axis];
470 }
471
472 touched.then_some(acc)
473}
474
475#[allow(
485 clippy::too_many_arguments,
486 clippy::cast_possible_truncation,
487 clippy::cast_sign_loss
488)]
489#[must_use]
490pub fn draw_sprite_dda(
491 fb: &mut [u32],
492 zb: &mut [f32],
493 pitch_pixels: usize,
494 width: u32,
495 height: u32,
496 cam: &CameraState,
497 settings: &OpticastSettings,
498 sprite: &Sprite,
499) -> u32 {
500 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
501 return 0;
502 }
503 draw_sprite_dda_shaded(
504 fb,
505 zb,
506 pitch_pixels,
507 width,
508 height,
509 cam,
510 settings,
511 sprite,
512 None,
513 )
514}
515
516#[allow(clippy::too_many_arguments)]
521#[must_use]
522pub fn draw_sprite_dda_shaded(
523 fb: &mut [u32],
524 zb: &mut [f32],
525 pitch_pixels: usize,
526 width: u32,
527 height: u32,
528 cam: &CameraState,
529 settings: &OpticastSettings,
530 sprite: &Sprite,
531 shade_ctx: Option<SpriteShade>,
532) -> u32 {
533 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
534 return 0;
535 }
536 let dense = if sprite.material_map.is_empty() {
541 SpriteDense::from_kv6(&sprite.kv6)
542 } else {
543 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
544 };
545 draw_sprite_dense_shaded(
546 fb,
547 zb,
548 pitch_pixels,
549 width,
550 height,
551 cam,
552 settings,
553 &dense,
554 sprite.p,
555 sprite.s,
556 sprite.h,
557 sprite.f,
558 sprite.flags,
559 shade_ctx,
560 )
561}
562
563#[allow(clippy::too_many_arguments)]
572#[must_use]
573pub fn draw_sprite_dense(
574 fb: &mut [u32],
575 zb: &mut [f32],
576 pitch_pixels: usize,
577 width: u32,
578 height: u32,
579 cam: &CameraState,
580 settings: &OpticastSettings,
581 dense: &SpriteDense,
582 pos: [f32; 3],
583 s: [f32; 3],
584 h: [f32; 3],
585 f: [f32; 3],
586 flags: u32,
587) -> u32 {
588 draw_sprite_dense_shaded(
589 fb,
590 zb,
591 pitch_pixels,
592 width,
593 height,
594 cam,
595 settings,
596 dense,
597 pos,
598 s,
599 h,
600 f,
601 flags,
602 None,
603 )
604}
605
606#[allow(
618 clippy::too_many_arguments,
619 clippy::cast_possible_truncation,
620 clippy::cast_sign_loss
621)]
622#[must_use]
623pub fn draw_sprite_dense_shaded(
624 fb: &mut [u32],
625 zb: &mut [f32],
626 pitch_pixels: usize,
627 width: u32,
628 height: u32,
629 cam: &CameraState,
630 settings: &OpticastSettings,
631 dense: &SpriteDense,
632 pos: [f32; 3],
633 s: [f32; 3],
634 h: [f32; 3],
635 f: [f32; 3],
636 flags: u32,
637 shade_ctx: Option<SpriteShade>,
638) -> u32 {
639 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
640 return 0;
641 }
642 let Some(minv) = invert_basis(s, h, f) else {
643 return 0;
644 };
645 let pivot = dense.pivot;
646 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
647
648 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
650 return 0;
651 };
652
653 let layers =
659 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
660
661 debug_assert_eq!(fb.len(), zb.len());
662 let target = RasterTarget::new(fb, zb);
663 let mut written = 0u32;
664 for py in rect.1..rect.3 {
665 let row = py as usize * pitch_pixels;
666 for px in rect.0..rect.2 {
667 let (origin, dir) = pixel_ray(cam, settings, px, py);
668 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
670 let ol = mat_apply(&minv, rel);
671 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
672 let dir_local = mat_apply(&minv, dir);
673 let fwd_dot =
674 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
675 let idx = row + px as usize;
676
677 if let Some(shade_ctx) = layers {
678 if fwd_dot <= 1e-6 {
680 continue;
681 }
682 let max_t = if no_z {
687 f32::INFINITY
688 } else {
689 unsafe { target.read_depth(idx) }
690 };
691 let Some(acc) =
692 cast_local_layers(dense, origin_local, dir_local, fwd_dot, max_t, shade_ctx)
693 else {
694 continue;
695 };
696 let wrote = unsafe {
698 match acc.opaque {
699 Some((bg_color, t)) => {
700 let bg = rgb_to_f32(bg_color);
703 let out = f32_to_rgb([
704 acc.rgb[0] + acc.trans * bg[0],
705 acc.rgb[1] + acc.trans * bg[1],
706 acc.rgb[2] + acc.trans * bg[2],
707 ]);
708 let depth = t * fwd_dot;
709 if no_z {
710 target.write_color(idx, out);
711 target.write_depth(idx, depth);
712 true
713 } else {
714 target.z_test_write(idx, out, depth)
715 }
716 }
717 None => {
718 let bg = rgb_to_f32(target.read_color(idx));
723 let out = f32_to_rgb([
724 acc.rgb[0] + acc.trans * bg[0],
725 acc.rgb[1] + acc.trans * bg[1],
726 acc.rgb[2] + acc.trans * bg[2],
727 ]);
728 target.write_color(idx, out);
729 true
730 }
731 }
732 };
733 written += u32::from(wrote);
734 } else {
735 let Some((color, t)) = cast_local(dense, origin_local, dir_local) else {
737 continue;
738 };
739 let depth = t * fwd_dot;
740 if depth < NEAR_Z {
741 continue;
742 }
743 let lit = shade(color, 0);
744 let wrote = unsafe {
747 if no_z {
748 target.write_color(idx, lit);
749 target.write_depth(idx, depth);
750 true
751 } else {
752 target.z_test_write(idx, lit, depth)
753 }
754 };
755 written += u32::from(wrote);
756 }
757 }
758 }
759 written
760}
761
762#[allow(
766 clippy::cast_possible_truncation,
767 clippy::cast_sign_loss,
768 clippy::cast_precision_loss
769)]
770fn project_screen_rect(
771 dense: &SpriteDense,
772 pos: [f32; 3],
773 s: [f32; 3],
774 h: [f32; 3],
775 f: [f32; 3],
776 cam: &CameraState,
777 settings: &OpticastSettings,
778 width: u32,
779 height: u32,
780) -> Option<(u32, u32, u32, u32)> {
781 let (xs, ys, zs) = (
782 dense.dims[0] as f32,
783 dense.dims[1] as f32,
784 dense.dims[2] as f32,
785 );
786 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
787 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
788 let mut all_front = true;
789 for &cx in &[0.0, xs] {
790 for &cy in &[0.0, ys] {
791 for &cz in &[0.0, zs] {
792 let lx = cx - xp;
794 let ly = cy - yp;
795 let lz = cz - zp;
796 let world = [
797 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
798 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
799 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
800 ];
801 let rel = [
802 world[0] - cam.pos[0],
803 world[1] - cam.pos[1],
804 world[2] - cam.pos[2],
805 ];
806 let cz_cam =
807 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
808 if cz_cam < NEAR_Z {
809 all_front = false;
810 continue;
811 }
812 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
813 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
814 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
815 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
816 x0 = x0.min(sx);
817 y0 = y0.min(sy);
818 x1 = x1.max(sx);
819 y1 = y1.max(sy);
820 }
821 }
822 }
823 let (w, h) = (width as f32, height as f32);
824 let (rx0, ry0, rx1, ry1) = if all_front {
825 (
826 (x0 - 1.0).max(0.0),
827 (y0 - 1.0).max(0.0),
828 (x1 + 1.0).min(w),
829 (y1 + 1.0).min(h),
830 )
831 } else {
832 (0.0, 0.0, w, h)
834 };
835 if rx0 >= rx1 || ry0 >= ry1 {
836 return None;
837 }
838 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
839}
840
841pub struct ClipFlipbook {
848 frames: Vec<SpriteDense>,
849}
850
851impl ClipFlipbook {
852 #[must_use]
855 pub fn empty() -> Self {
856 Self { frames: Vec::new() }
857 }
858
859 #[must_use]
861 pub fn from_decoded(clip: &DecodedClip) -> Self {
862 Self::from_decoded_with_materials(clip, &[])
863 }
864
865 #[must_use]
871 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(u32, u8)]) -> Self {
872 let frames = clip
873 .frames
874 .iter()
875 .map(|frame| {
876 SpriteDense::from_voxel_frame_with_materials(
877 frame,
878 clip.dims,
879 clip.pivot,
880 material_map,
881 )
882 })
883 .collect();
884 Self { frames }
885 }
886
887 #[must_use]
888 pub fn frame_count(&self) -> usize {
889 self.frames.len()
890 }
891
892 #[must_use]
894 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
895 self.frames.get(frame)
896 }
897
898 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
902 match self.frames.get_mut(frame) {
903 Some(slot) => {
904 *slot = dense;
905 true
906 }
907 None => false,
908 }
909 }
910
911 #[allow(clippy::too_many_arguments)]
915 #[must_use]
916 pub fn draw_frame(
917 &self,
918 fb: &mut [u32],
919 zb: &mut [f32],
920 pitch_pixels: usize,
921 width: u32,
922 height: u32,
923 cam: &CameraState,
924 settings: &OpticastSettings,
925 frame: usize,
926 pos: [f32; 3],
927 s: [f32; 3],
928 h: [f32; 3],
929 f: [f32; 3],
930 flags: u32,
931 ) -> u32 {
932 self.draw_frame_shaded(
933 fb,
934 zb,
935 pitch_pixels,
936 width,
937 height,
938 cam,
939 settings,
940 frame,
941 pos,
942 s,
943 h,
944 f,
945 flags,
946 None,
947 )
948 }
949
950 #[allow(clippy::too_many_arguments)]
955 #[must_use]
956 pub fn draw_frame_shaded(
957 &self,
958 fb: &mut [u32],
959 zb: &mut [f32],
960 pitch_pixels: usize,
961 width: u32,
962 height: u32,
963 cam: &CameraState,
964 settings: &OpticastSettings,
965 frame: usize,
966 pos: [f32; 3],
967 s: [f32; 3],
968 h: [f32; 3],
969 f: [f32; 3],
970 flags: u32,
971 shade_ctx: Option<SpriteShade>,
972 ) -> u32 {
973 let Some(dense) = self.frames.get(frame) else {
974 return 0;
975 };
976 draw_sprite_dense_shaded(
977 fb,
978 zb,
979 pitch_pixels,
980 width,
981 height,
982 cam,
983 settings,
984 dense,
985 pos,
986 s,
987 h,
988 f,
989 flags,
990 shade_ctx,
991 )
992 }
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998 use crate::camera_math;
999 use crate::Camera;
1000 use roxlap_formats::kv6::Kv6;
1001 use roxlap_formats::material::{Material, MaterialTable};
1002 use roxlap_formats::sprite::Sprite;
1003 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1004
1005 fn settings(w: u32, h: u32) -> OpticastSettings {
1006 OpticastSettings::for_oracle_framebuffer(w, h)
1007 }
1008
1009 fn cam_looking_y() -> Camera {
1011 Camera {
1012 pos: [0.0, 0.0, 0.0],
1013 right: [1.0, 0.0, 0.0],
1014 down: [0.0, 0.0, 1.0],
1015 forward: [0.0, 1.0, 0.0],
1016 }
1017 }
1018
1019 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1021 let owpc = dims[2].div_ceil(32).max(1) as usize;
1022 let cols = (dims[0] * dims[1]) as usize;
1023 let mut occupancy = vec![0u32; cols * owpc];
1024 let mut color_offsets = vec![0u32; cols + 1];
1025 let mut colors = Vec::new();
1026 for y in 0..dims[1] {
1027 for x in 0..dims[0] {
1028 let col = (x + y * dims[0]) as usize;
1029 color_offsets[col] = colors.len() as u32;
1030 for z in 0..dims[2] {
1031 if let Some(c) = fill(x, y, z) {
1032 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1033 colors.push(c);
1034 }
1035 }
1036 }
1037 }
1038 color_offsets[cols] = colors.len() as u32;
1039 VoxelFrame {
1040 occupancy,
1041 colors,
1042 color_offsets,
1043 }
1044 }
1045
1046 #[test]
1051 fn clip_flipbook_frames_render_differently() {
1052 let dims = [8u32, 8, 8];
1053 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(
1056 dims,
1057 [4.0, 4.0, 4.0],
1058 1.0,
1059 LoopMode::Loop,
1060 &[f0, f1],
1061 &[],
1062 33,
1063 0,
1064 );
1065 let decoded = clip.decode().expect("decode");
1066 let book = ClipFlipbook::from_decoded(&decoded);
1067 assert_eq!(book.frame_count(), 2);
1068 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1069
1070 let (w, h) = (64u32, 64u32);
1071 let n = (w * h) as usize;
1072 let cam = cam_looking_y();
1073 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1074 let cfg = settings(w, h);
1075 let pose = [0.0, 40.0, 0.0];
1076 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1077
1078 let render = |frame: usize| -> Vec<u32> {
1079 let mut fb = vec![0u32; n];
1080 let mut zb = vec![f32::INFINITY; n];
1081 let wrote = book.draw_frame(
1082 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1083 );
1084 assert!(wrote > 0, "frame {frame} should draw some pixels");
1085 fb
1086 };
1087 let fb0 = render(0);
1088 let fb1 = render(1);
1089 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1090 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1093 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1094 let mut fb = vec![0u32; n];
1096 let mut zb = vec![f32::INFINITY; n];
1097 assert_eq!(
1098 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1099 0
1100 );
1101 }
1102
1103 #[test]
1104 fn clip_flipbook_set_frame_replaces_one_frame() {
1105 let dims = [8u32, 8, 8];
1108 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 =
1111 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1112 let decoded = clip.decode().unwrap();
1113 let mut book = ClipFlipbook::from_decoded(&decoded);
1114
1115 let (w, h) = (64u32, 64u32);
1116 let n = (w * h) as usize;
1117 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1118 let cfg = settings(w, h);
1119 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1120 let mut fb = vec![0u32; n];
1121 let mut zb = vec![f32::INFINITY; n];
1122 let _ = b.draw_frame(
1123 &mut fb,
1124 &mut zb,
1125 w as usize,
1126 w,
1127 h,
1128 &cs,
1129 &cfg,
1130 0,
1131 [0.0, 40.0, 0.0],
1132 [1.0, 0.0, 0.0],
1133 [0.0, 1.0, 0.0],
1134 [0.0, 0.0, 1.0],
1135 0,
1136 );
1137 fb
1138 };
1139
1140 let before = render0(&book);
1141 assert!(
1142 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1143 "frame 0 is red"
1144 );
1145
1146 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1148 assert!(book.set_frame(0, replacement));
1149 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1150 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1151
1152 let after = render0(&book);
1153 assert!(
1154 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1155 "frame 0 now green"
1156 );
1157 assert_ne!(before, after);
1158 }
1159
1160 #[test]
1163 fn cube_sprite_renders() {
1164 let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
1165 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1166 let (w, h) = (64u32, 64u32);
1167 let n = (w * h) as usize;
1168 let mut fb = vec![0u32; n];
1169 let mut zb = vec![f32::INFINITY; n];
1170 let cam = cam_looking_y();
1171 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1172 let wrote = draw_sprite_dda(
1173 &mut fb,
1174 &mut zb,
1175 w as usize,
1176 w,
1177 h,
1178 &cs,
1179 &settings(w, h),
1180 &sprite,
1181 );
1182
1183 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1184 let centre = (h / 2 * w + w / 2) as usize;
1185 assert_eq!(
1186 fb[centre] & 0x00ff_ffff,
1187 0x00_C0_40_20,
1188 "got {:08x}",
1189 fb[centre]
1190 );
1191 assert!(
1193 (zb[centre] - 36.0).abs() < 3.0,
1194 "centre depth {} not ≈ 36",
1195 zb[centre]
1196 );
1197 }
1198
1199 #[test]
1204 fn zero_high_byte_sprite_not_black() {
1205 let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
1206 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1207 let (w, h) = (64u32, 64u32);
1208 let n = (w * h) as usize;
1209 let mut fb = vec![0u32; n];
1210 let mut zb = vec![f32::INFINITY; n];
1211 let cam = cam_looking_y();
1212 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1213 let wrote = draw_sprite_dda(
1214 &mut fb,
1215 &mut zb,
1216 w as usize,
1217 w,
1218 h,
1219 &cs,
1220 &settings(w, h),
1221 &sprite,
1222 );
1223 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1224 let centre = (h / 2 * w + w / 2) as usize;
1225 assert_eq!(
1226 fb[centre] & 0x00ff_ffff,
1227 0x00_C0_40_20,
1228 "zero-high-byte sprite rendered as {:08x} (black bug)",
1229 fb[centre]
1230 );
1231 }
1232
1233 #[test]
1236 fn sprite_respects_zbuffer() {
1237 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1238 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1239 let (w, h) = (32u32, 32u32);
1240 let n = (w * h) as usize;
1241 let cam = cam_looking_y();
1242 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1243 let centre = (h / 2 * w + w / 2) as usize;
1244
1245 let mut fb = vec![0u32; n];
1247 let mut zb = vec![f32::INFINITY; n];
1248 fb[centre] = 0x80_11_22_33;
1249 zb[centre] = 10.0;
1250 let _ = draw_sprite_dda(
1251 &mut fb,
1252 &mut zb,
1253 w as usize,
1254 w,
1255 h,
1256 &cs,
1257 &settings(w, h),
1258 &sprite,
1259 );
1260 assert_eq!(
1261 fb[centre], 0x80_11_22_33,
1262 "near terrain must occlude sprite"
1263 );
1264
1265 let mut fb2 = vec![0u32; n];
1267 let mut zb2 = vec![f32::INFINITY; n];
1268 fb2[centre] = 0x80_11_22_33;
1269 zb2[centre] = 100.0;
1270 let _ = draw_sprite_dda(
1271 &mut fb2,
1272 &mut zb2,
1273 w as usize,
1274 w,
1275 h,
1276 &cs,
1277 &settings(w, h),
1278 &sprite,
1279 );
1280 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1281 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1282 }
1283
1284 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1287 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1288 for py in 0..h {
1289 for px in 0..w {
1290 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1291 x0 = x0.min(px);
1292 y0 = y0.min(py);
1293 x1 = x1.max(px);
1294 y1 = y1.max(py);
1295 }
1296 }
1297 }
1298 (x0, y0, x1, y1)
1299 }
1300
1301 #[test]
1306 fn posed_basis_reorients_silhouette() {
1307 let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
1310 let (w, h) = (64u32, 64u32);
1311 let n = (w * h) as usize;
1312 let cam = cam_looking_y();
1313 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1314
1315 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1317 let mut fb = vec![0u32; n];
1318 let mut zb = vec![f32::INFINITY; n];
1319 let _ = draw_sprite_dda(
1320 &mut fb,
1321 &mut zb,
1322 w as usize,
1323 w,
1324 h,
1325 &cs,
1326 &settings(w, h),
1327 &aa,
1328 );
1329 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1330 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1331 assert!(
1332 aa_wide > 4,
1333 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1334 );
1335
1336 let mut posed = aa.clone();
1339 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];
1343 let mut zb2 = vec![f32::INFINITY; n];
1344 let _ = draw_sprite_dda(
1345 &mut fb2,
1346 &mut zb2,
1347 w as usize,
1348 w,
1349 h,
1350 &cs,
1351 &settings(w, h),
1352 &posed,
1353 );
1354 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1355 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1356 assert!(
1357 posed_tall > 4,
1358 "posed box should be taller than wide (got h-w={posed_tall})"
1359 );
1360 }
1361
1362 #[test]
1365 fn degenerate_basis_draws_nothing() {
1366 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1367 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1368 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1370 let n = (w * h) as usize;
1371 let mut fb = vec![0u32; n];
1372 let mut zb = vec![f32::INFINITY; n];
1373 let cam = cam_looking_y();
1374 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1375 let wrote = draw_sprite_dda(
1376 &mut fb,
1377 &mut zb,
1378 w as usize,
1379 w,
1380 h,
1381 &cs,
1382 &settings(w, h),
1383 &sprite,
1384 );
1385 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1386 }
1387
1388 #[test]
1390 fn invisible_sprite_skipped() {
1391 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1392 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1393 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1394 let (w, h) = (32u32, 32u32);
1395 let n = (w * h) as usize;
1396 let mut fb = vec![0u32; n];
1397 let mut zb = vec![f32::INFINITY; n];
1398 let cam = cam_looking_y();
1399 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1400 let wrote = draw_sprite_dda(
1401 &mut fb,
1402 &mut zb,
1403 w as usize,
1404 w,
1405 h,
1406 &cs,
1407 &settings(w, h),
1408 &sprite,
1409 );
1410 assert_eq!(wrote, 0);
1411 }
1412
1413 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1419 let mut table = MaterialTable::new();
1420 table.set(1, mat);
1421 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1422 let (w, h) = (64u32, 64u32);
1423 let n = (w * h) as usize;
1424 let mut fb = vec![bg; n];
1425 let mut zb = vec![zb_v; n];
1426 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1427 let sh = SpriteShade {
1428 materials: &table,
1429 material: 1,
1430 alpha_mul,
1431 };
1432 let _ = draw_sprite_dense_shaded(
1433 &mut fb,
1434 &mut zb,
1435 w as usize,
1436 w,
1437 h,
1438 &cs,
1439 &settings(w, h),
1440 &dense,
1441 [0.0, 40.0, 0.0],
1442 [1.0, 0.0, 0.0],
1443 [0.0, 1.0, 0.0],
1444 [0.0, 0.0, 1.0],
1445 0,
1446 Some(sh),
1447 );
1448 (fb[(h / 2 * w + w / 2) as usize], fb)
1449 }
1450
1451 #[test]
1454 fn additive_sprite_brightens_background() {
1455 let bg = 0x80_20_20_20;
1456 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1457 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1458 assert!(
1459 cr > 0x20 && cg > 0x20 && cb >= 0x20,
1460 "centre {centre:08x} should be brighter than bg"
1461 );
1462 assert!(
1464 cr >= cg && cr >= cb,
1465 "additive of a red-dominant cube stays red-dominant"
1466 );
1467 }
1468
1469 #[test]
1472 fn alpha_blend_sprite_between_bg_and_color() {
1473 let bg = 0x80_20_20_20;
1474 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
1475 let cr = (centre >> 16) & 0xff;
1476 assert!(
1477 cr > 0x20,
1478 "blended red must rise above bg 0x20 (got {cr:02x})"
1479 );
1480 assert!(
1481 cr < 0xC0,
1482 "blended red must stay below opaque 0xC0 (got {cr:02x})"
1483 );
1484 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
1486 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
1487 }
1488
1489 #[test]
1492 fn alpha_mul_scales_opacity() {
1493 let bg = 0x80_20_20_20;
1494 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
1495 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
1496 let r_full = (full >> 16) & 0xff;
1497 let r_faded = (faded >> 16) & 0xff;
1498 assert!(
1500 r_full > r_faded,
1501 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
1502 );
1503 assert!(r_faded > 0x20, "even faded lifts above bg");
1504 }
1505
1506 #[test]
1510 fn opaque_shade_ctx_matches_plain_path() {
1511 let table = MaterialTable::new();
1512 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1513 let (w, h) = (64u32, 64u32);
1514 let n = (w * h) as usize;
1515 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1516 let pose = (
1517 [0.0, 40.0, 0.0],
1518 [1.0, 0.0, 0.0],
1519 [0.0, 1.0, 0.0],
1520 [0.0, 0.0, 1.0],
1521 );
1522
1523 let mut fb_plain = vec![0u32; n];
1524 let mut zb_plain = vec![f32::INFINITY; n];
1525 let _ = draw_sprite_dense(
1526 &mut fb_plain,
1527 &mut zb_plain,
1528 w as usize,
1529 w,
1530 h,
1531 &cs,
1532 &settings(w, h),
1533 &dense,
1534 pose.0,
1535 pose.1,
1536 pose.2,
1537 pose.3,
1538 0,
1539 );
1540
1541 let mut fb_sh = vec![0u32; n];
1542 let mut zb_sh = vec![f32::INFINITY; n];
1543 let sh = SpriteShade {
1544 materials: &table,
1545 material: 0, alpha_mul: 255,
1547 };
1548 let _ = draw_sprite_dense_shaded(
1549 &mut fb_sh,
1550 &mut zb_sh,
1551 w as usize,
1552 w,
1553 h,
1554 &cs,
1555 &settings(w, h),
1556 &dense,
1557 pose.0,
1558 pose.1,
1559 pose.2,
1560 pose.3,
1561 0,
1562 Some(sh),
1563 );
1564
1565 assert_eq!(
1566 fb_plain, fb_sh,
1567 "opaque shade-ctx must match the plain path bit-for-bit"
1568 );
1569 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
1570 }
1571
1572 #[test]
1576 fn translucent_sprite_occluded_by_near_terrain() {
1577 let bg = 0x80_20_20_20;
1578 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
1579 assert_eq!(
1580 centre, bg,
1581 "near terrain (z=5) must occlude the sprite at y≈36"
1582 );
1583 }
1584
1585 #[test]
1591 fn per_span_thickness_independent() {
1592 fn centre(ysiz: u32) -> u32 {
1593 let mut table = MaterialTable::new();
1594 table.set(1, Material::alpha_blend(128));
1595 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, 0x80_C0_40_20));
1596 let (w, h) = (64u32, 64u32);
1597 let n = (w * h) as usize;
1598 let mut fb = vec![0x80_10_10_10u32; n];
1599 let mut zb = vec![f32::INFINITY; n];
1600 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1601 let sh = SpriteShade {
1602 materials: &table,
1603 material: 1,
1604 alpha_mul: 255,
1605 };
1606 let _ = draw_sprite_dense_shaded(
1607 &mut fb,
1608 &mut zb,
1609 w as usize,
1610 w,
1611 h,
1612 &cs,
1613 &settings(w, h),
1614 &dense,
1615 [0.0, 40.0, 0.0],
1616 [1.0, 0.0, 0.0],
1617 [0.0, 1.0, 0.0],
1618 [0.0, 0.0, 1.0],
1619 0,
1620 Some(sh),
1621 );
1622 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
1623 }
1624 assert_eq!(
1628 centre(1),
1629 centre(2),
1630 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
1631 );
1632 }
1633
1634 #[test]
1639 fn volumetric_thickness_deepens_opacity() {
1640 fn red_at(depth: u32) -> u32 {
1643 let mut table = MaterialTable::new();
1644 table.set(1, Material::volumetric(128));
1645 let kv6 =
1650 Kv6::from_fn_keep_interior(8, depth, 8, |_, _, _| Some(0x80_C0_20_20), |_| true);
1651 let dense = SpriteDense::from_kv6(&kv6);
1652 let (w, h) = (64u32, 64u32);
1653 let n = (w * h) as usize;
1654 let mut fb = vec![0x80_10_10_10u32; n];
1655 let mut zb = vec![f32::INFINITY; n];
1656 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1657 let sh = SpriteShade {
1658 materials: &table,
1659 material: 1,
1660 alpha_mul: 255,
1661 };
1662 let _ = draw_sprite_dense_shaded(
1663 &mut fb,
1664 &mut zb,
1665 w as usize,
1666 w,
1667 h,
1668 &cs,
1669 &settings(w, h),
1670 &dense,
1671 [0.0, 40.0, 0.0],
1672 [1.0, 0.0, 0.0],
1673 [0.0, 1.0, 0.0],
1674 [0.0, 0.0, 1.0],
1675 0,
1676 Some(sh),
1677 );
1678 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
1679 }
1680 let shallow = red_at(1);
1681 let deep = red_at(12);
1682 assert!(
1685 shallow > 0x10,
1686 "even a 1-deep volume tints (got {shallow:02x})"
1687 );
1688 assert!(
1689 deep > shallow,
1690 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
1691 );
1692 }
1693
1694 #[test]
1699 fn translucent_sprite_tints_opaque_sprite_behind() {
1700 let mut table = MaterialTable::new();
1701 table.set(1, Material::alpha_blend(128));
1702 let (w, h) = (64u32, 64u32);
1703 let n = (w * h) as usize;
1704 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
1706 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1707 let cfg = settings(w, h);
1708 let id = [1.0, 0.0, 0.0];
1709 let up = [0.0, 1.0, 0.0];
1710 let fw = [0.0, 0.0, 1.0];
1711 let centre = (h / 2 * w + w / 2) as usize;
1712
1713 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_FF_00_00));
1715 let sh_op = SpriteShade {
1716 materials: &table,
1717 material: 0,
1718 alpha_mul: 255,
1719 };
1720 let _ = draw_sprite_dense_shaded(
1721 &mut fb,
1722 &mut zb,
1723 w as usize,
1724 w,
1725 h,
1726 &cs,
1727 &cfg,
1728 &backdrop,
1729 [0.0, 80.0, 0.0],
1730 id,
1731 up,
1732 fw,
1733 0,
1734 Some(sh_op),
1735 );
1736 let after_backdrop = fb[centre];
1737 assert_eq!(
1738 after_backdrop & 0x00ff_ffff,
1739 0x00FF_0000,
1740 "backdrop red must be drawn first"
1741 );
1742
1743 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_00_FF_FF));
1745 let sh_gl = SpriteShade {
1746 materials: &table,
1747 material: 1,
1748 alpha_mul: 255,
1749 };
1750 let wrote = draw_sprite_dense_shaded(
1751 &mut fb,
1752 &mut zb,
1753 w as usize,
1754 w,
1755 h,
1756 &cs,
1757 &cfg,
1758 &glass,
1759 [0.0, 40.0, 0.0],
1760 id,
1761 up,
1762 fw,
1763 0,
1764 Some(sh_gl),
1765 );
1766 let _ = wrote;
1767 let after_glass = fb[centre];
1768 assert_ne!(
1769 after_glass, after_backdrop,
1770 "glass must tint the backdrop (composite over it)"
1771 );
1772 assert!(
1774 (after_glass >> 16) & 0xff < 0xFF,
1775 "glass should reduce the backdrop's red (got {after_glass:08x})"
1776 );
1777 }
1778
1779 #[test]
1782 fn from_kv6_with_materials_classifies_by_color() {
1783 let col = 0x80_AA_BB_CC;
1784 let kv6 = Kv6::solid_cube(6, col);
1785 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(0x00AA_BBCC, 2)]);
1786 assert_eq!(
1787 dense.mat.len(),
1788 dense.col.len(),
1789 "per-voxel mat array sized"
1790 );
1791 let mut solids = 0;
1792 for idx in 0..dense.occ.len() {
1793 if dense.occ[idx] {
1794 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
1795 solids += 1;
1796 }
1797 }
1798 assert!(solids > 0, "cube has solid voxels");
1799 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(0x0012_3456, 5)]);
1801 assert!(
1802 dense0.mat.iter().all(|&m| m == 0),
1803 "unmapped colour → material 0"
1804 );
1805 }
1806
1807 #[test]
1812 fn per_voxel_material_matches_uniform_when_homogeneous() {
1813 let mut table = MaterialTable::new();
1814 table.set(1, Material::alpha_blend(120));
1815 let col = 0x80_30_A0_F0;
1816 let kv6 = Kv6::solid_cube(10, col);
1817 let (w, h) = (64u32, 64u32);
1818 let n = (w * h) as usize;
1819 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1820 let cfg = settings(w, h);
1821 let (pos, s, hh, f) = (
1822 [0.0, 40.0, 0.0],
1823 [1.0, 0.0, 0.0],
1824 [0.0, 1.0, 0.0],
1825 [0.0, 0.0, 1.0],
1826 );
1827 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
1828 let mut fb = vec![0x80_10_10_10u32; n];
1829 let mut zb = vec![f32::INFINITY; n];
1830 let sh = SpriteShade {
1831 materials: &table,
1832 material,
1833 alpha_mul: 255,
1834 };
1835 let _ = draw_sprite_dense_shaded(
1836 &mut fb,
1837 &mut zb,
1838 w as usize,
1839 w,
1840 h,
1841 &cs,
1842 &cfg,
1843 dense,
1844 pos,
1845 s,
1846 hh,
1847 f,
1848 0,
1849 Some(sh),
1850 );
1851 fb
1852 };
1853 let pv = render(
1856 &SpriteDense::from_kv6_with_materials(&kv6, &[(col & 0xff_ffff, 1)]),
1857 0,
1858 );
1859 let un = render(&SpriteDense::from_kv6(&kv6), 1);
1861 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
1862 let centre = (h / 2 * w + w / 2) as usize;
1864 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
1865 }
1866
1867 #[test]
1872 fn clip_flipbook_with_materials_classifies_every_frame() {
1873 let dims = [6u32, 6, 6];
1874 let glass = 0x00AA_BBCC;
1875 let glass_lit = 0x80AA_BBCC;
1876 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
1878 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
1879 let clip = VoxelClip::from_frames(
1880 dims,
1881 [3.0, 3.0, 3.0],
1882 1.0,
1883 LoopMode::Loop,
1884 &[f0, f1],
1885 &[],
1886 33,
1887 0,
1888 );
1889 let decoded = clip.decode().expect("decode");
1890
1891 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
1892 assert_eq!(book.frame_count(), 2);
1893 for fr in 0..2 {
1894 let dense = book.frame(fr).expect("frame in range");
1895 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
1896 let mut solids = 0;
1897 for idx in 0..dense.occ.len() {
1898 if dense.occ[idx] {
1899 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
1900 solids += 1;
1901 }
1902 }
1903 assert!(solids > 0, "frame {fr} has solid voxels");
1904 }
1905
1906 let plain = ClipFlipbook::from_decoded(&decoded);
1908 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
1909 for fr in 0..2 {
1910 assert!(plain.frame(fr).unwrap().mat.is_empty());
1911 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
1912 }
1913 }
1914}