1use roxlap_formats::kv6::Kv6;
23use roxlap_formats::material::{material_for_color, BlendMode, MaterialTable};
24use roxlap_formats::sprite::{
25 Sprite, SPRITE_FLAG_INVISIBLE, SPRITE_FLAG_LIGHT_AMBIENT_ONLY, SPRITE_FLAG_LIGHT_WORLD_UP,
26 SPRITE_FLAG_NO_Z,
27};
28use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
29use roxlap_formats::Rgb;
30
31use std::sync::Arc;
32
33use crate::camera_math::CameraState;
34use crate::dda::{
35 dda_setup, intersect_aabb, min_axis, pixel_ray, shade, shade_dynamic, CpuLights, ShadowTester,
36 WorldOccluder, WorldShadow, WorldShadowCtx,
37};
38use crate::opticast::OpticastSettings;
39use crate::raster_target::RasterTarget;
40
41const NEAR_Z: f32 = 1.0;
44
45#[inline]
51fn full_bright(col: u32) -> u32 {
52 (col & 0x00ff_ffff) | 0x8000_0000
53}
54
55#[derive(Clone)]
64pub struct SpriteDense {
65 dims: [i32; 3],
66 occ: Vec<bool>,
67 col: Vec<u32>,
68 mat: Vec<u8>,
74 pivot: [f32; 3],
75}
76
77impl SpriteDense {
78 #[must_use]
80 #[allow(clippy::cast_possible_wrap)]
81 pub fn from_kv6(kv6: &Kv6) -> Self {
82 let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
83 let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
84 let mut occ = vec![false; n];
85 let mut col = vec![0u32; n];
86 let mut vi = 0usize;
87 for x in 0..kv6.xsiz as usize {
88 for y in 0..kv6.ysiz as usize {
89 let cnt = usize::from(kv6.ylen[x][y]);
90 for _ in 0..cnt {
91 let v = kv6.voxels[vi];
92 vi += 1;
93 let z = i32::from(v.z);
94 if z >= 0 && z < dims[2] {
95 let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
96 occ[idx] = true;
97 col[idx] = full_bright(v.col);
98 }
99 }
100 }
101 }
102 Self {
103 dims,
104 occ,
105 col,
106 mat: Vec::new(),
107 pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
108 }
109 }
110
111 #[must_use]
119 #[allow(clippy::cast_possible_wrap)]
120 pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(Rgb, u8)]) -> Self {
121 let mut dense = Self::from_kv6(kv6);
122 if !material_map.is_empty() {
123 let n = dense.col.len();
124 let mut mat = vec![0u8; n];
125 for (idx, slot) in mat.iter_mut().enumerate() {
126 if dense.occ[idx] {
127 *slot = material_for_color(material_map, dense.col[idx]);
128 }
129 }
130 dense.mat = mat;
131 }
132 dense
133 }
134
135 #[must_use]
141 #[allow(clippy::cast_possible_wrap)]
142 pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
143 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
144 let owpc = mz.div_ceil(32).max(1) as usize;
145 let n = (mx * my * mz) as usize;
146 let mut occ = vec![false; n];
147 let mut col = vec![0u32; n];
148 for col_idx in 0..(mx * my) as usize {
149 let x = col_idx as u32 % mx;
150 let y = col_idx as u32 / mx;
151 let run_start = frame.color_offsets[col_idx] as usize;
152 let mut k = 0usize;
153 for z in 0..mz {
154 let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
155 if (word >> (z & 31)) & 1 != 0 {
156 let idx = (((x * my + y) * mz) + z) as usize;
157 occ[idx] = true;
158 col[idx] = full_bright(frame.colors[run_start + k]);
159 k += 1;
160 }
161 }
162 }
163 Self {
164 dims: [mx as i32, my as i32, mz as i32],
165 occ,
166 col,
167 mat: Vec::new(),
168 pivot,
169 }
170 }
171
172 #[must_use]
178 pub fn from_voxel_frame_with_materials(
179 frame: &VoxelFrame,
180 dims: [u32; 3],
181 pivot: [f32; 3],
182 material_map: &[(Rgb, u8)],
183 ) -> Self {
184 let mut dense = Self::from_voxel_frame(frame, dims, pivot);
185 if !material_map.is_empty() {
186 let n = dense.col.len();
187 let mut mat = vec![0u8; n];
188 for (idx, slot) in mat.iter_mut().enumerate() {
189 if dense.occ[idx] {
190 *slot = material_for_color(material_map, dense.col[idx]);
191 }
192 }
193 dense.mat = mat;
194 }
195 dense
196 }
197
198 #[inline]
199 #[allow(clippy::cast_sign_loss)]
200 fn idx_of(&self, c: [i32; 3]) -> usize {
201 ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
202 }
203
204 #[inline]
205 fn at(&self, c: [i32; 3]) -> Option<u32> {
206 let idx = self.idx_of(c);
207 self.occ[idx].then(|| self.col[idx])
208 }
209}
210
211fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
214 let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
215 + f[0] * (s[1] * h[2] - h[1] * s[2]);
216 if det.abs() < 1e-12 {
217 return None;
218 }
219 let inv = 1.0 / det;
220 Some([
221 [
222 (h[1] * f[2] - f[1] * h[2]) * inv,
223 -(h[0] * f[2] - f[0] * h[2]) * inv,
224 (h[0] * f[1] - f[0] * h[1]) * inv,
225 ],
226 [
227 -(s[1] * f[2] - f[1] * s[2]) * inv,
228 (s[0] * f[2] - f[0] * s[2]) * inv,
229 -(s[0] * f[1] - f[0] * s[1]) * inv,
230 ],
231 [
232 (s[1] * h[2] - h[1] * s[2]) * inv,
233 -(s[0] * h[2] - h[0] * s[2]) * inv,
234 (s[0] * h[1] - h[0] * s[1]) * inv,
235 ],
236 ])
237}
238
239#[inline]
240fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
241 [
242 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
243 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
244 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
245 ]
246}
247
248#[allow(clippy::cast_possible_truncation)]
252fn cast_local(
258 dense: &SpriteDense,
259 origin: [f32; 3],
260 dir: [f32; 3],
261) -> Option<(u32, f32, [f32; 3], [i32; 3])> {
262 #[allow(clippy::cast_precision_loss)]
263 let hi = [
264 dense.dims[0] as f32,
265 dense.dims[1] as f32,
266 dense.dims[2] as f32,
267 ];
268 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
269 let start = t0 + 1e-4;
270 let p = [
271 origin[0] + dir[0] * start,
272 origin[1] + dir[1] * start,
273 origin[2] + dir[2] * start,
274 ];
275 let mut cell = [
276 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
277 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
278 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
279 ];
280 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
281 let mut t_curr = t0;
282 let mut normal = [0.0f32; 3];
285 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
286 for _ in 0..max_steps {
287 if cell[0] < 0
288 || cell[0] >= dense.dims[0]
289 || cell[1] < 0
290 || cell[1] >= dense.dims[1]
291 || cell[2] < 0
292 || cell[2] >= dense.dims[2]
293 || t_curr > t1
294 {
295 return None;
296 }
297 if let Some(color) = dense.at(cell) {
298 return Some((color, t_curr, normal, cell));
299 }
300 let axis = min_axis(t_max);
301 t_curr = t_max[axis];
302 cell[axis] += step[axis];
303 t_max[axis] += t_delta[axis];
304 normal = [0.0; 3];
305 normal[axis] = -(step[axis] as f32);
306 }
307 None
308}
309
310struct SpriteOccEntry {
316 dense: Arc<SpriteDense>,
317 pos: [f32; 3],
318 pivot: [f32; 3],
319 minv: [[f32; 3]; 3],
320}
321
322#[derive(Default)]
332pub struct SpriteOccluder {
333 entries: Vec<SpriteOccEntry>,
334}
335
336impl SpriteOccluder {
337 #[must_use]
340 pub fn new() -> Self {
341 Self::default()
342 }
343
344 #[must_use]
346 pub fn is_empty(&self) -> bool {
347 self.entries.is_empty()
348 }
349
350 pub fn push(
356 &mut self,
357 dense: Arc<SpriteDense>,
358 pos: [f32; 3],
359 s: [f32; 3],
360 h: [f32; 3],
361 f: [f32; 3],
362 ) {
363 let Some(minv) = invert_basis(s, h, f) else {
364 return;
365 };
366 let pivot = dense.pivot;
367 self.entries.push(SpriteOccEntry {
368 dense,
369 pos,
370 pivot,
371 minv,
372 });
373 }
374}
375
376impl WorldOccluder for SpriteOccluder {
377 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
378 self.entries
379 .iter()
380 .any(|e| sprite_entry_occluded(e, origin, dir, max_t))
381 }
382}
383
384#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
387fn sprite_entry_occluded(e: &SpriteOccEntry, ow: [f32; 3], dw: [f32; 3], max_t: f32) -> bool {
388 let rel = [ow[0] - e.pos[0], ow[1] - e.pos[1], ow[2] - e.pos[2]];
390 let ol = mat_apply(&e.minv, rel);
391 let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
392 let dir = mat_apply(&e.minv, dw);
393
394 let hi = [
395 e.dense.dims[0] as f32,
396 e.dense.dims[1] as f32,
397 e.dense.dims[2] as f32,
398 ];
399 let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
400 return false;
401 };
402 let t_enter = t0.max(0.0);
403 let t_exit = t1.min(max_t);
404 if t_enter > t_exit {
405 return false;
406 }
407 let start = t_enter + 1e-4;
408 let p = [
409 origin[0] + dir[0] * start,
410 origin[1] + dir[1] * start,
411 origin[2] + dir[2] * start,
412 ];
413 let mut cell = [
414 (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
415 (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
416 (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
417 ];
418 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
419 let mut t_curr = t_enter;
420 let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
421 for _ in 0..max_steps {
422 if cell[0] < 0
423 || cell[0] >= e.dense.dims[0]
424 || cell[1] < 0
425 || cell[1] >= e.dense.dims[1]
426 || cell[2] < 0
427 || cell[2] >= e.dense.dims[2]
428 || t_curr > t_exit
429 {
430 return false;
431 }
432 if e.dense.occ[e.dense.idx_of(cell)] {
433 return true;
434 }
435 let a = min_axis(t_max);
436 t_curr = t_max[a];
437 cell[a] += step[a];
438 t_max[a] += t_delta[a];
439 }
440 false
441}
442
443#[derive(Clone, Copy)]
450pub struct SpriteShade<'a> {
451 pub materials: &'a MaterialTable,
453 pub material: u8,
456 pub alpha_mul: u8,
459 pub tint: u32,
462 pub lights: CpuLights<'a>,
466 pub shadow: Option<&'a dyn WorldOccluder>,
471}
472
473struct LayerAccum {
475 rgb: [f32; 3],
478 trans: f32,
480 opaque: Option<(u32, f32)>,
485}
486
487#[inline]
491fn tint_packed(color: u32, tint: u32) -> u32 {
492 if tint & 0x00FF_FFFF == 0x00FF_FFFF {
493 return color;
494 }
495 let mul = |shift: u32| {
496 let c = (color >> shift) & 0xff;
497 let t = (tint >> shift) & 0xff;
498 ((c * t) / 255) & 0xff
499 };
500 (color & 0xff00_0000) | (mul(16) << 16) | (mul(8) << 8) | mul(0)
501}
502
503#[inline]
506fn rgb_to_f32(c: u32) -> [f32; 3] {
507 [
508 ((c >> 16) & 0xff) as f32 / 255.0,
509 ((c >> 8) & 0xff) as f32 / 255.0,
510 (c & 0xff) as f32 / 255.0,
511 ]
512}
513
514#[inline]
517#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
518fn f32_to_rgb(c: [f32; 3]) -> u32 {
519 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
520 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
521}
522
523const SPRITE_WORLD_UP: [f32; 3] = [0.0, 0.0, -1.0];
526
527#[derive(Clone, Copy, PartialEq, Eq)]
532pub enum SpriteLightMode {
533 FaceNormal,
535 WorldUp,
537 AmbientOnly,
539 FullBright,
542}
543
544impl SpriteLightMode {
545 #[must_use]
551 pub fn from_flags(flags: u32) -> Self {
552 let world_up = flags & SPRITE_FLAG_LIGHT_WORLD_UP != 0;
553 let ambient_only = flags & SPRITE_FLAG_LIGHT_AMBIENT_ONLY != 0;
554 match (ambient_only, world_up) {
555 (true, true) => Self::FullBright, (true, false) => Self::AmbientOnly,
557 (false, true) => Self::WorldUp,
558 (false, false) => Self::FaceNormal,
559 }
560 }
561}
562
563fn shade_dynamic_mode(
568 mode: SpriteLightMode,
569 albedo: [f32; 3],
570 n_world: [f32; 3],
571 center: [f32; 3],
572 lights: &CpuLights<'_>,
573 tester: Option<&mut dyn ShadowTester>,
574) -> u32 {
575 match mode {
576 SpriteLightMode::FaceNormal => shade_dynamic(albedo, 1.0, n_world, center, lights, tester),
577 SpriteLightMode::WorldUp => {
578 shade_dynamic(albedo, 1.0, SPRITE_WORLD_UP, center, lights, tester)
579 }
580 SpriteLightMode::AmbientOnly => {
581 let mut amb = *lights;
582 amb.sun = false;
583 amb.points = &[];
584 amb.bands = 0; shade_dynamic(albedo, 1.0, n_world, center, &amb, None)
586 }
587 SpriteLightMode::FullBright => f32_to_rgb(albedo),
589 }
590}
591
592#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
599fn cast_local_layers(
600 dense: &SpriteDense,
601 origin: [f32; 3],
602 dir: [f32; 3],
603 fwd_dot: f32,
604 max_t: f32,
605 shade_ctx: SpriteShade,
606 s: [f32; 3],
610 h: [f32; 3],
611 f: [f32; 3],
612 pos: [f32; 3],
613 light_mode: SpriteLightMode,
614) -> Option<LayerAccum> {
615 #[allow(clippy::cast_precision_loss)]
616 let hi = [
617 dense.dims[0] as f32,
618 dense.dims[1] as f32,
619 dense.dims[2] as f32,
620 ];
621 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
622 let start = t0 + 1e-4;
623 let p = [
624 origin[0] + dir[0] * start,
625 origin[1] + dir[1] * start,
626 origin[2] + dir[2] * start,
627 ];
628 let mut cell = [
629 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
630 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
631 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
632 ];
633 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
634 let mut t_curr = t0;
635 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
636
637 let mut acc = LayerAccum {
638 rgb: [0.0; 3],
639 trans: 1.0,
640 opaque: None,
641 };
642 let mut touched = false;
643 let mut prev_solid = false;
654 let mut prev_mat = 0u8;
655 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
658 let mut normal = [0.0f32; 3];
662
663 let lights = shade_ctx.lights;
669 let tint = shade_ctx.tint;
670 let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
671 ctx: WorldShadowCtx::identity(occ),
672 });
673 let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3]| -> u32 {
674 if !lights.enabled {
675 return tint_packed(shade(dense.col[idx], 0), tint);
676 }
677 let to_world = |v: [f32; 3]| {
678 [
679 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
680 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
681 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
682 ]
683 };
684 let n_world = to_world(n_local);
685 let rel = [
686 cell[0] as f32 + 0.5 - dense.pivot[0],
687 cell[1] as f32 + 0.5 - dense.pivot[1],
688 cell[2] as f32 + 0.5 - dense.pivot[2],
689 ];
690 let wc = to_world(rel);
691 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
692 let albedo = [
693 ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
694 ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
695 (dense.col[idx] & 0xff) as f32 / 255.0,
696 ];
697 let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
698 tint_packed(
699 shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
700 tint,
701 )
702 };
703
704 for _ in 0..max_steps {
705 if cell[0] < 0
706 || cell[0] >= dense.dims[0]
707 || cell[1] < 0
708 || cell[1] >= dense.dims[1]
709 || cell[2] < 0
710 || cell[2] >= dense.dims[2]
711 || t_curr > t1
712 {
713 break;
714 }
715 let depth = t_curr * fwd_dot;
718 if depth >= max_t {
719 break;
720 }
721 let exit_axis = min_axis(t_max);
724 let t_exit = t_max[exit_axis];
725 let idx = dense.idx_of(cell);
726 let solid_here = dense.occ[idx];
727 if solid_here && depth >= NEAR_Z {
728 let mat_id = if dense.mat.is_empty() {
729 shade_ctx.material
730 } else {
731 dense.mat[idx]
732 };
733 let m = shade_ctx.materials.get(mat_id);
734 if m.is_opaque() {
735 acc.opaque = Some((shade_layer(idx, cell, normal), t_curr));
736 touched = true;
737 break;
738 }
739 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
740 if m.mode == BlendMode::Volumetric {
741 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
745 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
746 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
747 acc.rgb[0] += acc.trans * eff_a * lit[0];
748 acc.rgb[1] += acc.trans * eff_a * lit[1];
749 acc.rgb[2] += acc.trans * eff_a * lit[2];
750 acc.trans *= 1.0 - eff_a;
751 touched = true;
752 prev_mat = mat_id;
753 if acc.trans < 1.0 / 256.0 {
754 break;
755 }
756 } else if !prev_solid || mat_id != prev_mat {
757 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
760 acc.rgb[0] += acc.trans * a * lit[0];
761 acc.rgb[1] += acc.trans * a * lit[1];
762 acc.rgb[2] += acc.trans * a * lit[2];
763 if m.mode == BlendMode::AlphaBlend {
764 acc.trans *= 1.0 - a; }
766 touched = true;
767 prev_mat = mat_id;
768 if acc.trans < 1.0 / 256.0 {
769 break;
770 }
771 }
772 }
773 prev_solid = solid_here;
774 t_curr = t_exit;
775 cell[exit_axis] += step[exit_axis];
776 t_max[exit_axis] += t_delta[exit_axis];
777 normal = [0.0; 3];
778 normal[exit_axis] = -(step[exit_axis] as f32);
779 }
780
781 touched.then_some(acc)
782}
783
784#[allow(
794 clippy::too_many_arguments,
795 clippy::cast_possible_truncation,
796 clippy::cast_sign_loss
797)]
798#[must_use]
799pub fn draw_sprite_dda(
800 fb: &mut [u32],
801 zb: &mut [f32],
802 pitch_pixels: usize,
803 width: u32,
804 height: u32,
805 cam: &CameraState,
806 settings: &OpticastSettings,
807 sprite: &Sprite,
808) -> u32 {
809 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
810 return 0;
811 }
812 draw_sprite_dda_shaded(
813 fb,
814 zb,
815 pitch_pixels,
816 width,
817 height,
818 cam,
819 settings,
820 sprite,
821 None,
822 )
823}
824
825#[allow(clippy::too_many_arguments)]
830#[must_use]
831pub fn draw_sprite_dda_shaded(
832 fb: &mut [u32],
833 zb: &mut [f32],
834 pitch_pixels: usize,
835 width: u32,
836 height: u32,
837 cam: &CameraState,
838 settings: &OpticastSettings,
839 sprite: &Sprite,
840 shade_ctx: Option<SpriteShade>,
841) -> u32 {
842 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
843 return 0;
844 }
845 let dense = if sprite.material_map.is_empty() {
850 SpriteDense::from_kv6(&sprite.kv6)
851 } else {
852 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
853 };
854 draw_sprite_dense_shaded(
855 fb,
856 zb,
857 pitch_pixels,
858 width,
859 height,
860 cam,
861 settings,
862 &dense,
863 sprite.p,
864 sprite.s,
865 sprite.h,
866 sprite.f,
867 sprite.flags,
868 shade_ctx,
869 )
870}
871
872#[allow(clippy::too_many_arguments)]
881#[must_use]
882pub fn draw_sprite_dense(
883 fb: &mut [u32],
884 zb: &mut [f32],
885 pitch_pixels: usize,
886 width: u32,
887 height: u32,
888 cam: &CameraState,
889 settings: &OpticastSettings,
890 dense: &SpriteDense,
891 pos: [f32; 3],
892 s: [f32; 3],
893 h: [f32; 3],
894 f: [f32; 3],
895 flags: u32,
896) -> u32 {
897 draw_sprite_dense_shaded(
898 fb,
899 zb,
900 pitch_pixels,
901 width,
902 height,
903 cam,
904 settings,
905 dense,
906 pos,
907 s,
908 h,
909 f,
910 flags,
911 None,
912 )
913}
914
915#[allow(
927 clippy::too_many_arguments,
928 clippy::cast_possible_truncation,
929 clippy::cast_sign_loss
930)]
931#[must_use]
932pub fn draw_sprite_dense_shaded(
933 fb: &mut [u32],
934 zb: &mut [f32],
935 pitch_pixels: usize,
936 width: u32,
937 height: u32,
938 cam: &CameraState,
939 settings: &OpticastSettings,
940 dense: &SpriteDense,
941 pos: [f32; 3],
942 s: [f32; 3],
943 h: [f32; 3],
944 f: [f32; 3],
945 flags: u32,
946 shade_ctx: Option<SpriteShade>,
947) -> u32 {
948 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
949 return 0;
950 }
951 let Some(minv) = invert_basis(s, h, f) else {
952 return 0;
953 };
954 let pivot = dense.pivot;
955 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
956 let light_mode = SpriteLightMode::from_flags(flags);
958
959 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
961 return 0;
962 };
963
964 let layers =
970 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
971
972 debug_assert_eq!(fb.len(), zb.len());
973 let target = RasterTarget::new(fb, zb);
974 let draw_row = |py: u32| -> u32 {
978 let mut written = 0u32;
979 let row = py as usize * pitch_pixels;
980 for px in rect.0..rect.2 {
981 let (origin, dir) = pixel_ray(cam, settings, px, py);
982 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
984 let ol = mat_apply(&minv, rel);
985 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
986 let dir_local = mat_apply(&minv, dir);
987 let fwd_dot =
988 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
989 let idx = row + px as usize;
990
991 if let Some(shade_ctx) = layers {
992 if fwd_dot <= 1e-6 {
994 continue;
995 }
996 let max_t = if no_z {
1001 f32::INFINITY
1002 } else {
1003 unsafe { target.read_depth(idx) }
1004 };
1005 let Some(acc) = cast_local_layers(
1006 dense,
1007 origin_local,
1008 dir_local,
1009 fwd_dot,
1010 max_t,
1011 shade_ctx,
1012 s,
1013 h,
1014 f,
1015 pos,
1016 light_mode,
1017 ) else {
1018 continue;
1019 };
1020 let wrote = unsafe {
1022 match acc.opaque {
1023 Some((bg_color, t)) => {
1024 let bg = rgb_to_f32(bg_color);
1027 let out = f32_to_rgb([
1028 acc.rgb[0] + acc.trans * bg[0],
1029 acc.rgb[1] + acc.trans * bg[1],
1030 acc.rgb[2] + acc.trans * bg[2],
1031 ]);
1032 let depth = t * fwd_dot;
1033 if no_z {
1034 target.write_color(idx, out);
1035 target.write_depth(idx, depth);
1036 true
1037 } else {
1038 target.z_test_write(idx, out, depth)
1039 }
1040 }
1041 None => {
1042 let bg = rgb_to_f32(target.read_color(idx));
1047 let out = f32_to_rgb([
1048 acc.rgb[0] + acc.trans * bg[0],
1049 acc.rgb[1] + acc.trans * bg[1],
1050 acc.rgb[2] + acc.trans * bg[2],
1051 ]);
1052 target.write_color(idx, out);
1053 true
1054 }
1055 }
1056 };
1057 written += u32::from(wrote);
1058 } else {
1059 let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1061 else {
1062 continue;
1063 };
1064 let depth = t * fwd_dot;
1065 if depth < NEAR_Z {
1066 continue;
1067 }
1068 let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1073 let lit = if dl.enabled {
1074 let to_world = |v: [f32; 3]| {
1075 [
1076 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1077 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1078 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1079 ]
1080 };
1081 let n_world = to_world(n_local);
1082 let rel = [
1083 cell[0] as f32 + 0.5 - pivot[0],
1084 cell[1] as f32 + 0.5 - pivot[1],
1085 cell[2] as f32 + 0.5 - pivot[2],
1086 ];
1087 let wc = to_world(rel);
1088 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1089 let albedo = [
1090 ((color >> 16) & 0xff) as f32 / 255.0,
1091 ((color >> 8) & 0xff) as f32 / 255.0,
1092 (color & 0xff) as f32 / 255.0,
1093 ];
1094 let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1098 ctx: WorldShadowCtx::identity(occ),
1099 });
1100 let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1101 shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1102 } else {
1103 shade(color, 0)
1104 };
1105 let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1107 let wrote = unsafe {
1110 if no_z {
1111 target.write_color(idx, lit);
1112 target.write_depth(idx, depth);
1113 true
1114 } else {
1115 target.z_test_write(idx, lit, depth)
1116 }
1117 };
1118 written += u32::from(wrote);
1119 }
1120 }
1121 written
1122 };
1123 let rows = rect.3.saturating_sub(rect.1) as usize;
1128 let cols = rect.2.saturating_sub(rect.0) as usize;
1129 const SPRITE_PAR_MIN_PIXELS: usize = 64 * 64;
1130 if rows >= 2 && rows * cols >= SPRITE_PAR_MIN_PIXELS {
1131 use rayon::prelude::*;
1132 (rect.1..rect.3).into_par_iter().map(draw_row).sum()
1133 } else {
1134 (rect.1..rect.3).map(draw_row).sum()
1135 }
1136}
1137
1138#[allow(
1142 clippy::cast_possible_truncation,
1143 clippy::cast_sign_loss,
1144 clippy::cast_precision_loss
1145)]
1146fn project_screen_rect(
1147 dense: &SpriteDense,
1148 pos: [f32; 3],
1149 s: [f32; 3],
1150 h: [f32; 3],
1151 f: [f32; 3],
1152 cam: &CameraState,
1153 settings: &OpticastSettings,
1154 width: u32,
1155 height: u32,
1156) -> Option<(u32, u32, u32, u32)> {
1157 let (xs, ys, zs) = (
1158 dense.dims[0] as f32,
1159 dense.dims[1] as f32,
1160 dense.dims[2] as f32,
1161 );
1162 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1163 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1164 let mut all_front = true;
1165 for &cx in &[0.0, xs] {
1166 for &cy in &[0.0, ys] {
1167 for &cz in &[0.0, zs] {
1168 let lx = cx - xp;
1170 let ly = cy - yp;
1171 let lz = cz - zp;
1172 let world = [
1173 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1174 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1175 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1176 ];
1177 let rel = [
1178 world[0] - cam.pos[0],
1179 world[1] - cam.pos[1],
1180 world[2] - cam.pos[2],
1181 ];
1182 let cz_cam =
1183 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1184 if cz_cam < NEAR_Z {
1185 all_front = false;
1186 continue;
1187 }
1188 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1189 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1190 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1191 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1192 x0 = x0.min(sx);
1193 y0 = y0.min(sy);
1194 x1 = x1.max(sx);
1195 y1 = y1.max(sy);
1196 }
1197 }
1198 }
1199 let (w, h) = (width as f32, height as f32);
1200 let (rx0, ry0, rx1, ry1) = if all_front {
1201 (
1202 (x0 - 1.0).max(0.0),
1203 (y0 - 1.0).max(0.0),
1204 (x1 + 1.0).min(w),
1205 (y1 + 1.0).min(h),
1206 )
1207 } else {
1208 (0.0, 0.0, w, h)
1210 };
1211 if rx0 >= rx1 || ry0 >= ry1 {
1212 return None;
1213 }
1214 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1215}
1216
1217pub struct ClipFlipbook {
1224 frames: Vec<Arc<SpriteDense>>,
1227}
1228
1229impl ClipFlipbook {
1230 #[must_use]
1233 pub fn empty() -> Self {
1234 Self { frames: Vec::new() }
1235 }
1236
1237 #[must_use]
1239 pub fn from_decoded(clip: &DecodedClip) -> Self {
1240 Self::from_decoded_with_materials(clip, &[])
1241 }
1242
1243 #[must_use]
1249 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(Rgb, u8)]) -> Self {
1250 let frames = clip
1251 .frames
1252 .iter()
1253 .map(|frame| {
1254 Arc::new(SpriteDense::from_voxel_frame_with_materials(
1255 frame,
1256 clip.dims,
1257 clip.pivot,
1258 material_map,
1259 ))
1260 })
1261 .collect();
1262 Self { frames }
1263 }
1264
1265 #[must_use]
1268 pub fn frame_count(&self) -> usize {
1269 self.frames.len()
1270 }
1271
1272 #[must_use]
1274 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1275 self.frames.get(frame).map(Arc::as_ref)
1276 }
1277
1278 #[must_use]
1281 pub fn frame_arc(&self, frame: usize) -> Option<Arc<SpriteDense>> {
1282 self.frames.get(frame).cloned()
1283 }
1284
1285 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1289 match self.frames.get_mut(frame) {
1290 Some(slot) => {
1291 *slot = Arc::new(dense);
1292 true
1293 }
1294 None => false,
1295 }
1296 }
1297
1298 #[allow(clippy::too_many_arguments)]
1302 #[must_use]
1303 pub fn draw_frame(
1304 &self,
1305 fb: &mut [u32],
1306 zb: &mut [f32],
1307 pitch_pixels: usize,
1308 width: u32,
1309 height: u32,
1310 cam: &CameraState,
1311 settings: &OpticastSettings,
1312 frame: usize,
1313 pos: [f32; 3],
1314 s: [f32; 3],
1315 h: [f32; 3],
1316 f: [f32; 3],
1317 flags: u32,
1318 ) -> u32 {
1319 self.draw_frame_shaded(
1320 fb,
1321 zb,
1322 pitch_pixels,
1323 width,
1324 height,
1325 cam,
1326 settings,
1327 frame,
1328 pos,
1329 s,
1330 h,
1331 f,
1332 flags,
1333 None,
1334 )
1335 }
1336
1337 #[allow(clippy::too_many_arguments)]
1342 #[must_use]
1343 pub fn draw_frame_shaded(
1344 &self,
1345 fb: &mut [u32],
1346 zb: &mut [f32],
1347 pitch_pixels: usize,
1348 width: u32,
1349 height: u32,
1350 cam: &CameraState,
1351 settings: &OpticastSettings,
1352 frame: usize,
1353 pos: [f32; 3],
1354 s: [f32; 3],
1355 h: [f32; 3],
1356 f: [f32; 3],
1357 flags: u32,
1358 shade_ctx: Option<SpriteShade>,
1359 ) -> u32 {
1360 let Some(dense) = self.frames.get(frame) else {
1361 return 0;
1362 };
1363 draw_sprite_dense_shaded(
1364 fb,
1365 zb,
1366 pitch_pixels,
1367 width,
1368 height,
1369 cam,
1370 settings,
1371 dense,
1372 pos,
1373 s,
1374 h,
1375 f,
1376 flags,
1377 shade_ctx,
1378 )
1379 }
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use super::*;
1385 use crate::camera_math;
1386 use crate::Camera;
1387 use roxlap_formats::kv6::Kv6;
1388 use roxlap_formats::material::{Material, MaterialTable};
1389 use roxlap_formats::VoxColor;
1390
1391 #[test]
1394 fn sprite_light_mode_world_up_and_ambient_only() {
1395 let lights = CpuLights {
1396 enabled: true,
1397 sun: true,
1398 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0, 1.0, 1.0],
1400 sun_intensity: 1.0,
1401 sun_casts_shadow: false,
1402 points: &[],
1403 ambient: [0.2, 0.2, 0.2],
1404 bands: 0,
1405 shadow_tint: [0.0; 3],
1406 shadow_strength: 0.0,
1407 shadow_bias: 0.0,
1408 shadow_max_dist: 0.0,
1409 };
1410 let a = [1.0, 1.0, 1.0];
1411 let c = [0.0, 0.0, 0.0];
1412 let g = |packed: u32| (packed >> 8) & 0xff; let up_n = [0.0, 0.0, -1.0];
1414 let side_n = [1.0, 0.0, 0.0];
1415 let face_up = g(shade_dynamic_mode(
1416 SpriteLightMode::FaceNormal,
1417 a,
1418 up_n,
1419 c,
1420 &lights,
1421 None,
1422 ));
1423 let face_side = g(shade_dynamic_mode(
1424 SpriteLightMode::FaceNormal,
1425 a,
1426 side_n,
1427 c,
1428 &lights,
1429 None,
1430 ));
1431 let amb = g(shade_dynamic_mode(
1432 SpriteLightMode::AmbientOnly,
1433 a,
1434 up_n,
1435 c,
1436 &lights,
1437 None,
1438 ));
1439 let world_up = g(shade_dynamic_mode(
1440 SpriteLightMode::WorldUp,
1441 a,
1442 side_n,
1443 c,
1444 &lights,
1445 None,
1446 ));
1447 assert!(
1448 face_up > face_side,
1449 "a sun-facing face is brighter than a side face"
1450 );
1451 assert!(amb < face_up, "ambient-only drops the sun term");
1452 assert_eq!(
1453 world_up, face_up,
1454 "world-up shades a side-facing billboard as if it faced up"
1455 );
1456 let full = g(shade_dynamic_mode(
1457 SpriteLightMode::FullBright,
1458 a,
1459 side_n,
1460 c,
1461 &lights,
1462 None,
1463 ));
1464 assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1467 assert!(full > amb, "full-bright glow is brighter than ambient-only");
1468 }
1469
1470 #[test]
1475 fn cast_local_reports_face_normal() {
1476 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| {
1478 (z >= 4).then_some(VoxColor(0x80_C0_40_20))
1479 });
1480 let dense = SpriteDense::from_kv6(&kv6);
1481 let (_c, _t, n, cell) =
1483 cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1484 assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1485 assert!(
1486 n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1487 "z-crossing face normal points back toward the ray (-z): {n:?}",
1488 );
1489 }
1490 use roxlap_formats::sprite::Sprite;
1491 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1492
1493 fn settings(w: u32, h: u32) -> OpticastSettings {
1494 OpticastSettings::for_oracle_framebuffer(w, h)
1495 }
1496
1497 fn cam_looking_y() -> Camera {
1499 Camera {
1500 pos: [0.0, 0.0, 0.0],
1501 right: [1.0, 0.0, 0.0],
1502 down: [0.0, 0.0, 1.0],
1503 forward: [0.0, 1.0, 0.0],
1504 }
1505 }
1506
1507 #[test]
1514 fn scaled_basis_scales_drawn_extent() {
1515 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, _| Some(VoxColor(0x80_C0_40_20)));
1516 let (w, h) = (64u32, 64u32);
1517 let n = (w * h) as usize;
1518 let cam = cam_looking_y();
1519 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1520 let cfg = settings(w, h);
1521
1522 let px_at = |k: f32| -> u32 {
1523 let mut sprite = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1524 for a in 0..3 {
1525 sprite.s[a] *= k;
1526 sprite.h[a] *= k;
1527 sprite.f[a] *= k;
1528 }
1529 let mut fb = vec![0u32; n];
1530 let mut zb = vec![f32::INFINITY; n];
1531 draw_sprite_dda(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, &sprite)
1532 };
1533
1534 let (unit, double, half) = (px_at(1.0), px_at(2.0), px_at(0.5));
1535 assert!(unit > 0, "unit-scale cube must draw ({unit} px)");
1536 let r2 = f64::from(double) / f64::from(unit);
1537 let rh = f64::from(half) / f64::from(unit);
1538 assert!(
1539 (3.0..8.0).contains(&r2),
1540 "2× scale should roughly quadruple coverage: {unit} → {double} px (×{r2:.2})"
1541 );
1542 assert!(
1543 (0.08..0.5).contains(&rh),
1544 "0.5× scale should roughly quarter coverage: {unit} → {half} px (×{rh:.2})"
1545 );
1546 }
1547
1548 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1550 let owpc = dims[2].div_ceil(32).max(1) as usize;
1551 let cols = (dims[0] * dims[1]) as usize;
1552 let mut occupancy = vec![0u32; cols * owpc];
1553 let mut color_offsets = vec![0u32; cols + 1];
1554 let mut colors = Vec::new();
1555 for y in 0..dims[1] {
1556 for x in 0..dims[0] {
1557 let col = (x + y * dims[0]) as usize;
1558 color_offsets[col] = colors.len() as u32;
1559 for z in 0..dims[2] {
1560 if let Some(c) = fill(x, y, z) {
1561 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1562 colors.push(c);
1563 }
1564 }
1565 }
1566 }
1567 color_offsets[cols] = colors.len() as u32;
1568 VoxelFrame {
1569 occupancy,
1570 colors,
1571 color_offsets,
1572 }
1573 }
1574
1575 #[test]
1580 fn clip_flipbook_frames_render_differently() {
1581 let dims = [8u32, 8, 8];
1582 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(
1585 dims,
1586 [4.0, 4.0, 4.0],
1587 1.0,
1588 LoopMode::Loop,
1589 &[f0, f1],
1590 &[],
1591 33,
1592 0,
1593 );
1594 let decoded = clip.decode().expect("decode");
1595 let book = ClipFlipbook::from_decoded(&decoded);
1596 assert_eq!(book.frame_count(), 2);
1597 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1598
1599 let (w, h) = (64u32, 64u32);
1600 let n = (w * h) as usize;
1601 let cam = cam_looking_y();
1602 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1603 let cfg = settings(w, h);
1604 let pose = [0.0, 40.0, 0.0];
1605 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1606
1607 let render = |frame: usize| -> Vec<u32> {
1608 let mut fb = vec![0u32; n];
1609 let mut zb = vec![f32::INFINITY; n];
1610 let wrote = book.draw_frame(
1611 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1612 );
1613 assert!(wrote > 0, "frame {frame} should draw some pixels");
1614 fb
1615 };
1616 let fb0 = render(0);
1617 let fb1 = render(1);
1618 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1619 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1622 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1623 let mut fb = vec![0u32; n];
1625 let mut zb = vec![f32::INFINITY; n];
1626 assert_eq!(
1627 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1628 0
1629 );
1630 }
1631
1632 #[test]
1633 fn clip_flipbook_set_frame_replaces_one_frame() {
1634 let dims = [8u32, 8, 8];
1637 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 =
1640 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1641 let decoded = clip.decode().unwrap();
1642 let mut book = ClipFlipbook::from_decoded(&decoded);
1643
1644 let (w, h) = (64u32, 64u32);
1645 let n = (w * h) as usize;
1646 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1647 let cfg = settings(w, h);
1648 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1649 let mut fb = vec![0u32; n];
1650 let mut zb = vec![f32::INFINITY; n];
1651 let _ = b.draw_frame(
1652 &mut fb,
1653 &mut zb,
1654 w as usize,
1655 w,
1656 h,
1657 &cs,
1658 &cfg,
1659 0,
1660 [0.0, 40.0, 0.0],
1661 [1.0, 0.0, 0.0],
1662 [0.0, 1.0, 0.0],
1663 [0.0, 0.0, 1.0],
1664 0,
1665 );
1666 fb
1667 };
1668
1669 let before = render0(&book);
1670 assert!(
1671 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1672 "frame 0 is red"
1673 );
1674
1675 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1677 assert!(book.set_frame(0, replacement));
1678 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1679 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1680
1681 let after = render0(&book);
1682 assert!(
1683 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1684 "frame 0 now green"
1685 );
1686 assert_ne!(before, after);
1687 }
1688
1689 #[test]
1692 fn cube_sprite_renders() {
1693 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_C0_40_20));
1694 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1695 let (w, h) = (64u32, 64u32);
1696 let n = (w * h) as usize;
1697 let mut fb = vec![0u32; n];
1698 let mut zb = vec![f32::INFINITY; n];
1699 let cam = cam_looking_y();
1700 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1701 let wrote = draw_sprite_dda(
1702 &mut fb,
1703 &mut zb,
1704 w as usize,
1705 w,
1706 h,
1707 &cs,
1708 &settings(w, h),
1709 &sprite,
1710 );
1711
1712 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1713 let centre = (h / 2 * w + w / 2) as usize;
1714 assert_eq!(
1715 fb[centre] & 0x00ff_ffff,
1716 0x00_C0_40_20,
1717 "got {:08x}",
1718 fb[centre]
1719 );
1720 assert!(
1722 (zb[centre] - 36.0).abs() < 3.0,
1723 "centre depth {} not ≈ 36",
1724 zb[centre]
1725 );
1726 }
1727
1728 #[test]
1733 fn zero_high_byte_sprite_not_black() {
1734 let kv6 = Kv6::solid_cube(8, VoxColor(0x00_C0_40_20));
1735 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1736 let (w, h) = (64u32, 64u32);
1737 let n = (w * h) as usize;
1738 let mut fb = vec![0u32; n];
1739 let mut zb = vec![f32::INFINITY; n];
1740 let cam = cam_looking_y();
1741 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1742 let wrote = draw_sprite_dda(
1743 &mut fb,
1744 &mut zb,
1745 w as usize,
1746 w,
1747 h,
1748 &cs,
1749 &settings(w, h),
1750 &sprite,
1751 );
1752 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1753 let centre = (h / 2 * w + w / 2) as usize;
1754 assert_eq!(
1755 fb[centre] & 0x00ff_ffff,
1756 0x00_C0_40_20,
1757 "zero-high-byte sprite rendered as {:08x} (black bug)",
1758 fb[centre]
1759 );
1760 }
1761
1762 #[test]
1765 fn sprite_respects_zbuffer() {
1766 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1767 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1768 let (w, h) = (32u32, 32u32);
1769 let n = (w * h) as usize;
1770 let cam = cam_looking_y();
1771 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1772 let centre = (h / 2 * w + w / 2) as usize;
1773
1774 let mut fb = vec![0u32; n];
1776 let mut zb = vec![f32::INFINITY; n];
1777 fb[centre] = 0x80_11_22_33;
1778 zb[centre] = 10.0;
1779 let _ = draw_sprite_dda(
1780 &mut fb,
1781 &mut zb,
1782 w as usize,
1783 w,
1784 h,
1785 &cs,
1786 &settings(w, h),
1787 &sprite,
1788 );
1789 assert_eq!(
1790 fb[centre], 0x80_11_22_33,
1791 "near terrain must occlude sprite"
1792 );
1793
1794 let mut fb2 = vec![0u32; n];
1796 let mut zb2 = vec![f32::INFINITY; n];
1797 fb2[centre] = 0x80_11_22_33;
1798 zb2[centre] = 100.0;
1799 let _ = draw_sprite_dda(
1800 &mut fb2,
1801 &mut zb2,
1802 w as usize,
1803 w,
1804 h,
1805 &cs,
1806 &settings(w, h),
1807 &sprite,
1808 );
1809 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1810 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1811 }
1812
1813 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1816 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1817 for py in 0..h {
1818 for px in 0..w {
1819 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1820 x0 = x0.min(px);
1821 y0 = y0.min(py);
1822 x1 = x1.max(px);
1823 y1 = y1.max(py);
1824 }
1825 }
1826 }
1827 (x0, y0, x1, y1)
1828 }
1829
1830 #[test]
1835 fn posed_basis_reorients_silhouette() {
1836 let kv6 = Kv6::solid_box(16, 4, 4, VoxColor(0x80_C0_40_20));
1839 let (w, h) = (64u32, 64u32);
1840 let n = (w * h) as usize;
1841 let cam = cam_looking_y();
1842 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1843
1844 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1846 let mut fb = vec![0u32; n];
1847 let mut zb = vec![f32::INFINITY; n];
1848 let _ = draw_sprite_dda(
1849 &mut fb,
1850 &mut zb,
1851 w as usize,
1852 w,
1853 h,
1854 &cs,
1855 &settings(w, h),
1856 &aa,
1857 );
1858 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1859 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1860 assert!(
1861 aa_wide > 4,
1862 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1863 );
1864
1865 let mut posed = aa.clone();
1868 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];
1872 let mut zb2 = vec![f32::INFINITY; n];
1873 let _ = draw_sprite_dda(
1874 &mut fb2,
1875 &mut zb2,
1876 w as usize,
1877 w,
1878 h,
1879 &cs,
1880 &settings(w, h),
1881 &posed,
1882 );
1883 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1884 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1885 assert!(
1886 posed_tall > 4,
1887 "posed box should be taller than wide (got h-w={posed_tall})"
1888 );
1889 }
1890
1891 #[test]
1894 fn degenerate_basis_draws_nothing() {
1895 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1896 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1897 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1899 let n = (w * h) as usize;
1900 let mut fb = vec![0u32; n];
1901 let mut zb = vec![f32::INFINITY; n];
1902 let cam = cam_looking_y();
1903 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1904 let wrote = draw_sprite_dda(
1905 &mut fb,
1906 &mut zb,
1907 w as usize,
1908 w,
1909 h,
1910 &cs,
1911 &settings(w, h),
1912 &sprite,
1913 );
1914 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1915 }
1916
1917 #[test]
1919 fn invisible_sprite_skipped() {
1920 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1921 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1922 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1923 let (w, h) = (32u32, 32u32);
1924 let n = (w * h) as usize;
1925 let mut fb = vec![0u32; n];
1926 let mut zb = vec![f32::INFINITY; n];
1927 let cam = cam_looking_y();
1928 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1929 let wrote = draw_sprite_dda(
1930 &mut fb,
1931 &mut zb,
1932 w as usize,
1933 w,
1934 h,
1935 &cs,
1936 &settings(w, h),
1937 &sprite,
1938 );
1939 assert_eq!(wrote, 0);
1940 }
1941
1942 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1948 let mut table = MaterialTable::new();
1949 table.set(1, mat);
1950 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
1951 let (w, h) = (64u32, 64u32);
1952 let n = (w * h) as usize;
1953 let mut fb = vec![bg; n];
1954 let mut zb = vec![zb_v; n];
1955 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1956 let sh = SpriteShade {
1957 materials: &table,
1958 lights: CpuLights::default(),
1959 material: 1,
1960 alpha_mul,
1961 tint: 0x00FF_FFFF,
1962 shadow: None,
1963 };
1964 let _ = draw_sprite_dense_shaded(
1965 &mut fb,
1966 &mut zb,
1967 w as usize,
1968 w,
1969 h,
1970 &cs,
1971 &settings(w, h),
1972 &dense,
1973 [0.0, 40.0, 0.0],
1974 [1.0, 0.0, 0.0],
1975 [0.0, 1.0, 0.0],
1976 [0.0, 0.0, 1.0],
1977 0,
1978 Some(sh),
1979 );
1980 (fb[(h / 2 * w + w / 2) as usize], fb)
1981 }
1982
1983 #[test]
1986 fn additive_sprite_brightens_background() {
1987 let bg = 0x80_20_20_20;
1988 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1989 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1990 assert!(
1991 cr > 0x20 && cg > 0x20 && cb >= 0x20,
1992 "centre {centre:08x} should be brighter than bg"
1993 );
1994 assert!(
1996 cr >= cg && cr >= cb,
1997 "additive of a red-dominant cube stays red-dominant"
1998 );
1999 }
2000
2001 #[test]
2004 fn alpha_blend_sprite_between_bg_and_color() {
2005 let bg = 0x80_20_20_20;
2006 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
2007 let cr = (centre >> 16) & 0xff;
2008 assert!(
2009 cr > 0x20,
2010 "blended red must rise above bg 0x20 (got {cr:02x})"
2011 );
2012 assert!(
2013 cr < 0xC0,
2014 "blended red must stay below opaque 0xC0 (got {cr:02x})"
2015 );
2016 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
2018 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
2019 }
2020
2021 #[test]
2024 fn alpha_mul_scales_opacity() {
2025 let bg = 0x80_20_20_20;
2026 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
2027 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
2028 let r_full = (full >> 16) & 0xff;
2029 let r_faded = (faded >> 16) & 0xff;
2030 assert!(
2032 r_full > r_faded,
2033 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
2034 );
2035 assert!(r_faded > 0x20, "even faded lifts above bg");
2036 }
2037
2038 #[test]
2042 fn opaque_shade_ctx_matches_plain_path() {
2043 let table = MaterialTable::new();
2044 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2045 let (w, h) = (64u32, 64u32);
2046 let n = (w * h) as usize;
2047 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2048 let pose = (
2049 [0.0, 40.0, 0.0],
2050 [1.0, 0.0, 0.0],
2051 [0.0, 1.0, 0.0],
2052 [0.0, 0.0, 1.0],
2053 );
2054
2055 let mut fb_plain = vec![0u32; n];
2056 let mut zb_plain = vec![f32::INFINITY; n];
2057 let _ = draw_sprite_dense(
2058 &mut fb_plain,
2059 &mut zb_plain,
2060 w as usize,
2061 w,
2062 h,
2063 &cs,
2064 &settings(w, h),
2065 &dense,
2066 pose.0,
2067 pose.1,
2068 pose.2,
2069 pose.3,
2070 0,
2071 );
2072
2073 let mut fb_sh = vec![0u32; n];
2074 let mut zb_sh = vec![f32::INFINITY; n];
2075 let sh = SpriteShade {
2076 materials: &table,
2077 lights: CpuLights::default(),
2078 material: 0, alpha_mul: 255,
2080 tint: 0x00FF_FFFF,
2081 shadow: None,
2082 };
2083 let _ = draw_sprite_dense_shaded(
2084 &mut fb_sh,
2085 &mut zb_sh,
2086 w as usize,
2087 w,
2088 h,
2089 &cs,
2090 &settings(w, h),
2091 &dense,
2092 pose.0,
2093 pose.1,
2094 pose.2,
2095 pose.3,
2096 0,
2097 Some(sh),
2098 );
2099
2100 assert_eq!(
2101 fb_plain, fb_sh,
2102 "opaque shade-ctx must match the plain path bit-for-bit"
2103 );
2104 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2105 }
2106
2107 #[test]
2111 fn translucent_sprite_occluded_by_near_terrain() {
2112 let bg = 0x80_20_20_20;
2113 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2114 assert_eq!(
2115 centre, bg,
2116 "near terrain (z=5) must occlude the sprite at y≈36"
2117 );
2118 }
2119
2120 #[test]
2126 fn per_span_thickness_independent() {
2127 fn centre(ysiz: u32) -> u32 {
2128 let mut table = MaterialTable::new();
2129 table.set(1, Material::alpha_blend(128));
2130 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, VoxColor(0x80_C0_40_20)));
2131 let (w, h) = (64u32, 64u32);
2132 let n = (w * h) as usize;
2133 let mut fb = vec![0x80_10_10_10u32; n];
2134 let mut zb = vec![f32::INFINITY; n];
2135 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2136 let sh = SpriteShade {
2137 materials: &table,
2138 lights: CpuLights::default(),
2139 material: 1,
2140 alpha_mul: 255,
2141 tint: 0x00FF_FFFF,
2142 shadow: None,
2143 };
2144 let _ = draw_sprite_dense_shaded(
2145 &mut fb,
2146 &mut zb,
2147 w as usize,
2148 w,
2149 h,
2150 &cs,
2151 &settings(w, h),
2152 &dense,
2153 [0.0, 40.0, 0.0],
2154 [1.0, 0.0, 0.0],
2155 [0.0, 1.0, 0.0],
2156 [0.0, 0.0, 1.0],
2157 0,
2158 Some(sh),
2159 );
2160 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2161 }
2162 assert_eq!(
2166 centre(1),
2167 centre(2),
2168 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2169 );
2170 }
2171
2172 #[test]
2177 fn volumetric_thickness_deepens_opacity() {
2178 fn red_at(depth: u32) -> u32 {
2181 let mut table = MaterialTable::new();
2182 table.set(1, Material::volumetric(128));
2183 let kv6 = Kv6::from_fn_keep_interior(
2188 8,
2189 depth,
2190 8,
2191 |_, _, _| Some(VoxColor(0x80_C0_20_20)),
2192 |_| true,
2193 );
2194 let dense = SpriteDense::from_kv6(&kv6);
2195 let (w, h) = (64u32, 64u32);
2196 let n = (w * h) as usize;
2197 let mut fb = vec![0x80_10_10_10u32; n];
2198 let mut zb = vec![f32::INFINITY; n];
2199 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2200 let sh = SpriteShade {
2201 materials: &table,
2202 lights: CpuLights::default(),
2203 material: 1,
2204 alpha_mul: 255,
2205 tint: 0x00FF_FFFF,
2206 shadow: None,
2207 };
2208 let _ = draw_sprite_dense_shaded(
2209 &mut fb,
2210 &mut zb,
2211 w as usize,
2212 w,
2213 h,
2214 &cs,
2215 &settings(w, h),
2216 &dense,
2217 [0.0, 40.0, 0.0],
2218 [1.0, 0.0, 0.0],
2219 [0.0, 1.0, 0.0],
2220 [0.0, 0.0, 1.0],
2221 0,
2222 Some(sh),
2223 );
2224 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2225 }
2226 let shallow = red_at(1);
2227 let deep = red_at(12);
2228 assert!(
2231 shallow > 0x10,
2232 "even a 1-deep volume tints (got {shallow:02x})"
2233 );
2234 assert!(
2235 deep > shallow,
2236 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2237 );
2238 }
2239
2240 #[test]
2245 fn sprite_occluder_blocks_ray_through_volume() {
2246 use crate::dda::WorldOccluder;
2247 let dense = Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2250 8,
2251 VoxColor(0x80_FF_FF_FF),
2252 )));
2253 let mut occ = SpriteOccluder::new();
2254 occ.push(
2255 dense,
2256 [0.0, 0.0, 0.0],
2257 [1.0, 0.0, 0.0],
2258 [0.0, 1.0, 0.0],
2259 [0.0, 0.0, 1.0],
2260 );
2261 assert!(!occ.is_empty());
2262 assert!(
2264 occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2265 "a ray through the cube must be occluded"
2266 );
2267 assert!(
2269 !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2270 "a ray missing the cube must not be occluded"
2271 );
2272 assert!(
2274 !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2275 "max_t shorter than the distance to the cube ⇒ unoccluded"
2276 );
2277 }
2278
2279 #[test]
2284 fn sprite_receives_hard_shadow() {
2285 let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2291 let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2292 (dx * dx + dy * dy + dz * dz <= 49).then_some(VoxColor(0x80_C0_C0_C0))
2293 }));
2294 let mut occ = SpriteOccluder::new();
2295 occ.push(
2296 Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2297 8,
2298 VoxColor(0x80_FF_FF_FF),
2299 ))),
2300 [0.0, 25.0, 0.0],
2301 [1.0, 0.0, 0.0],
2302 [0.0, 1.0, 0.0],
2303 [0.0, 0.0, 1.0],
2304 );
2305 let table = MaterialTable::new();
2306 let base = CpuLights {
2307 enabled: true,
2308 sun: true,
2309 sun_dir: [0.0, -1.0, 0.0], sun_color: [1.0; 3],
2311 sun_intensity: 1.0,
2312 sun_casts_shadow: true,
2313 ambient: [0.3; 3],
2314 shadow_strength: 0.85,
2315 shadow_bias: 1.5,
2316 shadow_max_dist: 128.0,
2317 ..CpuLights::default()
2318 };
2319 let (w, h) = (64u32, 64u32);
2320 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2321 let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2322 let n = (w * h) as usize;
2323 let mut fb = vec![0u32; n];
2324 let mut zb = vec![f32::INFINITY; n];
2325 let sh = SpriteShade {
2326 materials: &table,
2327 lights: base,
2328 material: 0,
2329 alpha_mul: 255,
2330 tint: 0x00FF_FFFF,
2331 shadow,
2332 };
2333 let _ = draw_sprite_dense_shaded(
2334 &mut fb,
2335 &mut zb,
2336 w as usize,
2337 w,
2338 h,
2339 &cs,
2340 &settings(w, h),
2341 &target,
2342 [0.0, 40.0, 0.0],
2343 [1.0, 0.0, 0.0],
2344 [0.0, 1.0, 0.0],
2345 [0.0, 0.0, 1.0],
2346 0,
2347 Some(sh),
2348 );
2349 fb.iter()
2350 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2351 .sum()
2352 };
2353 let lit = sum_lum(None);
2354 let shadowed = sum_lum(Some(&occ));
2355 assert!(
2356 shadowed < lit,
2357 "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2358 );
2359 }
2360
2361 #[test]
2364 fn sprite_rgb_tint_recolours() {
2365 let table = MaterialTable::new();
2366 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF)));
2367 let (w, h) = (64u32, 64u32);
2368 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2369 let centre = |tint: u32| -> u32 {
2370 let n = (w * h) as usize;
2371 let mut fb = vec![0u32; n];
2372 let mut zb = vec![f32::INFINITY; n];
2373 let sh = SpriteShade {
2374 materials: &table,
2375 lights: CpuLights::default(),
2376 material: 0,
2377 alpha_mul: 255,
2378 tint,
2379 shadow: None,
2380 };
2381 let _ = draw_sprite_dense_shaded(
2382 &mut fb,
2383 &mut zb,
2384 w as usize,
2385 w,
2386 h,
2387 &cs,
2388 &settings(w, h),
2389 &dense,
2390 [0.0, 40.0, 0.0],
2391 [1.0, 0.0, 0.0],
2392 [0.0, 1.0, 0.0],
2393 [0.0, 0.0, 1.0],
2394 0,
2395 Some(sh),
2396 );
2397 fb[(h / 2 * w + w / 2) as usize]
2398 };
2399 let r = |p: u32| (p >> 16) & 0xff;
2400 let g = |p: u32| (p >> 8) & 0xff;
2401 let b = |p: u32| p & 0xff;
2402 let white = centre(0x00FF_FFFF);
2403 let red = centre(0x00FF_0000);
2404 assert!(
2405 g(white) > 180 && b(white) > 180 && r(white) > 180,
2406 "white tint must be a no-op: {white:#08x}"
2407 );
2408 assert!(
2409 r(red) > 180 && g(red) < 20 && b(red) < 20,
2410 "red tint zeroes green/blue, keeps red: {red:#08x}"
2411 );
2412 }
2413
2414 #[test]
2419 fn translucent_sprite_layers_are_lit() {
2420 fn center_red(lights: CpuLights) -> u32 {
2421 let mut table = MaterialTable::new();
2422 table.set(1, Material::alpha_blend(160));
2423 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, VoxColor(0x80_E0_30_30)));
2424 let (w, h) = (64u32, 64u32);
2425 let n = (w * h) as usize;
2426 let mut fb = vec![0x80_10_10_10u32; n];
2427 let mut zb = vec![f32::INFINITY; n];
2428 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2429 let sh = SpriteShade {
2430 materials: &table,
2431 lights,
2432 material: 1,
2433 alpha_mul: 255,
2434 tint: 0x00FF_FFFF,
2435 shadow: None,
2436 };
2437 let _ = draw_sprite_dense_shaded(
2438 &mut fb,
2439 &mut zb,
2440 w as usize,
2441 w,
2442 h,
2443 &cs,
2444 &settings(w, h),
2445 &dense,
2446 [0.0, 40.0, 0.0],
2447 [1.0, 0.0, 0.0],
2448 [0.0, 1.0, 0.0],
2449 [0.0, 0.0, 1.0],
2450 0,
2451 Some(sh),
2452 );
2453 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2454 }
2455 let baked = center_red(CpuLights::default()); let dim = center_red(CpuLights {
2457 enabled: true,
2458 ambient: [0.3; 3], ..CpuLights::default()
2460 });
2461 assert!(
2462 dim < baked,
2463 "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2464 );
2465 }
2466
2467 #[test]
2472 fn translucent_sprite_tints_opaque_sprite_behind() {
2473 let mut table = MaterialTable::new();
2474 table.set(1, Material::alpha_blend(128));
2475 let (w, h) = (64u32, 64u32);
2476 let n = (w * h) as usize;
2477 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
2479 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2480 let cfg = settings(w, h);
2481 let id = [1.0, 0.0, 0.0];
2482 let up = [0.0, 1.0, 0.0];
2483 let fw = [0.0, 0.0, 1.0];
2484 let centre = (h / 2 * w + w / 2) as usize;
2485
2486 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_FF_00_00)));
2488 let sh_op = SpriteShade {
2489 materials: &table,
2490 lights: CpuLights::default(),
2491 material: 0,
2492 alpha_mul: 255,
2493 tint: 0x00FF_FFFF,
2494 shadow: None,
2495 };
2496 let _ = draw_sprite_dense_shaded(
2497 &mut fb,
2498 &mut zb,
2499 w as usize,
2500 w,
2501 h,
2502 &cs,
2503 &cfg,
2504 &backdrop,
2505 [0.0, 80.0, 0.0],
2506 id,
2507 up,
2508 fw,
2509 0,
2510 Some(sh_op),
2511 );
2512 let after_backdrop = fb[centre];
2513 assert_eq!(
2514 after_backdrop & 0x00ff_ffff,
2515 0x00FF_0000,
2516 "backdrop red must be drawn first"
2517 );
2518
2519 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_00_FF_FF)));
2521 let sh_gl = SpriteShade {
2522 materials: &table,
2523 lights: CpuLights::default(),
2524 material: 1,
2525 alpha_mul: 255,
2526 tint: 0x00FF_FFFF,
2527 shadow: None,
2528 };
2529 let wrote = draw_sprite_dense_shaded(
2530 &mut fb,
2531 &mut zb,
2532 w as usize,
2533 w,
2534 h,
2535 &cs,
2536 &cfg,
2537 &glass,
2538 [0.0, 40.0, 0.0],
2539 id,
2540 up,
2541 fw,
2542 0,
2543 Some(sh_gl),
2544 );
2545 let _ = wrote;
2546 let after_glass = fb[centre];
2547 assert_ne!(
2548 after_glass, after_backdrop,
2549 "glass must tint the backdrop (composite over it)"
2550 );
2551 assert!(
2553 (after_glass >> 16) & 0xff < 0xFF,
2554 "glass should reduce the backdrop's red (got {after_glass:08x})"
2555 );
2556 }
2557
2558 #[test]
2561 fn from_kv6_with_materials_classifies_by_color() {
2562 let col = VoxColor(0x80_AA_BB_CC);
2563 let kv6 = Kv6::solid_cube(6, col);
2564 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
2565 assert_eq!(
2566 dense.mat.len(),
2567 dense.col.len(),
2568 "per-voxel mat array sized"
2569 );
2570 let mut solids = 0;
2571 for idx in 0..dense.occ.len() {
2572 if dense.occ[idx] {
2573 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2574 solids += 1;
2575 }
2576 }
2577 assert!(solids > 0, "cube has solid voxels");
2578 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x0012_3456), 5)]);
2580 assert!(
2581 dense0.mat.iter().all(|&m| m == 0),
2582 "unmapped colour → material 0"
2583 );
2584 }
2585
2586 #[test]
2591 fn per_voxel_material_matches_uniform_when_homogeneous() {
2592 let mut table = MaterialTable::new();
2593 table.set(1, Material::alpha_blend(120));
2594 let col = VoxColor(0x80_30_A0_F0);
2595 let kv6 = Kv6::solid_cube(10, col);
2596 let (w, h) = (64u32, 64u32);
2597 let n = (w * h) as usize;
2598 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2599 let cfg = settings(w, h);
2600 let (pos, s, hh, f) = (
2601 [0.0, 40.0, 0.0],
2602 [1.0, 0.0, 0.0],
2603 [0.0, 1.0, 0.0],
2604 [0.0, 0.0, 1.0],
2605 );
2606 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2607 let mut fb = vec![0x80_10_10_10u32; n];
2608 let mut zb = vec![f32::INFINITY; n];
2609 let sh = SpriteShade {
2610 materials: &table,
2611 lights: CpuLights::default(),
2612 material,
2613 alpha_mul: 255,
2614 tint: 0x00FF_FFFF,
2615 shadow: None,
2616 };
2617 let _ = draw_sprite_dense_shaded(
2618 &mut fb,
2619 &mut zb,
2620 w as usize,
2621 w,
2622 h,
2623 &cs,
2624 &cfg,
2625 dense,
2626 pos,
2627 s,
2628 hh,
2629 f,
2630 0,
2631 Some(sh),
2632 );
2633 fb
2634 };
2635 let pv = render(
2638 &SpriteDense::from_kv6_with_materials(&kv6, &[(col.rgb_part(), 1)]),
2639 0,
2640 );
2641 let un = render(&SpriteDense::from_kv6(&kv6), 1);
2643 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2644 let centre = (h / 2 * w + w / 2) as usize;
2646 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2647 }
2648
2649 #[test]
2654 fn clip_flipbook_with_materials_classifies_every_frame() {
2655 let dims = [6u32, 6, 6];
2656 let glass = Rgb(0x00AA_BBCC);
2657 let glass_lit = 0x80AA_BBCC;
2658 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2660 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2661 let clip = VoxelClip::from_frames(
2662 dims,
2663 [3.0, 3.0, 3.0],
2664 1.0,
2665 LoopMode::Loop,
2666 &[f0, f1],
2667 &[],
2668 33,
2669 0,
2670 );
2671 let decoded = clip.decode().expect("decode");
2672
2673 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2674 assert_eq!(book.frame_count(), 2);
2675 for fr in 0..2 {
2676 let dense = book.frame(fr).expect("frame in range");
2677 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2678 let mut solids = 0;
2679 for idx in 0..dense.occ.len() {
2680 if dense.occ[idx] {
2681 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2682 solids += 1;
2683 }
2684 }
2685 assert!(solids > 0, "frame {fr} has solid voxels");
2686 }
2687
2688 let plain = ClipFlipbook::from_decoded(&decoded);
2690 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2691 for fr in 0..2 {
2692 assert!(plain.frame(fr).unwrap().mat.is_empty());
2693 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2694 }
2695 }
2696}