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::{
29 dda_setup, intersect_aabb, min_axis, pixel_ray, shade, shade_dynamic, CpuLights, ShadowTester,
30 WorldOccluder, WorldShadow, WorldShadowCtx,
31};
32use crate::opticast::OpticastSettings;
33use crate::raster_target::RasterTarget;
34
35const NEAR_Z: f32 = 1.0;
38
39#[inline]
45fn full_bright(col: u32) -> u32 {
46 (col & 0x00ff_ffff) | 0x8000_0000
47}
48
49#[derive(Clone)]
58pub struct SpriteDense {
59 dims: [i32; 3],
60 occ: Vec<bool>,
61 col: Vec<u32>,
62 mat: Vec<u8>,
68 pivot: [f32; 3],
69}
70
71impl SpriteDense {
72 #[must_use]
74 #[allow(clippy::cast_possible_wrap)]
75 pub fn from_kv6(kv6: &Kv6) -> Self {
76 let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
77 let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
78 let mut occ = vec![false; n];
79 let mut col = vec![0u32; n];
80 let mut vi = 0usize;
81 for x in 0..kv6.xsiz as usize {
82 for y in 0..kv6.ysiz as usize {
83 let cnt = usize::from(kv6.ylen[x][y]);
84 for _ in 0..cnt {
85 let v = kv6.voxels[vi];
86 vi += 1;
87 let z = i32::from(v.z);
88 if z >= 0 && z < dims[2] {
89 let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
90 occ[idx] = true;
91 col[idx] = full_bright(v.col);
92 }
93 }
94 }
95 }
96 Self {
97 dims,
98 occ,
99 col,
100 mat: Vec::new(),
101 pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
102 }
103 }
104
105 #[must_use]
113 #[allow(clippy::cast_possible_wrap)]
114 pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(u32, u8)]) -> Self {
115 let mut dense = Self::from_kv6(kv6);
116 if !material_map.is_empty() {
117 let n = dense.col.len();
118 let mut mat = vec![0u8; n];
119 for (idx, slot) in mat.iter_mut().enumerate() {
120 if dense.occ[idx] {
121 *slot = material_for_color(material_map, dense.col[idx]);
122 }
123 }
124 dense.mat = mat;
125 }
126 dense
127 }
128
129 #[must_use]
135 #[allow(clippy::cast_possible_wrap)]
136 pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
137 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
138 let owpc = mz.div_ceil(32).max(1) as usize;
139 let n = (mx * my * mz) as usize;
140 let mut occ = vec![false; n];
141 let mut col = vec![0u32; n];
142 for col_idx in 0..(mx * my) as usize {
143 let x = col_idx as u32 % mx;
144 let y = col_idx as u32 / mx;
145 let run_start = frame.color_offsets[col_idx] as usize;
146 let mut k = 0usize;
147 for z in 0..mz {
148 let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
149 if (word >> (z & 31)) & 1 != 0 {
150 let idx = (((x * my + y) * mz) + z) as usize;
151 occ[idx] = true;
152 col[idx] = full_bright(frame.colors[run_start + k]);
153 k += 1;
154 }
155 }
156 }
157 Self {
158 dims: [mx as i32, my as i32, mz as i32],
159 occ,
160 col,
161 mat: Vec::new(),
162 pivot,
163 }
164 }
165
166 #[must_use]
172 pub fn from_voxel_frame_with_materials(
173 frame: &VoxelFrame,
174 dims: [u32; 3],
175 pivot: [f32; 3],
176 material_map: &[(u32, u8)],
177 ) -> Self {
178 let mut dense = Self::from_voxel_frame(frame, dims, pivot);
179 if !material_map.is_empty() {
180 let n = dense.col.len();
181 let mut mat = vec![0u8; n];
182 for (idx, slot) in mat.iter_mut().enumerate() {
183 if dense.occ[idx] {
184 *slot = material_for_color(material_map, dense.col[idx]);
185 }
186 }
187 dense.mat = mat;
188 }
189 dense
190 }
191
192 #[inline]
193 #[allow(clippy::cast_sign_loss)]
194 fn idx_of(&self, c: [i32; 3]) -> usize {
195 ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
196 }
197
198 #[inline]
199 fn at(&self, c: [i32; 3]) -> Option<u32> {
200 let idx = self.idx_of(c);
201 self.occ[idx].then(|| self.col[idx])
202 }
203}
204
205fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
208 let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
209 + f[0] * (s[1] * h[2] - h[1] * s[2]);
210 if det.abs() < 1e-12 {
211 return None;
212 }
213 let inv = 1.0 / det;
214 Some([
215 [
216 (h[1] * f[2] - f[1] * h[2]) * inv,
217 -(h[0] * f[2] - f[0] * h[2]) * inv,
218 (h[0] * f[1] - f[0] * h[1]) * inv,
219 ],
220 [
221 -(s[1] * f[2] - f[1] * s[2]) * inv,
222 (s[0] * f[2] - f[0] * s[2]) * inv,
223 -(s[0] * f[1] - f[0] * s[1]) * inv,
224 ],
225 [
226 (s[1] * h[2] - h[1] * s[2]) * inv,
227 -(s[0] * h[2] - h[0] * s[2]) * inv,
228 (s[0] * h[1] - h[0] * s[1]) * inv,
229 ],
230 ])
231}
232
233#[inline]
234fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
235 [
236 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
237 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
238 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
239 ]
240}
241
242#[allow(clippy::cast_possible_truncation)]
246fn cast_local(
252 dense: &SpriteDense,
253 origin: [f32; 3],
254 dir: [f32; 3],
255) -> Option<(u32, f32, [f32; 3], [i32; 3])> {
256 #[allow(clippy::cast_precision_loss)]
257 let hi = [
258 dense.dims[0] as f32,
259 dense.dims[1] as f32,
260 dense.dims[2] as f32,
261 ];
262 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
263 let start = t0 + 1e-4;
264 let p = [
265 origin[0] + dir[0] * start,
266 origin[1] + dir[1] * start,
267 origin[2] + dir[2] * start,
268 ];
269 let mut cell = [
270 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
271 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
272 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
273 ];
274 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
275 let mut t_curr = t0;
276 let mut normal = [0.0f32; 3];
279 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
280 for _ in 0..max_steps {
281 if cell[0] < 0
282 || cell[0] >= dense.dims[0]
283 || cell[1] < 0
284 || cell[1] >= dense.dims[1]
285 || cell[2] < 0
286 || cell[2] >= dense.dims[2]
287 || t_curr > t1
288 {
289 return None;
290 }
291 if let Some(color) = dense.at(cell) {
292 return Some((color, t_curr, normal, cell));
293 }
294 let axis = min_axis(t_max);
295 t_curr = t_max[axis];
296 cell[axis] += step[axis];
297 t_max[axis] += t_delta[axis];
298 normal = [0.0; 3];
299 normal[axis] = -(step[axis] as f32);
300 }
301 None
302}
303
304struct SpriteOccEntry {
308 dense: SpriteDense,
309 pos: [f32; 3],
310 pivot: [f32; 3],
311 minv: [[f32; 3]; 3],
312}
313
314#[derive(Default)]
324pub struct SpriteOccluder {
325 entries: Vec<SpriteOccEntry>,
326}
327
328impl SpriteOccluder {
329 #[must_use]
330 pub fn new() -> Self {
331 Self::default()
332 }
333
334 #[must_use]
336 pub fn is_empty(&self) -> bool {
337 self.entries.is_empty()
338 }
339
340 pub fn push(
344 &mut self,
345 dense: SpriteDense,
346 pos: [f32; 3],
347 s: [f32; 3],
348 h: [f32; 3],
349 f: [f32; 3],
350 ) {
351 let Some(minv) = invert_basis(s, h, f) else {
352 return;
353 };
354 let pivot = dense.pivot;
355 self.entries.push(SpriteOccEntry {
356 dense,
357 pos,
358 pivot,
359 minv,
360 });
361 }
362}
363
364impl WorldOccluder for SpriteOccluder {
365 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
366 self.entries
367 .iter()
368 .any(|e| sprite_entry_occluded(e, origin, dir, max_t))
369 }
370}
371
372#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
375fn sprite_entry_occluded(e: &SpriteOccEntry, ow: [f32; 3], dw: [f32; 3], max_t: f32) -> bool {
376 let rel = [ow[0] - e.pos[0], ow[1] - e.pos[1], ow[2] - e.pos[2]];
378 let ol = mat_apply(&e.minv, rel);
379 let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
380 let dir = mat_apply(&e.minv, dw);
381
382 let hi = [
383 e.dense.dims[0] as f32,
384 e.dense.dims[1] as f32,
385 e.dense.dims[2] as f32,
386 ];
387 let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
388 return false;
389 };
390 let t_enter = t0.max(0.0);
391 let t_exit = t1.min(max_t);
392 if t_enter > t_exit {
393 return false;
394 }
395 let start = t_enter + 1e-4;
396 let p = [
397 origin[0] + dir[0] * start,
398 origin[1] + dir[1] * start,
399 origin[2] + dir[2] * start,
400 ];
401 let mut cell = [
402 (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
403 (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
404 (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
405 ];
406 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
407 let mut t_curr = t_enter;
408 let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
409 for _ in 0..max_steps {
410 if cell[0] < 0
411 || cell[0] >= e.dense.dims[0]
412 || cell[1] < 0
413 || cell[1] >= e.dense.dims[1]
414 || cell[2] < 0
415 || cell[2] >= e.dense.dims[2]
416 || t_curr > t_exit
417 {
418 return false;
419 }
420 if e.dense.occ[e.dense.idx_of(cell)] {
421 return true;
422 }
423 let a = min_axis(t_max);
424 t_curr = t_max[a];
425 cell[a] += step[a];
426 t_max[a] += t_delta[a];
427 }
428 false
429}
430
431#[derive(Clone, Copy)]
438pub struct SpriteShade<'a> {
439 pub materials: &'a MaterialTable,
441 pub material: u8,
444 pub alpha_mul: u8,
447 pub lights: CpuLights<'a>,
451 pub shadow: Option<&'a dyn WorldOccluder>,
456}
457
458struct LayerAccum {
460 rgb: [f32; 3],
463 trans: f32,
465 opaque: Option<(u32, f32)>,
470}
471
472#[inline]
475fn rgb_to_f32(c: u32) -> [f32; 3] {
476 [
477 ((c >> 16) & 0xff) as f32 / 255.0,
478 ((c >> 8) & 0xff) as f32 / 255.0,
479 (c & 0xff) as f32 / 255.0,
480 ]
481}
482
483#[inline]
486#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
487fn f32_to_rgb(c: [f32; 3]) -> u32 {
488 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
489 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
490}
491
492#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
499fn cast_local_layers(
500 dense: &SpriteDense,
501 origin: [f32; 3],
502 dir: [f32; 3],
503 fwd_dot: f32,
504 max_t: f32,
505 shade_ctx: SpriteShade,
506 s: [f32; 3],
510 h: [f32; 3],
511 f: [f32; 3],
512 pos: [f32; 3],
513) -> Option<LayerAccum> {
514 #[allow(clippy::cast_precision_loss)]
515 let hi = [
516 dense.dims[0] as f32,
517 dense.dims[1] as f32,
518 dense.dims[2] as f32,
519 ];
520 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
521 let start = t0 + 1e-4;
522 let p = [
523 origin[0] + dir[0] * start,
524 origin[1] + dir[1] * start,
525 origin[2] + dir[2] * start,
526 ];
527 let mut cell = [
528 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
529 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
530 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
531 ];
532 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
533 let mut t_curr = t0;
534 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
535
536 let mut acc = LayerAccum {
537 rgb: [0.0; 3],
538 trans: 1.0,
539 opaque: None,
540 };
541 let mut touched = false;
542 let mut prev_solid = false;
553 let mut prev_mat = 0u8;
554 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
557 let mut normal = [0.0f32; 3];
561
562 let lights = shade_ctx.lights;
568 let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
569 ctx: WorldShadowCtx::identity(occ),
570 });
571 let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3]| -> u32 {
572 if !lights.enabled {
573 return shade(dense.col[idx], 0);
574 }
575 let to_world = |v: [f32; 3]| {
576 [
577 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
578 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
579 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
580 ]
581 };
582 let n_world = to_world(n_local);
583 let rel = [
584 cell[0] as f32 + 0.5 - dense.pivot[0],
585 cell[1] as f32 + 0.5 - dense.pivot[1],
586 cell[2] as f32 + 0.5 - dense.pivot[2],
587 ];
588 let wc = to_world(rel);
589 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
590 let albedo = [
591 ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
592 ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
593 (dense.col[idx] & 0xff) as f32 / 255.0,
594 ];
595 let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
596 shade_dynamic(albedo, 1.0, n_world, center, &lights, t)
597 };
598
599 for _ in 0..max_steps {
600 if cell[0] < 0
601 || cell[0] >= dense.dims[0]
602 || cell[1] < 0
603 || cell[1] >= dense.dims[1]
604 || cell[2] < 0
605 || cell[2] >= dense.dims[2]
606 || t_curr > t1
607 {
608 break;
609 }
610 let depth = t_curr * fwd_dot;
613 if depth >= max_t {
614 break;
615 }
616 let exit_axis = min_axis(t_max);
619 let t_exit = t_max[exit_axis];
620 let idx = dense.idx_of(cell);
621 let solid_here = dense.occ[idx];
622 if solid_here && depth >= NEAR_Z {
623 let mat_id = if dense.mat.is_empty() {
624 shade_ctx.material
625 } else {
626 dense.mat[idx]
627 };
628 let m = shade_ctx.materials.get(mat_id);
629 if m.is_opaque() {
630 acc.opaque = Some((shade_layer(idx, cell, normal), t_curr));
631 touched = true;
632 break;
633 }
634 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
635 if m.mode == BlendMode::Volumetric {
636 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
640 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
641 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
642 acc.rgb[0] += acc.trans * eff_a * lit[0];
643 acc.rgb[1] += acc.trans * eff_a * lit[1];
644 acc.rgb[2] += acc.trans * eff_a * lit[2];
645 acc.trans *= 1.0 - eff_a;
646 touched = true;
647 prev_mat = mat_id;
648 if acc.trans < 1.0 / 256.0 {
649 break;
650 }
651 } else if !prev_solid || mat_id != prev_mat {
652 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
655 acc.rgb[0] += acc.trans * a * lit[0];
656 acc.rgb[1] += acc.trans * a * lit[1];
657 acc.rgb[2] += acc.trans * a * lit[2];
658 if m.mode == BlendMode::AlphaBlend {
659 acc.trans *= 1.0 - a; }
661 touched = true;
662 prev_mat = mat_id;
663 if acc.trans < 1.0 / 256.0 {
664 break;
665 }
666 }
667 }
668 prev_solid = solid_here;
669 t_curr = t_exit;
670 cell[exit_axis] += step[exit_axis];
671 t_max[exit_axis] += t_delta[exit_axis];
672 normal = [0.0; 3];
673 normal[exit_axis] = -(step[exit_axis] as f32);
674 }
675
676 touched.then_some(acc)
677}
678
679#[allow(
689 clippy::too_many_arguments,
690 clippy::cast_possible_truncation,
691 clippy::cast_sign_loss
692)]
693#[must_use]
694pub fn draw_sprite_dda(
695 fb: &mut [u32],
696 zb: &mut [f32],
697 pitch_pixels: usize,
698 width: u32,
699 height: u32,
700 cam: &CameraState,
701 settings: &OpticastSettings,
702 sprite: &Sprite,
703) -> u32 {
704 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
705 return 0;
706 }
707 draw_sprite_dda_shaded(
708 fb,
709 zb,
710 pitch_pixels,
711 width,
712 height,
713 cam,
714 settings,
715 sprite,
716 None,
717 )
718}
719
720#[allow(clippy::too_many_arguments)]
725#[must_use]
726pub fn draw_sprite_dda_shaded(
727 fb: &mut [u32],
728 zb: &mut [f32],
729 pitch_pixels: usize,
730 width: u32,
731 height: u32,
732 cam: &CameraState,
733 settings: &OpticastSettings,
734 sprite: &Sprite,
735 shade_ctx: Option<SpriteShade>,
736) -> u32 {
737 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
738 return 0;
739 }
740 let dense = if sprite.material_map.is_empty() {
745 SpriteDense::from_kv6(&sprite.kv6)
746 } else {
747 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
748 };
749 draw_sprite_dense_shaded(
750 fb,
751 zb,
752 pitch_pixels,
753 width,
754 height,
755 cam,
756 settings,
757 &dense,
758 sprite.p,
759 sprite.s,
760 sprite.h,
761 sprite.f,
762 sprite.flags,
763 shade_ctx,
764 )
765}
766
767#[allow(clippy::too_many_arguments)]
776#[must_use]
777pub fn draw_sprite_dense(
778 fb: &mut [u32],
779 zb: &mut [f32],
780 pitch_pixels: usize,
781 width: u32,
782 height: u32,
783 cam: &CameraState,
784 settings: &OpticastSettings,
785 dense: &SpriteDense,
786 pos: [f32; 3],
787 s: [f32; 3],
788 h: [f32; 3],
789 f: [f32; 3],
790 flags: u32,
791) -> u32 {
792 draw_sprite_dense_shaded(
793 fb,
794 zb,
795 pitch_pixels,
796 width,
797 height,
798 cam,
799 settings,
800 dense,
801 pos,
802 s,
803 h,
804 f,
805 flags,
806 None,
807 )
808}
809
810#[allow(
822 clippy::too_many_arguments,
823 clippy::cast_possible_truncation,
824 clippy::cast_sign_loss
825)]
826#[must_use]
827pub fn draw_sprite_dense_shaded(
828 fb: &mut [u32],
829 zb: &mut [f32],
830 pitch_pixels: usize,
831 width: u32,
832 height: u32,
833 cam: &CameraState,
834 settings: &OpticastSettings,
835 dense: &SpriteDense,
836 pos: [f32; 3],
837 s: [f32; 3],
838 h: [f32; 3],
839 f: [f32; 3],
840 flags: u32,
841 shade_ctx: Option<SpriteShade>,
842) -> u32 {
843 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
844 return 0;
845 }
846 let Some(minv) = invert_basis(s, h, f) else {
847 return 0;
848 };
849 let pivot = dense.pivot;
850 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
851
852 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
854 return 0;
855 };
856
857 let layers =
863 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
864
865 debug_assert_eq!(fb.len(), zb.len());
866 let target = RasterTarget::new(fb, zb);
867 let mut written = 0u32;
868 for py in rect.1..rect.3 {
869 let row = py as usize * pitch_pixels;
870 for px in rect.0..rect.2 {
871 let (origin, dir) = pixel_ray(cam, settings, px, py);
872 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
874 let ol = mat_apply(&minv, rel);
875 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
876 let dir_local = mat_apply(&minv, dir);
877 let fwd_dot =
878 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
879 let idx = row + px as usize;
880
881 if let Some(shade_ctx) = layers {
882 if fwd_dot <= 1e-6 {
884 continue;
885 }
886 let max_t = if no_z {
891 f32::INFINITY
892 } else {
893 unsafe { target.read_depth(idx) }
894 };
895 let Some(acc) = cast_local_layers(
896 dense,
897 origin_local,
898 dir_local,
899 fwd_dot,
900 max_t,
901 shade_ctx,
902 s,
903 h,
904 f,
905 pos,
906 ) else {
907 continue;
908 };
909 let wrote = unsafe {
911 match acc.opaque {
912 Some((bg_color, t)) => {
913 let bg = rgb_to_f32(bg_color);
916 let out = f32_to_rgb([
917 acc.rgb[0] + acc.trans * bg[0],
918 acc.rgb[1] + acc.trans * bg[1],
919 acc.rgb[2] + acc.trans * bg[2],
920 ]);
921 let depth = t * fwd_dot;
922 if no_z {
923 target.write_color(idx, out);
924 target.write_depth(idx, depth);
925 true
926 } else {
927 target.z_test_write(idx, out, depth)
928 }
929 }
930 None => {
931 let bg = rgb_to_f32(target.read_color(idx));
936 let out = f32_to_rgb([
937 acc.rgb[0] + acc.trans * bg[0],
938 acc.rgb[1] + acc.trans * bg[1],
939 acc.rgb[2] + acc.trans * bg[2],
940 ]);
941 target.write_color(idx, out);
942 true
943 }
944 }
945 };
946 written += u32::from(wrote);
947 } else {
948 let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
950 else {
951 continue;
952 };
953 let depth = t * fwd_dot;
954 if depth < NEAR_Z {
955 continue;
956 }
957 let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
962 let lit = if dl.enabled {
963 let to_world = |v: [f32; 3]| {
964 [
965 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
966 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
967 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
968 ]
969 };
970 let n_world = to_world(n_local);
971 let rel = [
972 cell[0] as f32 + 0.5 - pivot[0],
973 cell[1] as f32 + 0.5 - pivot[1],
974 cell[2] as f32 + 0.5 - pivot[2],
975 ];
976 let wc = to_world(rel);
977 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
978 let albedo = [
979 ((color >> 16) & 0xff) as f32 / 255.0,
980 ((color >> 8) & 0xff) as f32 / 255.0,
981 (color & 0xff) as f32 / 255.0,
982 ];
983 let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
987 ctx: WorldShadowCtx::identity(occ),
988 });
989 let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
990 shade_dynamic(albedo, 1.0, n_world, center, &dl, tester)
991 } else {
992 shade(color, 0)
993 };
994 let wrote = unsafe {
997 if no_z {
998 target.write_color(idx, lit);
999 target.write_depth(idx, depth);
1000 true
1001 } else {
1002 target.z_test_write(idx, lit, depth)
1003 }
1004 };
1005 written += u32::from(wrote);
1006 }
1007 }
1008 }
1009 written
1010}
1011
1012#[allow(
1016 clippy::cast_possible_truncation,
1017 clippy::cast_sign_loss,
1018 clippy::cast_precision_loss
1019)]
1020fn project_screen_rect(
1021 dense: &SpriteDense,
1022 pos: [f32; 3],
1023 s: [f32; 3],
1024 h: [f32; 3],
1025 f: [f32; 3],
1026 cam: &CameraState,
1027 settings: &OpticastSettings,
1028 width: u32,
1029 height: u32,
1030) -> Option<(u32, u32, u32, u32)> {
1031 let (xs, ys, zs) = (
1032 dense.dims[0] as f32,
1033 dense.dims[1] as f32,
1034 dense.dims[2] as f32,
1035 );
1036 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1037 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1038 let mut all_front = true;
1039 for &cx in &[0.0, xs] {
1040 for &cy in &[0.0, ys] {
1041 for &cz in &[0.0, zs] {
1042 let lx = cx - xp;
1044 let ly = cy - yp;
1045 let lz = cz - zp;
1046 let world = [
1047 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1048 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1049 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1050 ];
1051 let rel = [
1052 world[0] - cam.pos[0],
1053 world[1] - cam.pos[1],
1054 world[2] - cam.pos[2],
1055 ];
1056 let cz_cam =
1057 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1058 if cz_cam < NEAR_Z {
1059 all_front = false;
1060 continue;
1061 }
1062 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1063 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1064 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1065 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1066 x0 = x0.min(sx);
1067 y0 = y0.min(sy);
1068 x1 = x1.max(sx);
1069 y1 = y1.max(sy);
1070 }
1071 }
1072 }
1073 let (w, h) = (width as f32, height as f32);
1074 let (rx0, ry0, rx1, ry1) = if all_front {
1075 (
1076 (x0 - 1.0).max(0.0),
1077 (y0 - 1.0).max(0.0),
1078 (x1 + 1.0).min(w),
1079 (y1 + 1.0).min(h),
1080 )
1081 } else {
1082 (0.0, 0.0, w, h)
1084 };
1085 if rx0 >= rx1 || ry0 >= ry1 {
1086 return None;
1087 }
1088 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1089}
1090
1091pub struct ClipFlipbook {
1098 frames: Vec<SpriteDense>,
1099}
1100
1101impl ClipFlipbook {
1102 #[must_use]
1105 pub fn empty() -> Self {
1106 Self { frames: Vec::new() }
1107 }
1108
1109 #[must_use]
1111 pub fn from_decoded(clip: &DecodedClip) -> Self {
1112 Self::from_decoded_with_materials(clip, &[])
1113 }
1114
1115 #[must_use]
1121 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(u32, u8)]) -> Self {
1122 let frames = clip
1123 .frames
1124 .iter()
1125 .map(|frame| {
1126 SpriteDense::from_voxel_frame_with_materials(
1127 frame,
1128 clip.dims,
1129 clip.pivot,
1130 material_map,
1131 )
1132 })
1133 .collect();
1134 Self { frames }
1135 }
1136
1137 #[must_use]
1138 pub fn frame_count(&self) -> usize {
1139 self.frames.len()
1140 }
1141
1142 #[must_use]
1144 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1145 self.frames.get(frame)
1146 }
1147
1148 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1152 match self.frames.get_mut(frame) {
1153 Some(slot) => {
1154 *slot = dense;
1155 true
1156 }
1157 None => false,
1158 }
1159 }
1160
1161 #[allow(clippy::too_many_arguments)]
1165 #[must_use]
1166 pub fn draw_frame(
1167 &self,
1168 fb: &mut [u32],
1169 zb: &mut [f32],
1170 pitch_pixels: usize,
1171 width: u32,
1172 height: u32,
1173 cam: &CameraState,
1174 settings: &OpticastSettings,
1175 frame: usize,
1176 pos: [f32; 3],
1177 s: [f32; 3],
1178 h: [f32; 3],
1179 f: [f32; 3],
1180 flags: u32,
1181 ) -> u32 {
1182 self.draw_frame_shaded(
1183 fb,
1184 zb,
1185 pitch_pixels,
1186 width,
1187 height,
1188 cam,
1189 settings,
1190 frame,
1191 pos,
1192 s,
1193 h,
1194 f,
1195 flags,
1196 None,
1197 )
1198 }
1199
1200 #[allow(clippy::too_many_arguments)]
1205 #[must_use]
1206 pub fn draw_frame_shaded(
1207 &self,
1208 fb: &mut [u32],
1209 zb: &mut [f32],
1210 pitch_pixels: usize,
1211 width: u32,
1212 height: u32,
1213 cam: &CameraState,
1214 settings: &OpticastSettings,
1215 frame: usize,
1216 pos: [f32; 3],
1217 s: [f32; 3],
1218 h: [f32; 3],
1219 f: [f32; 3],
1220 flags: u32,
1221 shade_ctx: Option<SpriteShade>,
1222 ) -> u32 {
1223 let Some(dense) = self.frames.get(frame) else {
1224 return 0;
1225 };
1226 draw_sprite_dense_shaded(
1227 fb,
1228 zb,
1229 pitch_pixels,
1230 width,
1231 height,
1232 cam,
1233 settings,
1234 dense,
1235 pos,
1236 s,
1237 h,
1238 f,
1239 flags,
1240 shade_ctx,
1241 )
1242 }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248 use crate::camera_math;
1249 use crate::Camera;
1250 use roxlap_formats::kv6::Kv6;
1251 use roxlap_formats::material::{Material, MaterialTable};
1252
1253 #[test]
1258 fn cast_local_reports_face_normal() {
1259 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| (z >= 4).then_some(0x80_C0_40_20));
1261 let dense = SpriteDense::from_kv6(&kv6);
1262 let (_c, _t, n, cell) =
1264 cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1265 assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1266 assert!(
1267 n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1268 "z-crossing face normal points back toward the ray (-z): {n:?}",
1269 );
1270 }
1271 use roxlap_formats::sprite::Sprite;
1272 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1273
1274 fn settings(w: u32, h: u32) -> OpticastSettings {
1275 OpticastSettings::for_oracle_framebuffer(w, h)
1276 }
1277
1278 fn cam_looking_y() -> Camera {
1280 Camera {
1281 pos: [0.0, 0.0, 0.0],
1282 right: [1.0, 0.0, 0.0],
1283 down: [0.0, 0.0, 1.0],
1284 forward: [0.0, 1.0, 0.0],
1285 }
1286 }
1287
1288 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1290 let owpc = dims[2].div_ceil(32).max(1) as usize;
1291 let cols = (dims[0] * dims[1]) as usize;
1292 let mut occupancy = vec![0u32; cols * owpc];
1293 let mut color_offsets = vec![0u32; cols + 1];
1294 let mut colors = Vec::new();
1295 for y in 0..dims[1] {
1296 for x in 0..dims[0] {
1297 let col = (x + y * dims[0]) as usize;
1298 color_offsets[col] = colors.len() as u32;
1299 for z in 0..dims[2] {
1300 if let Some(c) = fill(x, y, z) {
1301 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1302 colors.push(c);
1303 }
1304 }
1305 }
1306 }
1307 color_offsets[cols] = colors.len() as u32;
1308 VoxelFrame {
1309 occupancy,
1310 colors,
1311 color_offsets,
1312 }
1313 }
1314
1315 #[test]
1320 fn clip_flipbook_frames_render_differently() {
1321 let dims = [8u32, 8, 8];
1322 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(
1325 dims,
1326 [4.0, 4.0, 4.0],
1327 1.0,
1328 LoopMode::Loop,
1329 &[f0, f1],
1330 &[],
1331 33,
1332 0,
1333 );
1334 let decoded = clip.decode().expect("decode");
1335 let book = ClipFlipbook::from_decoded(&decoded);
1336 assert_eq!(book.frame_count(), 2);
1337 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1338
1339 let (w, h) = (64u32, 64u32);
1340 let n = (w * h) as usize;
1341 let cam = cam_looking_y();
1342 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1343 let cfg = settings(w, h);
1344 let pose = [0.0, 40.0, 0.0];
1345 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1346
1347 let render = |frame: usize| -> Vec<u32> {
1348 let mut fb = vec![0u32; n];
1349 let mut zb = vec![f32::INFINITY; n];
1350 let wrote = book.draw_frame(
1351 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1352 );
1353 assert!(wrote > 0, "frame {frame} should draw some pixels");
1354 fb
1355 };
1356 let fb0 = render(0);
1357 let fb1 = render(1);
1358 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1359 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1362 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1363 let mut fb = vec![0u32; n];
1365 let mut zb = vec![f32::INFINITY; n];
1366 assert_eq!(
1367 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1368 0
1369 );
1370 }
1371
1372 #[test]
1373 fn clip_flipbook_set_frame_replaces_one_frame() {
1374 let dims = [8u32, 8, 8];
1377 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 =
1380 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1381 let decoded = clip.decode().unwrap();
1382 let mut book = ClipFlipbook::from_decoded(&decoded);
1383
1384 let (w, h) = (64u32, 64u32);
1385 let n = (w * h) as usize;
1386 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1387 let cfg = settings(w, h);
1388 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1389 let mut fb = vec![0u32; n];
1390 let mut zb = vec![f32::INFINITY; n];
1391 let _ = b.draw_frame(
1392 &mut fb,
1393 &mut zb,
1394 w as usize,
1395 w,
1396 h,
1397 &cs,
1398 &cfg,
1399 0,
1400 [0.0, 40.0, 0.0],
1401 [1.0, 0.0, 0.0],
1402 [0.0, 1.0, 0.0],
1403 [0.0, 0.0, 1.0],
1404 0,
1405 );
1406 fb
1407 };
1408
1409 let before = render0(&book);
1410 assert!(
1411 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1412 "frame 0 is red"
1413 );
1414
1415 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1417 assert!(book.set_frame(0, replacement));
1418 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1419 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1420
1421 let after = render0(&book);
1422 assert!(
1423 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1424 "frame 0 now green"
1425 );
1426 assert_ne!(before, after);
1427 }
1428
1429 #[test]
1432 fn cube_sprite_renders() {
1433 let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
1434 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1435 let (w, h) = (64u32, 64u32);
1436 let n = (w * h) as usize;
1437 let mut fb = vec![0u32; n];
1438 let mut zb = vec![f32::INFINITY; n];
1439 let cam = cam_looking_y();
1440 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1441 let wrote = draw_sprite_dda(
1442 &mut fb,
1443 &mut zb,
1444 w as usize,
1445 w,
1446 h,
1447 &cs,
1448 &settings(w, h),
1449 &sprite,
1450 );
1451
1452 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1453 let centre = (h / 2 * w + w / 2) as usize;
1454 assert_eq!(
1455 fb[centre] & 0x00ff_ffff,
1456 0x00_C0_40_20,
1457 "got {:08x}",
1458 fb[centre]
1459 );
1460 assert!(
1462 (zb[centre] - 36.0).abs() < 3.0,
1463 "centre depth {} not ≈ 36",
1464 zb[centre]
1465 );
1466 }
1467
1468 #[test]
1473 fn zero_high_byte_sprite_not_black() {
1474 let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
1475 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1476 let (w, h) = (64u32, 64u32);
1477 let n = (w * h) as usize;
1478 let mut fb = vec![0u32; n];
1479 let mut zb = vec![f32::INFINITY; n];
1480 let cam = cam_looking_y();
1481 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1482 let wrote = draw_sprite_dda(
1483 &mut fb,
1484 &mut zb,
1485 w as usize,
1486 w,
1487 h,
1488 &cs,
1489 &settings(w, h),
1490 &sprite,
1491 );
1492 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1493 let centre = (h / 2 * w + w / 2) as usize;
1494 assert_eq!(
1495 fb[centre] & 0x00ff_ffff,
1496 0x00_C0_40_20,
1497 "zero-high-byte sprite rendered as {:08x} (black bug)",
1498 fb[centre]
1499 );
1500 }
1501
1502 #[test]
1505 fn sprite_respects_zbuffer() {
1506 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1507 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1508 let (w, h) = (32u32, 32u32);
1509 let n = (w * h) as usize;
1510 let cam = cam_looking_y();
1511 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1512 let centre = (h / 2 * w + w / 2) as usize;
1513
1514 let mut fb = vec![0u32; n];
1516 let mut zb = vec![f32::INFINITY; n];
1517 fb[centre] = 0x80_11_22_33;
1518 zb[centre] = 10.0;
1519 let _ = draw_sprite_dda(
1520 &mut fb,
1521 &mut zb,
1522 w as usize,
1523 w,
1524 h,
1525 &cs,
1526 &settings(w, h),
1527 &sprite,
1528 );
1529 assert_eq!(
1530 fb[centre], 0x80_11_22_33,
1531 "near terrain must occlude sprite"
1532 );
1533
1534 let mut fb2 = vec![0u32; n];
1536 let mut zb2 = vec![f32::INFINITY; n];
1537 fb2[centre] = 0x80_11_22_33;
1538 zb2[centre] = 100.0;
1539 let _ = draw_sprite_dda(
1540 &mut fb2,
1541 &mut zb2,
1542 w as usize,
1543 w,
1544 h,
1545 &cs,
1546 &settings(w, h),
1547 &sprite,
1548 );
1549 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1550 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1551 }
1552
1553 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1556 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1557 for py in 0..h {
1558 for px in 0..w {
1559 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1560 x0 = x0.min(px);
1561 y0 = y0.min(py);
1562 x1 = x1.max(px);
1563 y1 = y1.max(py);
1564 }
1565 }
1566 }
1567 (x0, y0, x1, y1)
1568 }
1569
1570 #[test]
1575 fn posed_basis_reorients_silhouette() {
1576 let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
1579 let (w, h) = (64u32, 64u32);
1580 let n = (w * h) as usize;
1581 let cam = cam_looking_y();
1582 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1583
1584 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1586 let mut fb = vec![0u32; n];
1587 let mut zb = vec![f32::INFINITY; n];
1588 let _ = draw_sprite_dda(
1589 &mut fb,
1590 &mut zb,
1591 w as usize,
1592 w,
1593 h,
1594 &cs,
1595 &settings(w, h),
1596 &aa,
1597 );
1598 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1599 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1600 assert!(
1601 aa_wide > 4,
1602 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1603 );
1604
1605 let mut posed = aa.clone();
1608 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];
1612 let mut zb2 = vec![f32::INFINITY; n];
1613 let _ = draw_sprite_dda(
1614 &mut fb2,
1615 &mut zb2,
1616 w as usize,
1617 w,
1618 h,
1619 &cs,
1620 &settings(w, h),
1621 &posed,
1622 );
1623 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1624 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1625 assert!(
1626 posed_tall > 4,
1627 "posed box should be taller than wide (got h-w={posed_tall})"
1628 );
1629 }
1630
1631 #[test]
1634 fn degenerate_basis_draws_nothing() {
1635 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1636 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1637 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1639 let n = (w * h) as usize;
1640 let mut fb = vec![0u32; n];
1641 let mut zb = vec![f32::INFINITY; n];
1642 let cam = cam_looking_y();
1643 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1644 let wrote = draw_sprite_dda(
1645 &mut fb,
1646 &mut zb,
1647 w as usize,
1648 w,
1649 h,
1650 &cs,
1651 &settings(w, h),
1652 &sprite,
1653 );
1654 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1655 }
1656
1657 #[test]
1659 fn invisible_sprite_skipped() {
1660 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1661 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1662 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1663 let (w, h) = (32u32, 32u32);
1664 let n = (w * h) as usize;
1665 let mut fb = vec![0u32; n];
1666 let mut zb = vec![f32::INFINITY; n];
1667 let cam = cam_looking_y();
1668 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1669 let wrote = draw_sprite_dda(
1670 &mut fb,
1671 &mut zb,
1672 w as usize,
1673 w,
1674 h,
1675 &cs,
1676 &settings(w, h),
1677 &sprite,
1678 );
1679 assert_eq!(wrote, 0);
1680 }
1681
1682 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1688 let mut table = MaterialTable::new();
1689 table.set(1, mat);
1690 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1691 let (w, h) = (64u32, 64u32);
1692 let n = (w * h) as usize;
1693 let mut fb = vec![bg; n];
1694 let mut zb = vec![zb_v; n];
1695 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1696 let sh = SpriteShade {
1697 materials: &table,
1698 lights: CpuLights::default(),
1699 material: 1,
1700 alpha_mul,
1701 shadow: None,
1702 };
1703 let _ = draw_sprite_dense_shaded(
1704 &mut fb,
1705 &mut zb,
1706 w as usize,
1707 w,
1708 h,
1709 &cs,
1710 &settings(w, h),
1711 &dense,
1712 [0.0, 40.0, 0.0],
1713 [1.0, 0.0, 0.0],
1714 [0.0, 1.0, 0.0],
1715 [0.0, 0.0, 1.0],
1716 0,
1717 Some(sh),
1718 );
1719 (fb[(h / 2 * w + w / 2) as usize], fb)
1720 }
1721
1722 #[test]
1725 fn additive_sprite_brightens_background() {
1726 let bg = 0x80_20_20_20;
1727 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1728 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1729 assert!(
1730 cr > 0x20 && cg > 0x20 && cb >= 0x20,
1731 "centre {centre:08x} should be brighter than bg"
1732 );
1733 assert!(
1735 cr >= cg && cr >= cb,
1736 "additive of a red-dominant cube stays red-dominant"
1737 );
1738 }
1739
1740 #[test]
1743 fn alpha_blend_sprite_between_bg_and_color() {
1744 let bg = 0x80_20_20_20;
1745 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
1746 let cr = (centre >> 16) & 0xff;
1747 assert!(
1748 cr > 0x20,
1749 "blended red must rise above bg 0x20 (got {cr:02x})"
1750 );
1751 assert!(
1752 cr < 0xC0,
1753 "blended red must stay below opaque 0xC0 (got {cr:02x})"
1754 );
1755 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
1757 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
1758 }
1759
1760 #[test]
1763 fn alpha_mul_scales_opacity() {
1764 let bg = 0x80_20_20_20;
1765 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
1766 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
1767 let r_full = (full >> 16) & 0xff;
1768 let r_faded = (faded >> 16) & 0xff;
1769 assert!(
1771 r_full > r_faded,
1772 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
1773 );
1774 assert!(r_faded > 0x20, "even faded lifts above bg");
1775 }
1776
1777 #[test]
1781 fn opaque_shade_ctx_matches_plain_path() {
1782 let table = MaterialTable::new();
1783 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1784 let (w, h) = (64u32, 64u32);
1785 let n = (w * h) as usize;
1786 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1787 let pose = (
1788 [0.0, 40.0, 0.0],
1789 [1.0, 0.0, 0.0],
1790 [0.0, 1.0, 0.0],
1791 [0.0, 0.0, 1.0],
1792 );
1793
1794 let mut fb_plain = vec![0u32; n];
1795 let mut zb_plain = vec![f32::INFINITY; n];
1796 let _ = draw_sprite_dense(
1797 &mut fb_plain,
1798 &mut zb_plain,
1799 w as usize,
1800 w,
1801 h,
1802 &cs,
1803 &settings(w, h),
1804 &dense,
1805 pose.0,
1806 pose.1,
1807 pose.2,
1808 pose.3,
1809 0,
1810 );
1811
1812 let mut fb_sh = vec![0u32; n];
1813 let mut zb_sh = vec![f32::INFINITY; n];
1814 let sh = SpriteShade {
1815 materials: &table,
1816 lights: CpuLights::default(),
1817 material: 0, alpha_mul: 255,
1819 shadow: None,
1820 };
1821 let _ = draw_sprite_dense_shaded(
1822 &mut fb_sh,
1823 &mut zb_sh,
1824 w as usize,
1825 w,
1826 h,
1827 &cs,
1828 &settings(w, h),
1829 &dense,
1830 pose.0,
1831 pose.1,
1832 pose.2,
1833 pose.3,
1834 0,
1835 Some(sh),
1836 );
1837
1838 assert_eq!(
1839 fb_plain, fb_sh,
1840 "opaque shade-ctx must match the plain path bit-for-bit"
1841 );
1842 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
1843 }
1844
1845 #[test]
1849 fn translucent_sprite_occluded_by_near_terrain() {
1850 let bg = 0x80_20_20_20;
1851 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
1852 assert_eq!(
1853 centre, bg,
1854 "near terrain (z=5) must occlude the sprite at y≈36"
1855 );
1856 }
1857
1858 #[test]
1864 fn per_span_thickness_independent() {
1865 fn centre(ysiz: u32) -> u32 {
1866 let mut table = MaterialTable::new();
1867 table.set(1, Material::alpha_blend(128));
1868 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, 0x80_C0_40_20));
1869 let (w, h) = (64u32, 64u32);
1870 let n = (w * h) as usize;
1871 let mut fb = vec![0x80_10_10_10u32; n];
1872 let mut zb = vec![f32::INFINITY; n];
1873 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1874 let sh = SpriteShade {
1875 materials: &table,
1876 lights: CpuLights::default(),
1877 material: 1,
1878 alpha_mul: 255,
1879 shadow: None,
1880 };
1881 let _ = draw_sprite_dense_shaded(
1882 &mut fb,
1883 &mut zb,
1884 w as usize,
1885 w,
1886 h,
1887 &cs,
1888 &settings(w, h),
1889 &dense,
1890 [0.0, 40.0, 0.0],
1891 [1.0, 0.0, 0.0],
1892 [0.0, 1.0, 0.0],
1893 [0.0, 0.0, 1.0],
1894 0,
1895 Some(sh),
1896 );
1897 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
1898 }
1899 assert_eq!(
1903 centre(1),
1904 centre(2),
1905 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
1906 );
1907 }
1908
1909 #[test]
1914 fn volumetric_thickness_deepens_opacity() {
1915 fn red_at(depth: u32) -> u32 {
1918 let mut table = MaterialTable::new();
1919 table.set(1, Material::volumetric(128));
1920 let kv6 =
1925 Kv6::from_fn_keep_interior(8, depth, 8, |_, _, _| Some(0x80_C0_20_20), |_| true);
1926 let dense = SpriteDense::from_kv6(&kv6);
1927 let (w, h) = (64u32, 64u32);
1928 let n = (w * h) as usize;
1929 let mut fb = vec![0x80_10_10_10u32; n];
1930 let mut zb = vec![f32::INFINITY; n];
1931 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1932 let sh = SpriteShade {
1933 materials: &table,
1934 lights: CpuLights::default(),
1935 material: 1,
1936 alpha_mul: 255,
1937 shadow: None,
1938 };
1939 let _ = draw_sprite_dense_shaded(
1940 &mut fb,
1941 &mut zb,
1942 w as usize,
1943 w,
1944 h,
1945 &cs,
1946 &settings(w, h),
1947 &dense,
1948 [0.0, 40.0, 0.0],
1949 [1.0, 0.0, 0.0],
1950 [0.0, 1.0, 0.0],
1951 [0.0, 0.0, 1.0],
1952 0,
1953 Some(sh),
1954 );
1955 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
1956 }
1957 let shallow = red_at(1);
1958 let deep = red_at(12);
1959 assert!(
1962 shallow > 0x10,
1963 "even a 1-deep volume tints (got {shallow:02x})"
1964 );
1965 assert!(
1966 deep > shallow,
1967 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
1968 );
1969 }
1970
1971 #[test]
1976 fn sprite_occluder_blocks_ray_through_volume() {
1977 use crate::dda::WorldOccluder;
1978 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF));
1981 let mut occ = SpriteOccluder::new();
1982 occ.push(
1983 dense,
1984 [0.0, 0.0, 0.0],
1985 [1.0, 0.0, 0.0],
1986 [0.0, 1.0, 0.0],
1987 [0.0, 0.0, 1.0],
1988 );
1989 assert!(!occ.is_empty());
1990 assert!(
1992 occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
1993 "a ray through the cube must be occluded"
1994 );
1995 assert!(
1997 !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
1998 "a ray missing the cube must not be occluded"
1999 );
2000 assert!(
2002 !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2003 "max_t shorter than the distance to the cube ⇒ unoccluded"
2004 );
2005 }
2006
2007 #[test]
2012 fn sprite_receives_hard_shadow() {
2013 let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2019 let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2020 (dx * dx + dy * dy + dz * dz <= 49).then_some(0x80_C0_C0_C0)
2021 }));
2022 let mut occ = SpriteOccluder::new();
2023 occ.push(
2024 SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF)),
2025 [0.0, 25.0, 0.0],
2026 [1.0, 0.0, 0.0],
2027 [0.0, 1.0, 0.0],
2028 [0.0, 0.0, 1.0],
2029 );
2030 let table = MaterialTable::new();
2031 let base = CpuLights {
2032 enabled: true,
2033 sun: true,
2034 sun_dir: [0.0, -1.0, 0.0], sun_color: [1.0; 3],
2036 sun_intensity: 1.0,
2037 sun_casts_shadow: true,
2038 ambient: [0.3; 3],
2039 shadow_strength: 0.85,
2040 shadow_bias: 1.5,
2041 shadow_max_dist: 128.0,
2042 ..CpuLights::default()
2043 };
2044 let (w, h) = (64u32, 64u32);
2045 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2046 let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2047 let n = (w * h) as usize;
2048 let mut fb = vec![0u32; n];
2049 let mut zb = vec![f32::INFINITY; n];
2050 let sh = SpriteShade {
2051 materials: &table,
2052 lights: base,
2053 material: 0,
2054 alpha_mul: 255,
2055 shadow,
2056 };
2057 let _ = draw_sprite_dense_shaded(
2058 &mut fb,
2059 &mut zb,
2060 w as usize,
2061 w,
2062 h,
2063 &cs,
2064 &settings(w, h),
2065 &target,
2066 [0.0, 40.0, 0.0],
2067 [1.0, 0.0, 0.0],
2068 [0.0, 1.0, 0.0],
2069 [0.0, 0.0, 1.0],
2070 0,
2071 Some(sh),
2072 );
2073 fb.iter()
2074 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2075 .sum()
2076 };
2077 let lit = sum_lum(None);
2078 let shadowed = sum_lum(Some(&occ));
2079 assert!(
2080 shadowed < lit,
2081 "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2082 );
2083 }
2084
2085 #[test]
2090 fn translucent_sprite_layers_are_lit() {
2091 fn center_red(lights: CpuLights) -> u32 {
2092 let mut table = MaterialTable::new();
2093 table.set(1, Material::alpha_blend(160));
2094 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, 0x80_E0_30_30));
2095 let (w, h) = (64u32, 64u32);
2096 let n = (w * h) as usize;
2097 let mut fb = vec![0x80_10_10_10u32; n];
2098 let mut zb = vec![f32::INFINITY; n];
2099 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2100 let sh = SpriteShade {
2101 materials: &table,
2102 lights,
2103 material: 1,
2104 alpha_mul: 255,
2105 shadow: None,
2106 };
2107 let _ = draw_sprite_dense_shaded(
2108 &mut fb,
2109 &mut zb,
2110 w as usize,
2111 w,
2112 h,
2113 &cs,
2114 &settings(w, h),
2115 &dense,
2116 [0.0, 40.0, 0.0],
2117 [1.0, 0.0, 0.0],
2118 [0.0, 1.0, 0.0],
2119 [0.0, 0.0, 1.0],
2120 0,
2121 Some(sh),
2122 );
2123 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2124 }
2125 let baked = center_red(CpuLights::default()); let dim = center_red(CpuLights {
2127 enabled: true,
2128 ambient: [0.3; 3], ..CpuLights::default()
2130 });
2131 assert!(
2132 dim < baked,
2133 "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2134 );
2135 }
2136
2137 #[test]
2142 fn translucent_sprite_tints_opaque_sprite_behind() {
2143 let mut table = MaterialTable::new();
2144 table.set(1, Material::alpha_blend(128));
2145 let (w, h) = (64u32, 64u32);
2146 let n = (w * h) as usize;
2147 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
2149 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2150 let cfg = settings(w, h);
2151 let id = [1.0, 0.0, 0.0];
2152 let up = [0.0, 1.0, 0.0];
2153 let fw = [0.0, 0.0, 1.0];
2154 let centre = (h / 2 * w + w / 2) as usize;
2155
2156 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_FF_00_00));
2158 let sh_op = SpriteShade {
2159 materials: &table,
2160 lights: CpuLights::default(),
2161 material: 0,
2162 alpha_mul: 255,
2163 shadow: None,
2164 };
2165 let _ = draw_sprite_dense_shaded(
2166 &mut fb,
2167 &mut zb,
2168 w as usize,
2169 w,
2170 h,
2171 &cs,
2172 &cfg,
2173 &backdrop,
2174 [0.0, 80.0, 0.0],
2175 id,
2176 up,
2177 fw,
2178 0,
2179 Some(sh_op),
2180 );
2181 let after_backdrop = fb[centre];
2182 assert_eq!(
2183 after_backdrop & 0x00ff_ffff,
2184 0x00FF_0000,
2185 "backdrop red must be drawn first"
2186 );
2187
2188 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_00_FF_FF));
2190 let sh_gl = SpriteShade {
2191 materials: &table,
2192 lights: CpuLights::default(),
2193 material: 1,
2194 alpha_mul: 255,
2195 shadow: None,
2196 };
2197 let wrote = draw_sprite_dense_shaded(
2198 &mut fb,
2199 &mut zb,
2200 w as usize,
2201 w,
2202 h,
2203 &cs,
2204 &cfg,
2205 &glass,
2206 [0.0, 40.0, 0.0],
2207 id,
2208 up,
2209 fw,
2210 0,
2211 Some(sh_gl),
2212 );
2213 let _ = wrote;
2214 let after_glass = fb[centre];
2215 assert_ne!(
2216 after_glass, after_backdrop,
2217 "glass must tint the backdrop (composite over it)"
2218 );
2219 assert!(
2221 (after_glass >> 16) & 0xff < 0xFF,
2222 "glass should reduce the backdrop's red (got {after_glass:08x})"
2223 );
2224 }
2225
2226 #[test]
2229 fn from_kv6_with_materials_classifies_by_color() {
2230 let col = 0x80_AA_BB_CC;
2231 let kv6 = Kv6::solid_cube(6, col);
2232 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(0x00AA_BBCC, 2)]);
2233 assert_eq!(
2234 dense.mat.len(),
2235 dense.col.len(),
2236 "per-voxel mat array sized"
2237 );
2238 let mut solids = 0;
2239 for idx in 0..dense.occ.len() {
2240 if dense.occ[idx] {
2241 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2242 solids += 1;
2243 }
2244 }
2245 assert!(solids > 0, "cube has solid voxels");
2246 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(0x0012_3456, 5)]);
2248 assert!(
2249 dense0.mat.iter().all(|&m| m == 0),
2250 "unmapped colour → material 0"
2251 );
2252 }
2253
2254 #[test]
2259 fn per_voxel_material_matches_uniform_when_homogeneous() {
2260 let mut table = MaterialTable::new();
2261 table.set(1, Material::alpha_blend(120));
2262 let col = 0x80_30_A0_F0;
2263 let kv6 = Kv6::solid_cube(10, col);
2264 let (w, h) = (64u32, 64u32);
2265 let n = (w * h) as usize;
2266 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2267 let cfg = settings(w, h);
2268 let (pos, s, hh, f) = (
2269 [0.0, 40.0, 0.0],
2270 [1.0, 0.0, 0.0],
2271 [0.0, 1.0, 0.0],
2272 [0.0, 0.0, 1.0],
2273 );
2274 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2275 let mut fb = vec![0x80_10_10_10u32; n];
2276 let mut zb = vec![f32::INFINITY; n];
2277 let sh = SpriteShade {
2278 materials: &table,
2279 lights: CpuLights::default(),
2280 material,
2281 alpha_mul: 255,
2282 shadow: None,
2283 };
2284 let _ = draw_sprite_dense_shaded(
2285 &mut fb,
2286 &mut zb,
2287 w as usize,
2288 w,
2289 h,
2290 &cs,
2291 &cfg,
2292 dense,
2293 pos,
2294 s,
2295 hh,
2296 f,
2297 0,
2298 Some(sh),
2299 );
2300 fb
2301 };
2302 let pv = render(
2305 &SpriteDense::from_kv6_with_materials(&kv6, &[(col & 0xff_ffff, 1)]),
2306 0,
2307 );
2308 let un = render(&SpriteDense::from_kv6(&kv6), 1);
2310 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2311 let centre = (h / 2 * w + w / 2) as usize;
2313 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2314 }
2315
2316 #[test]
2321 fn clip_flipbook_with_materials_classifies_every_frame() {
2322 let dims = [6u32, 6, 6];
2323 let glass = 0x00AA_BBCC;
2324 let glass_lit = 0x80AA_BBCC;
2325 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2327 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2328 let clip = VoxelClip::from_frames(
2329 dims,
2330 [3.0, 3.0, 3.0],
2331 1.0,
2332 LoopMode::Loop,
2333 &[f0, f1],
2334 &[],
2335 33,
2336 0,
2337 );
2338 let decoded = clip.decode().expect("decode");
2339
2340 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2341 assert_eq!(book.frame_count(), 2);
2342 for fr in 0..2 {
2343 let dense = book.frame(fr).expect("frame in range");
2344 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2345 let mut solids = 0;
2346 for idx in 0..dense.occ.len() {
2347 if dense.occ[idx] {
2348 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2349 solids += 1;
2350 }
2351 }
2352 assert!(solids > 0, "frame {fr} has solid voxels");
2353 }
2354
2355 let plain = ClipFlipbook::from_decoded(&decoded);
2357 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2358 for fr in 0..2 {
2359 assert!(plain.frame(fr).unwrap().mat.is_empty());
2360 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2361 }
2362 }
2363}