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 #[inline]
163 #[allow(clippy::cast_sign_loss)]
164 fn idx_of(&self, c: [i32; 3]) -> usize {
165 ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
166 }
167
168 #[inline]
169 fn at(&self, c: [i32; 3]) -> Option<u32> {
170 let idx = self.idx_of(c);
171 self.occ[idx].then(|| self.col[idx])
172 }
173}
174
175fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
178 let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
179 + f[0] * (s[1] * h[2] - h[1] * s[2]);
180 if det.abs() < 1e-12 {
181 return None;
182 }
183 let inv = 1.0 / det;
184 Some([
185 [
186 (h[1] * f[2] - f[1] * h[2]) * inv,
187 -(h[0] * f[2] - f[0] * h[2]) * inv,
188 (h[0] * f[1] - f[0] * h[1]) * inv,
189 ],
190 [
191 -(s[1] * f[2] - f[1] * s[2]) * inv,
192 (s[0] * f[2] - f[0] * s[2]) * inv,
193 -(s[0] * f[1] - f[0] * s[1]) * inv,
194 ],
195 [
196 (s[1] * h[2] - h[1] * s[2]) * inv,
197 -(s[0] * h[2] - h[0] * s[2]) * inv,
198 (s[0] * h[1] - h[0] * s[1]) * inv,
199 ],
200 ])
201}
202
203#[inline]
204fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
205 [
206 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
207 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
208 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
209 ]
210}
211
212#[allow(clippy::cast_possible_truncation)]
216fn cast_local(dense: &SpriteDense, origin: [f32; 3], dir: [f32; 3]) -> Option<(u32, f32)> {
217 #[allow(clippy::cast_precision_loss)]
218 let hi = [
219 dense.dims[0] as f32,
220 dense.dims[1] as f32,
221 dense.dims[2] as f32,
222 ];
223 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
224 let start = t0 + 1e-4;
225 let p = [
226 origin[0] + dir[0] * start,
227 origin[1] + dir[1] * start,
228 origin[2] + dir[2] * start,
229 ];
230 let mut cell = [
231 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
232 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
233 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
234 ];
235 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
236 let mut t_curr = t0;
237 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
238 for _ in 0..max_steps {
239 if cell[0] < 0
240 || cell[0] >= dense.dims[0]
241 || cell[1] < 0
242 || cell[1] >= dense.dims[1]
243 || cell[2] < 0
244 || cell[2] >= dense.dims[2]
245 || t_curr > t1
246 {
247 return None;
248 }
249 if let Some(color) = dense.at(cell) {
250 return Some((color, t_curr));
251 }
252 let axis = min_axis(t_max);
253 t_curr = t_max[axis];
254 cell[axis] += step[axis];
255 t_max[axis] += t_delta[axis];
256 }
257 None
258}
259
260#[derive(Clone, Copy)]
267pub struct SpriteShade<'a> {
268 pub materials: &'a MaterialTable,
270 pub material: u8,
273 pub alpha_mul: u8,
276}
277
278struct LayerAccum {
280 rgb: [f32; 3],
283 trans: f32,
285 opaque: Option<(u32, f32)>,
290}
291
292#[inline]
295fn rgb_to_f32(c: u32) -> [f32; 3] {
296 [
297 ((c >> 16) & 0xff) as f32 / 255.0,
298 ((c >> 8) & 0xff) as f32 / 255.0,
299 (c & 0xff) as f32 / 255.0,
300 ]
301}
302
303#[inline]
306#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
307fn f32_to_rgb(c: [f32; 3]) -> u32 {
308 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
309 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
310}
311
312#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
319fn cast_local_layers(
320 dense: &SpriteDense,
321 origin: [f32; 3],
322 dir: [f32; 3],
323 fwd_dot: f32,
324 max_t: f32,
325 shade_ctx: SpriteShade,
326) -> Option<LayerAccum> {
327 #[allow(clippy::cast_precision_loss)]
328 let hi = [
329 dense.dims[0] as f32,
330 dense.dims[1] as f32,
331 dense.dims[2] as f32,
332 ];
333 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
334 let start = t0 + 1e-4;
335 let p = [
336 origin[0] + dir[0] * start,
337 origin[1] + dir[1] * start,
338 origin[2] + dir[2] * start,
339 ];
340 let mut cell = [
341 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
342 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
343 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
344 ];
345 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
346 let mut t_curr = t0;
347 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
348
349 let mut acc = LayerAccum {
350 rgb: [0.0; 3],
351 trans: 1.0,
352 opaque: None,
353 };
354 let mut touched = false;
355 let mut prev_solid = false;
366 let mut prev_mat = 0u8;
367
368 for _ in 0..max_steps {
369 if cell[0] < 0
370 || cell[0] >= dense.dims[0]
371 || cell[1] < 0
372 || cell[1] >= dense.dims[1]
373 || cell[2] < 0
374 || cell[2] >= dense.dims[2]
375 || t_curr > t1
376 {
377 break;
378 }
379 let depth = t_curr * fwd_dot;
382 if depth >= max_t {
383 break;
384 }
385 let idx = dense.idx_of(cell);
386 let solid_here = dense.occ[idx];
387 if solid_here && depth >= NEAR_Z {
388 let mat_id = if dense.mat.is_empty() {
389 shade_ctx.material
390 } else {
391 dense.mat[idx]
392 };
393 let m = shade_ctx.materials.get(mat_id);
394 if m.is_opaque() {
395 acc.opaque = Some((shade(dense.col[idx], 0), t_curr));
396 touched = true;
397 break;
398 }
399 if !prev_solid || mat_id != prev_mat {
401 let lit = rgb_to_f32(shade(dense.col[idx], 0));
402 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
403 acc.rgb[0] += acc.trans * a * lit[0];
404 acc.rgb[1] += acc.trans * a * lit[1];
405 acc.rgb[2] += acc.trans * a * lit[2];
406 if m.mode == BlendMode::AlphaBlend {
407 acc.trans *= 1.0 - a; }
409 touched = true;
410 prev_mat = mat_id;
411 if acc.trans < 1.0 / 256.0 {
412 break;
413 }
414 }
415 }
416 prev_solid = solid_here;
417 let axis = min_axis(t_max);
418 t_curr = t_max[axis];
419 cell[axis] += step[axis];
420 t_max[axis] += t_delta[axis];
421 }
422
423 touched.then_some(acc)
424}
425
426#[allow(
436 clippy::too_many_arguments,
437 clippy::cast_possible_truncation,
438 clippy::cast_sign_loss
439)]
440#[must_use]
441pub fn draw_sprite_dda(
442 fb: &mut [u32],
443 zb: &mut [f32],
444 pitch_pixels: usize,
445 width: u32,
446 height: u32,
447 cam: &CameraState,
448 settings: &OpticastSettings,
449 sprite: &Sprite,
450) -> u32 {
451 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
452 return 0;
453 }
454 draw_sprite_dda_shaded(
455 fb,
456 zb,
457 pitch_pixels,
458 width,
459 height,
460 cam,
461 settings,
462 sprite,
463 None,
464 )
465}
466
467#[allow(clippy::too_many_arguments)]
472#[must_use]
473pub fn draw_sprite_dda_shaded(
474 fb: &mut [u32],
475 zb: &mut [f32],
476 pitch_pixels: usize,
477 width: u32,
478 height: u32,
479 cam: &CameraState,
480 settings: &OpticastSettings,
481 sprite: &Sprite,
482 shade_ctx: Option<SpriteShade>,
483) -> u32 {
484 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
485 return 0;
486 }
487 let dense = if sprite.material_map.is_empty() {
492 SpriteDense::from_kv6(&sprite.kv6)
493 } else {
494 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
495 };
496 draw_sprite_dense_shaded(
497 fb,
498 zb,
499 pitch_pixels,
500 width,
501 height,
502 cam,
503 settings,
504 &dense,
505 sprite.p,
506 sprite.s,
507 sprite.h,
508 sprite.f,
509 sprite.flags,
510 shade_ctx,
511 )
512}
513
514#[allow(clippy::too_many_arguments)]
523#[must_use]
524pub fn draw_sprite_dense(
525 fb: &mut [u32],
526 zb: &mut [f32],
527 pitch_pixels: usize,
528 width: u32,
529 height: u32,
530 cam: &CameraState,
531 settings: &OpticastSettings,
532 dense: &SpriteDense,
533 pos: [f32; 3],
534 s: [f32; 3],
535 h: [f32; 3],
536 f: [f32; 3],
537 flags: u32,
538) -> u32 {
539 draw_sprite_dense_shaded(
540 fb,
541 zb,
542 pitch_pixels,
543 width,
544 height,
545 cam,
546 settings,
547 dense,
548 pos,
549 s,
550 h,
551 f,
552 flags,
553 None,
554 )
555}
556
557#[allow(
569 clippy::too_many_arguments,
570 clippy::cast_possible_truncation,
571 clippy::cast_sign_loss
572)]
573#[must_use]
574pub fn draw_sprite_dense_shaded(
575 fb: &mut [u32],
576 zb: &mut [f32],
577 pitch_pixels: usize,
578 width: u32,
579 height: u32,
580 cam: &CameraState,
581 settings: &OpticastSettings,
582 dense: &SpriteDense,
583 pos: [f32; 3],
584 s: [f32; 3],
585 h: [f32; 3],
586 f: [f32; 3],
587 flags: u32,
588 shade_ctx: Option<SpriteShade>,
589) -> u32 {
590 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
591 return 0;
592 }
593 let Some(minv) = invert_basis(s, h, f) else {
594 return 0;
595 };
596 let pivot = dense.pivot;
597 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
598
599 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
601 return 0;
602 };
603
604 let layers =
610 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
611
612 debug_assert_eq!(fb.len(), zb.len());
613 let target = RasterTarget::new(fb, zb);
614 let mut written = 0u32;
615 for py in rect.1..rect.3 {
616 let row = py as usize * pitch_pixels;
617 for px in rect.0..rect.2 {
618 let (origin, dir) = pixel_ray(cam, settings, px, py);
619 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
621 let ol = mat_apply(&minv, rel);
622 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
623 let dir_local = mat_apply(&minv, dir);
624 let fwd_dot =
625 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
626 let idx = row + px as usize;
627
628 if let Some(shade_ctx) = layers {
629 if fwd_dot <= 1e-6 {
631 continue;
632 }
633 let max_t = if no_z {
638 f32::INFINITY
639 } else {
640 unsafe { target.read_depth(idx) }
641 };
642 let Some(acc) =
643 cast_local_layers(dense, origin_local, dir_local, fwd_dot, max_t, shade_ctx)
644 else {
645 continue;
646 };
647 let wrote = unsafe {
649 match acc.opaque {
650 Some((bg_color, t)) => {
651 let bg = rgb_to_f32(bg_color);
654 let out = f32_to_rgb([
655 acc.rgb[0] + acc.trans * bg[0],
656 acc.rgb[1] + acc.trans * bg[1],
657 acc.rgb[2] + acc.trans * bg[2],
658 ]);
659 let depth = t * fwd_dot;
660 if no_z {
661 target.write_color(idx, out);
662 target.write_depth(idx, depth);
663 true
664 } else {
665 target.z_test_write(idx, out, depth)
666 }
667 }
668 None => {
669 let bg = rgb_to_f32(target.read_color(idx));
674 let out = f32_to_rgb([
675 acc.rgb[0] + acc.trans * bg[0],
676 acc.rgb[1] + acc.trans * bg[1],
677 acc.rgb[2] + acc.trans * bg[2],
678 ]);
679 target.write_color(idx, out);
680 true
681 }
682 }
683 };
684 written += u32::from(wrote);
685 } else {
686 let Some((color, t)) = cast_local(dense, origin_local, dir_local) else {
688 continue;
689 };
690 let depth = t * fwd_dot;
691 if depth < NEAR_Z {
692 continue;
693 }
694 let lit = shade(color, 0);
695 let wrote = unsafe {
698 if no_z {
699 target.write_color(idx, lit);
700 target.write_depth(idx, depth);
701 true
702 } else {
703 target.z_test_write(idx, lit, depth)
704 }
705 };
706 written += u32::from(wrote);
707 }
708 }
709 }
710 written
711}
712
713#[allow(
717 clippy::cast_possible_truncation,
718 clippy::cast_sign_loss,
719 clippy::cast_precision_loss
720)]
721fn project_screen_rect(
722 dense: &SpriteDense,
723 pos: [f32; 3],
724 s: [f32; 3],
725 h: [f32; 3],
726 f: [f32; 3],
727 cam: &CameraState,
728 settings: &OpticastSettings,
729 width: u32,
730 height: u32,
731) -> Option<(u32, u32, u32, u32)> {
732 let (xs, ys, zs) = (
733 dense.dims[0] as f32,
734 dense.dims[1] as f32,
735 dense.dims[2] as f32,
736 );
737 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
738 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
739 let mut all_front = true;
740 for &cx in &[0.0, xs] {
741 for &cy in &[0.0, ys] {
742 for &cz in &[0.0, zs] {
743 let lx = cx - xp;
745 let ly = cy - yp;
746 let lz = cz - zp;
747 let world = [
748 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
749 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
750 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
751 ];
752 let rel = [
753 world[0] - cam.pos[0],
754 world[1] - cam.pos[1],
755 world[2] - cam.pos[2],
756 ];
757 let cz_cam =
758 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
759 if cz_cam < NEAR_Z {
760 all_front = false;
761 continue;
762 }
763 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
764 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
765 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
766 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
767 x0 = x0.min(sx);
768 y0 = y0.min(sy);
769 x1 = x1.max(sx);
770 y1 = y1.max(sy);
771 }
772 }
773 }
774 let (w, h) = (width as f32, height as f32);
775 let (rx0, ry0, rx1, ry1) = if all_front {
776 (
777 (x0 - 1.0).max(0.0),
778 (y0 - 1.0).max(0.0),
779 (x1 + 1.0).min(w),
780 (y1 + 1.0).min(h),
781 )
782 } else {
783 (0.0, 0.0, w, h)
785 };
786 if rx0 >= rx1 || ry0 >= ry1 {
787 return None;
788 }
789 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
790}
791
792pub struct ClipFlipbook {
799 frames: Vec<SpriteDense>,
800}
801
802impl ClipFlipbook {
803 #[must_use]
806 pub fn empty() -> Self {
807 Self { frames: Vec::new() }
808 }
809
810 #[must_use]
812 pub fn from_decoded(clip: &DecodedClip) -> Self {
813 let frames = clip
814 .frames
815 .iter()
816 .map(|frame| SpriteDense::from_voxel_frame(frame, clip.dims, clip.pivot))
817 .collect();
818 Self { frames }
819 }
820
821 #[must_use]
822 pub fn frame_count(&self) -> usize {
823 self.frames.len()
824 }
825
826 #[must_use]
828 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
829 self.frames.get(frame)
830 }
831
832 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
836 match self.frames.get_mut(frame) {
837 Some(slot) => {
838 *slot = dense;
839 true
840 }
841 None => false,
842 }
843 }
844
845 #[allow(clippy::too_many_arguments)]
849 #[must_use]
850 pub fn draw_frame(
851 &self,
852 fb: &mut [u32],
853 zb: &mut [f32],
854 pitch_pixels: usize,
855 width: u32,
856 height: u32,
857 cam: &CameraState,
858 settings: &OpticastSettings,
859 frame: usize,
860 pos: [f32; 3],
861 s: [f32; 3],
862 h: [f32; 3],
863 f: [f32; 3],
864 flags: u32,
865 ) -> u32 {
866 self.draw_frame_shaded(
867 fb,
868 zb,
869 pitch_pixels,
870 width,
871 height,
872 cam,
873 settings,
874 frame,
875 pos,
876 s,
877 h,
878 f,
879 flags,
880 None,
881 )
882 }
883
884 #[allow(clippy::too_many_arguments)]
889 #[must_use]
890 pub fn draw_frame_shaded(
891 &self,
892 fb: &mut [u32],
893 zb: &mut [f32],
894 pitch_pixels: usize,
895 width: u32,
896 height: u32,
897 cam: &CameraState,
898 settings: &OpticastSettings,
899 frame: usize,
900 pos: [f32; 3],
901 s: [f32; 3],
902 h: [f32; 3],
903 f: [f32; 3],
904 flags: u32,
905 shade_ctx: Option<SpriteShade>,
906 ) -> u32 {
907 let Some(dense) = self.frames.get(frame) else {
908 return 0;
909 };
910 draw_sprite_dense_shaded(
911 fb,
912 zb,
913 pitch_pixels,
914 width,
915 height,
916 cam,
917 settings,
918 dense,
919 pos,
920 s,
921 h,
922 f,
923 flags,
924 shade_ctx,
925 )
926 }
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932 use crate::camera_math;
933 use crate::Camera;
934 use roxlap_formats::kv6::Kv6;
935 use roxlap_formats::material::{Material, MaterialTable};
936 use roxlap_formats::sprite::Sprite;
937 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
938
939 fn settings(w: u32, h: u32) -> OpticastSettings {
940 OpticastSettings::for_oracle_framebuffer(w, h)
941 }
942
943 fn cam_looking_y() -> Camera {
945 Camera {
946 pos: [0.0, 0.0, 0.0],
947 right: [1.0, 0.0, 0.0],
948 down: [0.0, 0.0, 1.0],
949 forward: [0.0, 1.0, 0.0],
950 }
951 }
952
953 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
955 let owpc = dims[2].div_ceil(32).max(1) as usize;
956 let cols = (dims[0] * dims[1]) as usize;
957 let mut occupancy = vec![0u32; cols * owpc];
958 let mut color_offsets = vec![0u32; cols + 1];
959 let mut colors = Vec::new();
960 for y in 0..dims[1] {
961 for x in 0..dims[0] {
962 let col = (x + y * dims[0]) as usize;
963 color_offsets[col] = colors.len() as u32;
964 for z in 0..dims[2] {
965 if let Some(c) = fill(x, y, z) {
966 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
967 colors.push(c);
968 }
969 }
970 }
971 }
972 color_offsets[cols] = colors.len() as u32;
973 VoxelFrame {
974 occupancy,
975 colors,
976 color_offsets,
977 }
978 }
979
980 #[test]
985 fn clip_flipbook_frames_render_differently() {
986 let dims = [8u32, 8, 8];
987 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(
990 dims,
991 [4.0, 4.0, 4.0],
992 1.0,
993 LoopMode::Loop,
994 &[f0, f1],
995 &[],
996 33,
997 0,
998 );
999 let decoded = clip.decode().expect("decode");
1000 let book = ClipFlipbook::from_decoded(&decoded);
1001 assert_eq!(book.frame_count(), 2);
1002 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1003
1004 let (w, h) = (64u32, 64u32);
1005 let n = (w * h) as usize;
1006 let cam = cam_looking_y();
1007 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1008 let cfg = settings(w, h);
1009 let pose = [0.0, 40.0, 0.0];
1010 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1011
1012 let render = |frame: usize| -> Vec<u32> {
1013 let mut fb = vec![0u32; n];
1014 let mut zb = vec![f32::INFINITY; n];
1015 let wrote = book.draw_frame(
1016 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1017 );
1018 assert!(wrote > 0, "frame {frame} should draw some pixels");
1019 fb
1020 };
1021 let fb0 = render(0);
1022 let fb1 = render(1);
1023 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1024 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1027 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1028 let mut fb = vec![0u32; n];
1030 let mut zb = vec![f32::INFINITY; n];
1031 assert_eq!(
1032 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1033 0
1034 );
1035 }
1036
1037 #[test]
1038 fn clip_flipbook_set_frame_replaces_one_frame() {
1039 let dims = [8u32, 8, 8];
1042 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 =
1045 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1046 let decoded = clip.decode().unwrap();
1047 let mut book = ClipFlipbook::from_decoded(&decoded);
1048
1049 let (w, h) = (64u32, 64u32);
1050 let n = (w * h) as usize;
1051 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1052 let cfg = settings(w, h);
1053 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1054 let mut fb = vec![0u32; n];
1055 let mut zb = vec![f32::INFINITY; n];
1056 let _ = b.draw_frame(
1057 &mut fb,
1058 &mut zb,
1059 w as usize,
1060 w,
1061 h,
1062 &cs,
1063 &cfg,
1064 0,
1065 [0.0, 40.0, 0.0],
1066 [1.0, 0.0, 0.0],
1067 [0.0, 1.0, 0.0],
1068 [0.0, 0.0, 1.0],
1069 0,
1070 );
1071 fb
1072 };
1073
1074 let before = render0(&book);
1075 assert!(
1076 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1077 "frame 0 is red"
1078 );
1079
1080 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1082 assert!(book.set_frame(0, replacement));
1083 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1084 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1085
1086 let after = render0(&book);
1087 assert!(
1088 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1089 "frame 0 now green"
1090 );
1091 assert_ne!(before, after);
1092 }
1093
1094 #[test]
1097 fn cube_sprite_renders() {
1098 let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
1099 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1100 let (w, h) = (64u32, 64u32);
1101 let n = (w * h) as usize;
1102 let mut fb = vec![0u32; n];
1103 let mut zb = vec![f32::INFINITY; n];
1104 let cam = cam_looking_y();
1105 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1106 let wrote = draw_sprite_dda(
1107 &mut fb,
1108 &mut zb,
1109 w as usize,
1110 w,
1111 h,
1112 &cs,
1113 &settings(w, h),
1114 &sprite,
1115 );
1116
1117 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1118 let centre = (h / 2 * w + w / 2) as usize;
1119 assert_eq!(
1120 fb[centre] & 0x00ff_ffff,
1121 0x00_C0_40_20,
1122 "got {:08x}",
1123 fb[centre]
1124 );
1125 assert!(
1127 (zb[centre] - 36.0).abs() < 3.0,
1128 "centre depth {} not ≈ 36",
1129 zb[centre]
1130 );
1131 }
1132
1133 #[test]
1138 fn zero_high_byte_sprite_not_black() {
1139 let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
1140 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1141 let (w, h) = (64u32, 64u32);
1142 let n = (w * h) as usize;
1143 let mut fb = vec![0u32; n];
1144 let mut zb = vec![f32::INFINITY; n];
1145 let cam = cam_looking_y();
1146 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1147 let wrote = draw_sprite_dda(
1148 &mut fb,
1149 &mut zb,
1150 w as usize,
1151 w,
1152 h,
1153 &cs,
1154 &settings(w, h),
1155 &sprite,
1156 );
1157 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1158 let centre = (h / 2 * w + w / 2) as usize;
1159 assert_eq!(
1160 fb[centre] & 0x00ff_ffff,
1161 0x00_C0_40_20,
1162 "zero-high-byte sprite rendered as {:08x} (black bug)",
1163 fb[centre]
1164 );
1165 }
1166
1167 #[test]
1170 fn sprite_respects_zbuffer() {
1171 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1172 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1173 let (w, h) = (32u32, 32u32);
1174 let n = (w * h) as usize;
1175 let cam = cam_looking_y();
1176 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1177 let centre = (h / 2 * w + w / 2) as usize;
1178
1179 let mut fb = vec![0u32; n];
1181 let mut zb = vec![f32::INFINITY; n];
1182 fb[centre] = 0x80_11_22_33;
1183 zb[centre] = 10.0;
1184 let _ = draw_sprite_dda(
1185 &mut fb,
1186 &mut zb,
1187 w as usize,
1188 w,
1189 h,
1190 &cs,
1191 &settings(w, h),
1192 &sprite,
1193 );
1194 assert_eq!(
1195 fb[centre], 0x80_11_22_33,
1196 "near terrain must occlude sprite"
1197 );
1198
1199 let mut fb2 = vec![0u32; n];
1201 let mut zb2 = vec![f32::INFINITY; n];
1202 fb2[centre] = 0x80_11_22_33;
1203 zb2[centre] = 100.0;
1204 let _ = draw_sprite_dda(
1205 &mut fb2,
1206 &mut zb2,
1207 w as usize,
1208 w,
1209 h,
1210 &cs,
1211 &settings(w, h),
1212 &sprite,
1213 );
1214 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1215 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1216 }
1217
1218 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1221 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1222 for py in 0..h {
1223 for px in 0..w {
1224 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1225 x0 = x0.min(px);
1226 y0 = y0.min(py);
1227 x1 = x1.max(px);
1228 y1 = y1.max(py);
1229 }
1230 }
1231 }
1232 (x0, y0, x1, y1)
1233 }
1234
1235 #[test]
1240 fn posed_basis_reorients_silhouette() {
1241 let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
1244 let (w, h) = (64u32, 64u32);
1245 let n = (w * h) as usize;
1246 let cam = cam_looking_y();
1247 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1248
1249 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1251 let mut fb = vec![0u32; n];
1252 let mut zb = vec![f32::INFINITY; n];
1253 let _ = draw_sprite_dda(
1254 &mut fb,
1255 &mut zb,
1256 w as usize,
1257 w,
1258 h,
1259 &cs,
1260 &settings(w, h),
1261 &aa,
1262 );
1263 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1264 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1265 assert!(
1266 aa_wide > 4,
1267 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1268 );
1269
1270 let mut posed = aa.clone();
1273 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];
1277 let mut zb2 = vec![f32::INFINITY; n];
1278 let _ = draw_sprite_dda(
1279 &mut fb2,
1280 &mut zb2,
1281 w as usize,
1282 w,
1283 h,
1284 &cs,
1285 &settings(w, h),
1286 &posed,
1287 );
1288 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1289 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1290 assert!(
1291 posed_tall > 4,
1292 "posed box should be taller than wide (got h-w={posed_tall})"
1293 );
1294 }
1295
1296 #[test]
1299 fn degenerate_basis_draws_nothing() {
1300 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1301 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1302 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1304 let n = (w * h) as usize;
1305 let mut fb = vec![0u32; n];
1306 let mut zb = vec![f32::INFINITY; n];
1307 let cam = cam_looking_y();
1308 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1309 let wrote = draw_sprite_dda(
1310 &mut fb,
1311 &mut zb,
1312 w as usize,
1313 w,
1314 h,
1315 &cs,
1316 &settings(w, h),
1317 &sprite,
1318 );
1319 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1320 }
1321
1322 #[test]
1324 fn invisible_sprite_skipped() {
1325 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1326 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1327 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1328 let (w, h) = (32u32, 32u32);
1329 let n = (w * h) as usize;
1330 let mut fb = vec![0u32; n];
1331 let mut zb = vec![f32::INFINITY; n];
1332 let cam = cam_looking_y();
1333 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1334 let wrote = draw_sprite_dda(
1335 &mut fb,
1336 &mut zb,
1337 w as usize,
1338 w,
1339 h,
1340 &cs,
1341 &settings(w, h),
1342 &sprite,
1343 );
1344 assert_eq!(wrote, 0);
1345 }
1346
1347 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1353 let mut table = MaterialTable::new();
1354 table.set(1, mat);
1355 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1356 let (w, h) = (64u32, 64u32);
1357 let n = (w * h) as usize;
1358 let mut fb = vec![bg; n];
1359 let mut zb = vec![zb_v; n];
1360 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1361 let sh = SpriteShade {
1362 materials: &table,
1363 material: 1,
1364 alpha_mul,
1365 };
1366 let _ = draw_sprite_dense_shaded(
1367 &mut fb,
1368 &mut zb,
1369 w as usize,
1370 w,
1371 h,
1372 &cs,
1373 &settings(w, h),
1374 &dense,
1375 [0.0, 40.0, 0.0],
1376 [1.0, 0.0, 0.0],
1377 [0.0, 1.0, 0.0],
1378 [0.0, 0.0, 1.0],
1379 0,
1380 Some(sh),
1381 );
1382 (fb[(h / 2 * w + w / 2) as usize], fb)
1383 }
1384
1385 #[test]
1388 fn additive_sprite_brightens_background() {
1389 let bg = 0x80_20_20_20;
1390 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1391 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1392 assert!(
1393 cr > 0x20 && cg > 0x20 && cb >= 0x20,
1394 "centre {centre:08x} should be brighter than bg"
1395 );
1396 assert!(
1398 cr >= cg && cr >= cb,
1399 "additive of a red-dominant cube stays red-dominant"
1400 );
1401 }
1402
1403 #[test]
1406 fn alpha_blend_sprite_between_bg_and_color() {
1407 let bg = 0x80_20_20_20;
1408 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
1409 let cr = (centre >> 16) & 0xff;
1410 assert!(
1411 cr > 0x20,
1412 "blended red must rise above bg 0x20 (got {cr:02x})"
1413 );
1414 assert!(
1415 cr < 0xC0,
1416 "blended red must stay below opaque 0xC0 (got {cr:02x})"
1417 );
1418 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
1420 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
1421 }
1422
1423 #[test]
1426 fn alpha_mul_scales_opacity() {
1427 let bg = 0x80_20_20_20;
1428 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
1429 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
1430 let r_full = (full >> 16) & 0xff;
1431 let r_faded = (faded >> 16) & 0xff;
1432 assert!(
1434 r_full > r_faded,
1435 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
1436 );
1437 assert!(r_faded > 0x20, "even faded lifts above bg");
1438 }
1439
1440 #[test]
1444 fn opaque_shade_ctx_matches_plain_path() {
1445 let table = MaterialTable::new();
1446 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1447 let (w, h) = (64u32, 64u32);
1448 let n = (w * h) as usize;
1449 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1450 let pose = (
1451 [0.0, 40.0, 0.0],
1452 [1.0, 0.0, 0.0],
1453 [0.0, 1.0, 0.0],
1454 [0.0, 0.0, 1.0],
1455 );
1456
1457 let mut fb_plain = vec![0u32; n];
1458 let mut zb_plain = vec![f32::INFINITY; n];
1459 let _ = draw_sprite_dense(
1460 &mut fb_plain,
1461 &mut zb_plain,
1462 w as usize,
1463 w,
1464 h,
1465 &cs,
1466 &settings(w, h),
1467 &dense,
1468 pose.0,
1469 pose.1,
1470 pose.2,
1471 pose.3,
1472 0,
1473 );
1474
1475 let mut fb_sh = vec![0u32; n];
1476 let mut zb_sh = vec![f32::INFINITY; n];
1477 let sh = SpriteShade {
1478 materials: &table,
1479 material: 0, alpha_mul: 255,
1481 };
1482 let _ = draw_sprite_dense_shaded(
1483 &mut fb_sh,
1484 &mut zb_sh,
1485 w as usize,
1486 w,
1487 h,
1488 &cs,
1489 &settings(w, h),
1490 &dense,
1491 pose.0,
1492 pose.1,
1493 pose.2,
1494 pose.3,
1495 0,
1496 Some(sh),
1497 );
1498
1499 assert_eq!(
1500 fb_plain, fb_sh,
1501 "opaque shade-ctx must match the plain path bit-for-bit"
1502 );
1503 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
1504 }
1505
1506 #[test]
1510 fn translucent_sprite_occluded_by_near_terrain() {
1511 let bg = 0x80_20_20_20;
1512 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
1513 assert_eq!(
1514 centre, bg,
1515 "near terrain (z=5) must occlude the sprite at y≈36"
1516 );
1517 }
1518
1519 #[test]
1525 fn per_span_thickness_independent() {
1526 fn centre(ysiz: u32) -> u32 {
1527 let mut table = MaterialTable::new();
1528 table.set(1, Material::alpha_blend(128));
1529 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, 0x80_C0_40_20));
1530 let (w, h) = (64u32, 64u32);
1531 let n = (w * h) as usize;
1532 let mut fb = vec![0x80_10_10_10u32; n];
1533 let mut zb = vec![f32::INFINITY; n];
1534 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1535 let sh = SpriteShade {
1536 materials: &table,
1537 material: 1,
1538 alpha_mul: 255,
1539 };
1540 let _ = draw_sprite_dense_shaded(
1541 &mut fb,
1542 &mut zb,
1543 w as usize,
1544 w,
1545 h,
1546 &cs,
1547 &settings(w, h),
1548 &dense,
1549 [0.0, 40.0, 0.0],
1550 [1.0, 0.0, 0.0],
1551 [0.0, 1.0, 0.0],
1552 [0.0, 0.0, 1.0],
1553 0,
1554 Some(sh),
1555 );
1556 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
1557 }
1558 assert_eq!(
1562 centre(1),
1563 centre(2),
1564 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
1565 );
1566 }
1567
1568 #[test]
1573 fn translucent_sprite_tints_opaque_sprite_behind() {
1574 let mut table = MaterialTable::new();
1575 table.set(1, Material::alpha_blend(128));
1576 let (w, h) = (64u32, 64u32);
1577 let n = (w * h) as usize;
1578 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
1580 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1581 let cfg = settings(w, h);
1582 let id = [1.0, 0.0, 0.0];
1583 let up = [0.0, 1.0, 0.0];
1584 let fw = [0.0, 0.0, 1.0];
1585 let centre = (h / 2 * w + w / 2) as usize;
1586
1587 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_FF_00_00));
1589 let sh_op = SpriteShade {
1590 materials: &table,
1591 material: 0,
1592 alpha_mul: 255,
1593 };
1594 let _ = draw_sprite_dense_shaded(
1595 &mut fb,
1596 &mut zb,
1597 w as usize,
1598 w,
1599 h,
1600 &cs,
1601 &cfg,
1602 &backdrop,
1603 [0.0, 80.0, 0.0],
1604 id,
1605 up,
1606 fw,
1607 0,
1608 Some(sh_op),
1609 );
1610 let after_backdrop = fb[centre];
1611 assert_eq!(
1612 after_backdrop & 0x00ff_ffff,
1613 0x00FF_0000,
1614 "backdrop red must be drawn first"
1615 );
1616
1617 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_00_FF_FF));
1619 let sh_gl = SpriteShade {
1620 materials: &table,
1621 material: 1,
1622 alpha_mul: 255,
1623 };
1624 let wrote = draw_sprite_dense_shaded(
1625 &mut fb,
1626 &mut zb,
1627 w as usize,
1628 w,
1629 h,
1630 &cs,
1631 &cfg,
1632 &glass,
1633 [0.0, 40.0, 0.0],
1634 id,
1635 up,
1636 fw,
1637 0,
1638 Some(sh_gl),
1639 );
1640 let _ = wrote;
1641 let after_glass = fb[centre];
1642 assert_ne!(
1643 after_glass, after_backdrop,
1644 "glass must tint the backdrop (composite over it)"
1645 );
1646 assert!(
1648 (after_glass >> 16) & 0xff < 0xFF,
1649 "glass should reduce the backdrop's red (got {after_glass:08x})"
1650 );
1651 }
1652
1653 #[test]
1656 fn from_kv6_with_materials_classifies_by_color() {
1657 let col = 0x80_AA_BB_CC;
1658 let kv6 = Kv6::solid_cube(6, col);
1659 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(0x00AA_BBCC, 2)]);
1660 assert_eq!(
1661 dense.mat.len(),
1662 dense.col.len(),
1663 "per-voxel mat array sized"
1664 );
1665 let mut solids = 0;
1666 for idx in 0..dense.occ.len() {
1667 if dense.occ[idx] {
1668 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
1669 solids += 1;
1670 }
1671 }
1672 assert!(solids > 0, "cube has solid voxels");
1673 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(0x0012_3456, 5)]);
1675 assert!(
1676 dense0.mat.iter().all(|&m| m == 0),
1677 "unmapped colour → material 0"
1678 );
1679 }
1680
1681 #[test]
1686 fn per_voxel_material_matches_uniform_when_homogeneous() {
1687 let mut table = MaterialTable::new();
1688 table.set(1, Material::alpha_blend(120));
1689 let col = 0x80_30_A0_F0;
1690 let kv6 = Kv6::solid_cube(10, col);
1691 let (w, h) = (64u32, 64u32);
1692 let n = (w * h) as usize;
1693 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1694 let cfg = settings(w, h);
1695 let (pos, s, hh, f) = (
1696 [0.0, 40.0, 0.0],
1697 [1.0, 0.0, 0.0],
1698 [0.0, 1.0, 0.0],
1699 [0.0, 0.0, 1.0],
1700 );
1701 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
1702 let mut fb = vec![0x80_10_10_10u32; n];
1703 let mut zb = vec![f32::INFINITY; n];
1704 let sh = SpriteShade {
1705 materials: &table,
1706 material,
1707 alpha_mul: 255,
1708 };
1709 let _ = draw_sprite_dense_shaded(
1710 &mut fb,
1711 &mut zb,
1712 w as usize,
1713 w,
1714 h,
1715 &cs,
1716 &cfg,
1717 dense,
1718 pos,
1719 s,
1720 hh,
1721 f,
1722 0,
1723 Some(sh),
1724 );
1725 fb
1726 };
1727 let pv = render(
1730 &SpriteDense::from_kv6_with_materials(&kv6, &[(col & 0xff_ffff, 1)]),
1731 0,
1732 );
1733 let un = render(&SpriteDense::from_kv6(&kv6), 1);
1735 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
1736 let centre = (h / 2 * w + w / 2) as usize;
1738 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
1739 }
1740}