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 voxel_world_size: f32,
81}
82
83impl SpriteDense {
84 #[must_use]
86 #[allow(clippy::cast_possible_wrap)]
87 pub fn from_kv6(kv6: &Kv6) -> Self {
88 let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
89 let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
90 let mut occ = vec![false; n];
91 let mut col = vec![0u32; n];
92 let mut vi = 0usize;
93 for x in 0..kv6.xsiz as usize {
94 for y in 0..kv6.ysiz as usize {
95 let cnt = usize::from(kv6.ylen[x][y]);
96 for _ in 0..cnt {
97 let v = kv6.voxels[vi];
98 vi += 1;
99 let z = i32::from(v.z);
100 if z >= 0 && z < dims[2] {
101 let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
102 occ[idx] = true;
103 col[idx] = full_bright(v.col);
104 }
105 }
106 }
107 }
108 Self {
109 dims,
110 occ,
111 col,
112 mat: Vec::new(),
113 pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
114 voxel_world_size: 1.0,
115 }
116 }
117
118 #[must_use]
126 #[allow(clippy::cast_possible_wrap)]
127 pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(Rgb, u8)]) -> Self {
128 let mut dense = Self::from_kv6(kv6);
129 if !material_map.is_empty() {
130 let n = dense.col.len();
131 let mut mat = vec![0u8; n];
132 for (idx, slot) in mat.iter_mut().enumerate() {
133 if dense.occ[idx] {
134 *slot = material_for_color(material_map, dense.col[idx]);
135 }
136 }
137 dense.mat = mat;
138 }
139 dense
140 }
141
142 #[must_use]
148 #[allow(clippy::cast_possible_wrap)]
149 pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
150 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
151 let owpc = mz.div_ceil(32).max(1) as usize;
152 let n = (mx * my * mz) as usize;
153 let mut occ = vec![false; n];
154 let mut col = vec![0u32; n];
155 for col_idx in 0..(mx * my) as usize {
156 let x = col_idx as u32 % mx;
157 let y = col_idx as u32 / mx;
158 let run_start = frame.color_offsets[col_idx] as usize;
159 let mut k = 0usize;
160 for z in 0..mz {
161 let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
162 if (word >> (z & 31)) & 1 != 0 {
163 let idx = (((x * my + y) * mz) + z) as usize;
164 occ[idx] = true;
165 col[idx] = full_bright(frame.colors[run_start + k]);
166 k += 1;
167 }
168 }
169 }
170 Self {
171 dims: [mx as i32, my as i32, mz as i32],
172 occ,
173 col,
174 mat: Vec::new(),
175 pivot,
176 voxel_world_size: 1.0,
177 }
178 }
179
180 #[must_use]
183 pub fn with_voxel_world_size(mut self, vws: f32) -> Self {
184 self.voxel_world_size = vws;
185 self
186 }
187
188 #[must_use]
194 pub fn from_voxel_frame_with_materials(
195 frame: &VoxelFrame,
196 dims: [u32; 3],
197 pivot: [f32; 3],
198 material_map: &[(Rgb, u8)],
199 ) -> Self {
200 let mut dense = Self::from_voxel_frame(frame, dims, pivot);
201 if !material_map.is_empty() {
202 let n = dense.col.len();
203 let mut mat = vec![0u8; n];
204 for (idx, slot) in mat.iter_mut().enumerate() {
205 if dense.occ[idx] {
206 *slot = material_for_color(material_map, dense.col[idx]);
207 }
208 }
209 dense.mat = mat;
210 }
211 dense
212 }
213
214 #[inline]
215 #[allow(clippy::cast_sign_loss)]
216 fn idx_of(&self, c: [i32; 3]) -> usize {
217 ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
218 }
219
220 #[inline]
221 fn at(&self, c: [i32; 3]) -> Option<u32> {
222 let idx = self.idx_of(c);
223 self.occ[idx].then(|| self.col[idx])
224 }
225}
226
227fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
230 let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
231 + f[0] * (s[1] * h[2] - h[1] * s[2]);
232 if det.abs() < 1e-12 {
233 return None;
234 }
235 let inv = 1.0 / det;
236 Some([
237 [
238 (h[1] * f[2] - f[1] * h[2]) * inv,
239 -(h[0] * f[2] - f[0] * h[2]) * inv,
240 (h[0] * f[1] - f[0] * h[1]) * inv,
241 ],
242 [
243 -(s[1] * f[2] - f[1] * s[2]) * inv,
244 (s[0] * f[2] - f[0] * s[2]) * inv,
245 -(s[0] * f[1] - f[0] * s[1]) * inv,
246 ],
247 [
248 (s[1] * h[2] - h[1] * s[2]) * inv,
249 -(s[0] * h[2] - h[0] * s[2]) * inv,
250 (s[0] * h[1] - h[0] * s[1]) * inv,
251 ],
252 ])
253}
254
255#[inline]
256fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
257 [
258 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
259 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
260 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
261 ]
262}
263
264#[allow(clippy::cast_possible_truncation)]
268fn cast_local(
274 dense: &SpriteDense,
275 origin: [f32; 3],
276 dir: [f32; 3],
277) -> Option<(u32, f32, [f32; 3], [i32; 3])> {
278 #[allow(clippy::cast_precision_loss)]
279 let hi = [
280 dense.dims[0] as f32,
281 dense.dims[1] as f32,
282 dense.dims[2] as f32,
283 ];
284 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
285 let start = t0 + 1e-4;
286 let p = [
287 origin[0] + dir[0] * start,
288 origin[1] + dir[1] * start,
289 origin[2] + dir[2] * start,
290 ];
291 let mut cell = [
292 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
293 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
294 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
295 ];
296 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
297 let mut t_curr = t0;
298 let mut normal = [0.0f32; 3];
301 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
302 for _ in 0..max_steps {
303 if cell[0] < 0
304 || cell[0] >= dense.dims[0]
305 || cell[1] < 0
306 || cell[1] >= dense.dims[1]
307 || cell[2] < 0
308 || cell[2] >= dense.dims[2]
309 || t_curr > t1
310 {
311 return None;
312 }
313 if let Some(color) = dense.at(cell) {
314 return Some((color, t_curr, normal, cell));
315 }
316 let axis = min_axis(t_max);
317 t_curr = t_max[axis];
318 cell[axis] += step[axis];
319 t_max[axis] += t_delta[axis];
320 normal = [0.0; 3];
321 normal[axis] = -(step[axis] as f32);
322 }
323 None
324}
325
326struct SpriteOccEntry {
332 dense: Arc<SpriteDense>,
333 pos: [f32; 3],
334 pivot: [f32; 3],
335 minv: [[f32; 3]; 3],
336}
337
338#[derive(Default)]
348pub struct SpriteOccluder {
349 entries: Vec<SpriteOccEntry>,
350}
351
352impl SpriteOccluder {
353 #[must_use]
356 pub fn new() -> Self {
357 Self::default()
358 }
359
360 #[must_use]
362 pub fn is_empty(&self) -> bool {
363 self.entries.is_empty()
364 }
365
366 pub fn push(
372 &mut self,
373 dense: Arc<SpriteDense>,
374 pos: [f32; 3],
375 s: [f32; 3],
376 h: [f32; 3],
377 f: [f32; 3],
378 ) {
379 let sc = dense.voxel_world_size;
382 let (s, h, f) = (bb_scale3(s, sc), bb_scale3(h, sc), bb_scale3(f, sc));
383 let Some(minv) = invert_basis(s, h, f) else {
384 return;
385 };
386 let pivot = dense.pivot;
387 self.entries.push(SpriteOccEntry {
388 dense,
389 pos,
390 pivot,
391 minv,
392 });
393 }
394}
395
396impl WorldOccluder for SpriteOccluder {
397 fn occluded_world(&self, origin: [f64; 3], dir: [f32; 3], max_t: f32) -> bool {
398 self.entries
399 .iter()
400 .any(|e| sprite_entry_occluded(e, origin, dir, max_t))
401 }
402}
403
404#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
407fn bb_scale3(v: [f32; 3], k: f32) -> [f32; 3] {
409 [v[0] * k, v[1] * k, v[2] * k]
410}
411
412fn sprite_entry_occluded(e: &SpriteOccEntry, ow: [f64; 3], dw: [f32; 3], max_t: f32) -> bool {
413 #[allow(clippy::cast_possible_truncation)]
418 let rel = [
419 (ow[0] - f64::from(e.pos[0])) as f32,
420 (ow[1] - f64::from(e.pos[1])) as f32,
421 (ow[2] - f64::from(e.pos[2])) as f32,
422 ];
423 let ol = mat_apply(&e.minv, rel);
424 let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
425 let dir = mat_apply(&e.minv, dw);
426
427 let hi = [
428 e.dense.dims[0] as f32,
429 e.dense.dims[1] as f32,
430 e.dense.dims[2] as f32,
431 ];
432 let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
433 return false;
434 };
435 let t_enter = t0.max(0.0);
436 let t_exit = t1.min(max_t);
437 if t_enter > t_exit {
438 return false;
439 }
440 let start = t_enter + 1e-4;
441 let p = [
442 origin[0] + dir[0] * start,
443 origin[1] + dir[1] * start,
444 origin[2] + dir[2] * start,
445 ];
446 let mut cell = [
447 (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
448 (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
449 (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
450 ];
451 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
452 let mut t_curr = t_enter;
453 let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
454 for _ in 0..max_steps {
455 if cell[0] < 0
456 || cell[0] >= e.dense.dims[0]
457 || cell[1] < 0
458 || cell[1] >= e.dense.dims[1]
459 || cell[2] < 0
460 || cell[2] >= e.dense.dims[2]
461 || t_curr > t_exit
462 {
463 return false;
464 }
465 if e.dense.occ[e.dense.idx_of(cell)] {
466 return true;
467 }
468 let a = min_axis(t_max);
469 t_curr = t_max[a];
470 cell[a] += step[a];
471 t_max[a] += t_delta[a];
472 }
473 false
474}
475
476#[derive(Clone, Copy)]
483pub struct SpriteShade<'a> {
484 pub materials: &'a MaterialTable,
486 pub material: u8,
489 pub alpha_mul: u8,
492 pub tint: u32,
495 pub lights: CpuLights<'a>,
499 pub shadow: Option<&'a dyn WorldOccluder>,
504}
505
506struct LayerAccum {
508 rgb: [f32; 3],
511 trans: f32,
513 opaque: Option<(u32, f32)>,
518}
519
520#[inline]
524fn tint_packed(color: u32, tint: u32) -> u32 {
525 if tint & 0x00FF_FFFF == 0x00FF_FFFF {
526 return color;
527 }
528 let mul = |shift: u32| {
529 let c = (color >> shift) & 0xff;
530 let t = (tint >> shift) & 0xff;
531 ((c * t) / 255) & 0xff
532 };
533 (color & 0xff00_0000) | (mul(16) << 16) | (mul(8) << 8) | mul(0)
534}
535
536#[inline]
539fn rgb_to_f32(c: u32) -> [f32; 3] {
540 [
541 ((c >> 16) & 0xff) as f32 / 255.0,
542 ((c >> 8) & 0xff) as f32 / 255.0,
543 (c & 0xff) as f32 / 255.0,
544 ]
545}
546
547#[inline]
550#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
551fn f32_to_rgb(c: [f32; 3]) -> u32 {
552 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
553 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
554}
555
556const SPRITE_WORLD_UP: [f32; 3] = [0.0, 0.0, -1.0];
559
560#[derive(Clone, Copy, PartialEq, Eq)]
565pub enum SpriteLightMode {
566 FaceNormal,
568 WorldUp,
570 AmbientOnly,
572 FullBright,
575}
576
577impl SpriteLightMode {
578 #[must_use]
584 pub fn from_flags(flags: u32) -> Self {
585 let world_up = flags & SPRITE_FLAG_LIGHT_WORLD_UP != 0;
586 let ambient_only = flags & SPRITE_FLAG_LIGHT_AMBIENT_ONLY != 0;
587 match (ambient_only, world_up) {
588 (true, true) => Self::FullBright, (true, false) => Self::AmbientOnly,
590 (false, true) => Self::WorldUp,
591 (false, false) => Self::FaceNormal,
592 }
593 }
594}
595
596fn shade_dynamic_mode(
601 mode: SpriteLightMode,
602 albedo: [f32; 3],
603 n_world: [f32; 3],
604 center: [f32; 3],
605 lights: &CpuLights<'_>,
606 tester: Option<&mut dyn ShadowTester>,
607) -> u32 {
608 match mode {
609 SpriteLightMode::FaceNormal => shade_dynamic(albedo, 1.0, n_world, center, lights, tester),
610 SpriteLightMode::WorldUp => {
611 shade_dynamic(albedo, 1.0, SPRITE_WORLD_UP, center, lights, tester)
612 }
613 SpriteLightMode::AmbientOnly => {
614 let mut amb = *lights;
615 amb.sun = false;
616 amb.points = &[];
617 amb.bands = 0; shade_dynamic(albedo, 1.0, n_world, center, &amb, None)
619 }
620 SpriteLightMode::FullBright => f32_to_rgb(albedo),
622 }
623}
624
625#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
632fn cast_local_layers(
633 dense: &SpriteDense,
634 origin: [f32; 3],
635 dir: [f32; 3],
636 fwd_dot: f32,
637 max_t: f32,
638 shade_ctx: SpriteShade,
639 s: [f32; 3],
643 h: [f32; 3],
644 f: [f32; 3],
645 pos: [f32; 3],
646 light_mode: SpriteLightMode,
647) -> Option<LayerAccum> {
648 #[allow(clippy::cast_precision_loss)]
649 let hi = [
650 dense.dims[0] as f32,
651 dense.dims[1] as f32,
652 dense.dims[2] as f32,
653 ];
654 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
655 let start = t0 + 1e-4;
656 let p = [
657 origin[0] + dir[0] * start,
658 origin[1] + dir[1] * start,
659 origin[2] + dir[2] * start,
660 ];
661 let mut cell = [
662 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
663 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
664 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
665 ];
666 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
667 let mut t_curr = t0;
668 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
669
670 let mut acc = LayerAccum {
671 rgb: [0.0; 3],
672 trans: 1.0,
673 opaque: None,
674 };
675 let mut touched = false;
676 let mut prev_solid = false;
687 let mut prev_mat = 0u8;
688 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
691 let mut normal = [0.0f32; 3];
695
696 let lights = shade_ctx.lights;
702 let tint = shade_ctx.tint;
703 let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
704 ctx: WorldShadowCtx::identity(occ),
705 });
706 let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3]| -> u32 {
707 if !lights.enabled {
708 return tint_packed(shade(dense.col[idx], 0), tint);
709 }
710 let to_world = |v: [f32; 3]| {
711 [
712 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
713 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
714 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
715 ]
716 };
717 let n_world = to_world(n_local);
718 let rel = [
719 cell[0] as f32 + 0.5 - dense.pivot[0],
720 cell[1] as f32 + 0.5 - dense.pivot[1],
721 cell[2] as f32 + 0.5 - dense.pivot[2],
722 ];
723 let wc = to_world(rel);
724 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
725 let albedo = [
726 ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
727 ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
728 (dense.col[idx] & 0xff) as f32 / 255.0,
729 ];
730 let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
731 tint_packed(
732 shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
733 tint,
734 )
735 };
736
737 for _ in 0..max_steps {
738 if cell[0] < 0
739 || cell[0] >= dense.dims[0]
740 || cell[1] < 0
741 || cell[1] >= dense.dims[1]
742 || cell[2] < 0
743 || cell[2] >= dense.dims[2]
744 || t_curr > t1
745 {
746 break;
747 }
748 let depth = t_curr * fwd_dot;
751 if depth >= max_t {
752 break;
753 }
754 let exit_axis = min_axis(t_max);
757 let t_exit = t_max[exit_axis];
758 let idx = dense.idx_of(cell);
759 let solid_here = dense.occ[idx];
760 if solid_here && depth >= NEAR_Z {
761 let mat_id = if dense.mat.is_empty() {
762 shade_ctx.material
763 } else {
764 dense.mat[idx]
765 };
766 let m = shade_ctx.materials.get(mat_id);
767 if m.is_opaque() {
768 acc.opaque = Some((shade_layer(idx, cell, normal), t_curr));
769 touched = true;
770 break;
771 }
772 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
773 if m.mode == BlendMode::Volumetric {
774 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
778 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
779 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
780 acc.rgb[0] += acc.trans * eff_a * lit[0];
781 acc.rgb[1] += acc.trans * eff_a * lit[1];
782 acc.rgb[2] += acc.trans * eff_a * lit[2];
783 acc.trans *= 1.0 - eff_a;
784 touched = true;
785 prev_mat = mat_id;
786 if acc.trans < 1.0 / 256.0 {
787 break;
788 }
789 } else if !prev_solid || mat_id != prev_mat {
790 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
793 acc.rgb[0] += acc.trans * a * lit[0];
794 acc.rgb[1] += acc.trans * a * lit[1];
795 acc.rgb[2] += acc.trans * a * lit[2];
796 if m.mode == BlendMode::AlphaBlend {
797 acc.trans *= 1.0 - a; }
799 touched = true;
800 prev_mat = mat_id;
801 if acc.trans < 1.0 / 256.0 {
802 break;
803 }
804 }
805 }
806 prev_solid = solid_here;
807 t_curr = t_exit;
808 cell[exit_axis] += step[exit_axis];
809 t_max[exit_axis] += t_delta[exit_axis];
810 normal = [0.0; 3];
811 normal[exit_axis] = -(step[exit_axis] as f32);
812 }
813
814 touched.then_some(acc)
815}
816
817#[allow(
827 clippy::too_many_arguments,
828 clippy::cast_possible_truncation,
829 clippy::cast_sign_loss
830)]
831#[must_use]
832pub fn draw_sprite_dda(
833 fb: &mut [u32],
834 zb: &mut [f32],
835 pitch_pixels: usize,
836 width: u32,
837 height: u32,
838 cam: &CameraState,
839 settings: &OpticastSettings,
840 sprite: &Sprite,
841) -> u32 {
842 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
843 return 0;
844 }
845 draw_sprite_dda_shaded(
846 fb,
847 zb,
848 pitch_pixels,
849 width,
850 height,
851 cam,
852 settings,
853 sprite,
854 None,
855 )
856}
857
858#[allow(clippy::too_many_arguments)]
863#[must_use]
864pub fn draw_sprite_dda_shaded(
865 fb: &mut [u32],
866 zb: &mut [f32],
867 pitch_pixels: usize,
868 width: u32,
869 height: u32,
870 cam: &CameraState,
871 settings: &OpticastSettings,
872 sprite: &Sprite,
873 shade_ctx: Option<SpriteShade>,
874) -> u32 {
875 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
876 return 0;
877 }
878 let dense = if sprite.material_map.is_empty() {
883 SpriteDense::from_kv6(&sprite.kv6)
884 } else {
885 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
886 };
887 draw_sprite_dense_shaded(
888 fb,
889 zb,
890 pitch_pixels,
891 width,
892 height,
893 cam,
894 settings,
895 &dense,
896 sprite.p,
897 sprite.s,
898 sprite.h,
899 sprite.f,
900 sprite.flags,
901 shade_ctx,
902 )
903}
904
905#[allow(clippy::too_many_arguments)]
914#[must_use]
915pub fn draw_sprite_dense(
916 fb: &mut [u32],
917 zb: &mut [f32],
918 pitch_pixels: usize,
919 width: u32,
920 height: u32,
921 cam: &CameraState,
922 settings: &OpticastSettings,
923 dense: &SpriteDense,
924 pos: [f32; 3],
925 s: [f32; 3],
926 h: [f32; 3],
927 f: [f32; 3],
928 flags: u32,
929) -> u32 {
930 draw_sprite_dense_shaded(
931 fb,
932 zb,
933 pitch_pixels,
934 width,
935 height,
936 cam,
937 settings,
938 dense,
939 pos,
940 s,
941 h,
942 f,
943 flags,
944 None,
945 )
946}
947
948#[allow(
960 clippy::too_many_arguments,
961 clippy::cast_possible_truncation,
962 clippy::cast_sign_loss
963)]
964#[must_use]
965pub fn draw_sprite_dense_shaded(
966 fb: &mut [u32],
967 zb: &mut [f32],
968 pitch_pixels: usize,
969 width: u32,
970 height: u32,
971 cam: &CameraState,
972 settings: &OpticastSettings,
973 dense: &SpriteDense,
974 pos: [f32; 3],
975 s: [f32; 3],
976 h: [f32; 3],
977 f: [f32; 3],
978 flags: u32,
979 shade_ctx: Option<SpriteShade>,
980) -> u32 {
981 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
982 return 0;
983 }
984 let sc = dense.voxel_world_size;
989 let (s, h, f) = (bb_scale3(s, sc), bb_scale3(h, sc), bb_scale3(f, sc));
990 let Some(minv) = invert_basis(s, h, f) else {
991 return 0;
992 };
993 let pivot = dense.pivot;
994 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
995 let light_mode = SpriteLightMode::from_flags(flags);
997
998 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
1000 return 0;
1001 };
1002
1003 let layers =
1009 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
1010
1011 debug_assert_eq!(fb.len(), zb.len());
1012 let target = RasterTarget::new(fb, zb);
1013 let draw_row = |py: u32| -> u32 {
1017 let mut written = 0u32;
1018 let row = py as usize * pitch_pixels;
1019 for px in rect.0..rect.2 {
1020 let (origin, dir) = pixel_ray(cam, settings, px, py);
1021 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
1023 let ol = mat_apply(&minv, rel);
1024 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
1025 let dir_local = mat_apply(&minv, dir);
1026 let fwd_dot =
1027 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
1028 let idx = row + px as usize;
1029
1030 if let Some(shade_ctx) = layers {
1031 if fwd_dot <= 1e-6 {
1033 continue;
1034 }
1035 let max_t = if no_z {
1040 f32::INFINITY
1041 } else {
1042 unsafe { target.read_depth(idx) }
1043 };
1044 let Some(acc) = cast_local_layers(
1045 dense,
1046 origin_local,
1047 dir_local,
1048 fwd_dot,
1049 max_t,
1050 shade_ctx,
1051 s,
1052 h,
1053 f,
1054 pos,
1055 light_mode,
1056 ) else {
1057 continue;
1058 };
1059 let wrote = unsafe {
1061 match acc.opaque {
1062 Some((bg_color, t)) => {
1063 let bg = rgb_to_f32(bg_color);
1066 let out = f32_to_rgb([
1067 acc.rgb[0] + acc.trans * bg[0],
1068 acc.rgb[1] + acc.trans * bg[1],
1069 acc.rgb[2] + acc.trans * bg[2],
1070 ]);
1071 let depth = t * fwd_dot;
1072 if no_z {
1073 target.write_color(idx, out);
1074 target.write_depth(idx, depth);
1075 true
1076 } else {
1077 target.z_test_write(idx, out, depth)
1078 }
1079 }
1080 None => {
1081 let bg = rgb_to_f32(target.read_color(idx));
1086 let out = f32_to_rgb([
1087 acc.rgb[0] + acc.trans * bg[0],
1088 acc.rgb[1] + acc.trans * bg[1],
1089 acc.rgb[2] + acc.trans * bg[2],
1090 ]);
1091 target.write_color(idx, out);
1092 true
1093 }
1094 }
1095 };
1096 written += u32::from(wrote);
1097 } else {
1098 let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1100 else {
1101 continue;
1102 };
1103 let depth = t * fwd_dot;
1104 if depth < NEAR_Z {
1105 continue;
1106 }
1107 let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1112 let lit = if dl.enabled {
1113 let to_world = |v: [f32; 3]| {
1114 [
1115 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1116 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1117 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1118 ]
1119 };
1120 let n_world = to_world(n_local);
1121 let rel = [
1122 cell[0] as f32 + 0.5 - pivot[0],
1123 cell[1] as f32 + 0.5 - pivot[1],
1124 cell[2] as f32 + 0.5 - pivot[2],
1125 ];
1126 let wc = to_world(rel);
1127 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1128 let albedo = [
1129 ((color >> 16) & 0xff) as f32 / 255.0,
1130 ((color >> 8) & 0xff) as f32 / 255.0,
1131 (color & 0xff) as f32 / 255.0,
1132 ];
1133 let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1137 ctx: WorldShadowCtx::identity(occ),
1138 });
1139 let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1140 shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1141 } else {
1142 shade(color, 0)
1143 };
1144 let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1146 let wrote = unsafe {
1149 if no_z {
1150 target.write_color(idx, lit);
1151 target.write_depth(idx, depth);
1152 true
1153 } else {
1154 target.z_test_write(idx, lit, depth)
1155 }
1156 };
1157 written += u32::from(wrote);
1158 }
1159 }
1160 written
1161 };
1162 let rows = rect.3.saturating_sub(rect.1) as usize;
1167 let cols = rect.2.saturating_sub(rect.0) as usize;
1168 const SPRITE_PAR_MIN_PIXELS: usize = 64 * 64;
1169 if rows >= 2 && rows * cols >= SPRITE_PAR_MIN_PIXELS {
1170 use rayon::prelude::*;
1171 (rect.1..rect.3).into_par_iter().map(draw_row).sum()
1172 } else {
1173 (rect.1..rect.3).map(draw_row).sum()
1174 }
1175}
1176
1177#[allow(
1181 clippy::cast_possible_truncation,
1182 clippy::cast_sign_loss,
1183 clippy::cast_precision_loss
1184)]
1185fn project_screen_rect(
1186 dense: &SpriteDense,
1187 pos: [f32; 3],
1188 s: [f32; 3],
1189 h: [f32; 3],
1190 f: [f32; 3],
1191 cam: &CameraState,
1192 settings: &OpticastSettings,
1193 width: u32,
1194 height: u32,
1195) -> Option<(u32, u32, u32, u32)> {
1196 let (xs, ys, zs) = (
1197 dense.dims[0] as f32,
1198 dense.dims[1] as f32,
1199 dense.dims[2] as f32,
1200 );
1201 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1202 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1203 let mut all_front = true;
1204 for &cx in &[0.0, xs] {
1205 for &cy in &[0.0, ys] {
1206 for &cz in &[0.0, zs] {
1207 let lx = cx - xp;
1209 let ly = cy - yp;
1210 let lz = cz - zp;
1211 let world = [
1212 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1213 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1214 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1215 ];
1216 let rel = [
1217 world[0] - cam.pos[0],
1218 world[1] - cam.pos[1],
1219 world[2] - cam.pos[2],
1220 ];
1221 let cz_cam =
1222 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1223 if cz_cam < NEAR_Z {
1224 all_front = false;
1225 continue;
1226 }
1227 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1228 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1229 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1230 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1231 x0 = x0.min(sx);
1232 y0 = y0.min(sy);
1233 x1 = x1.max(sx);
1234 y1 = y1.max(sy);
1235 }
1236 }
1237 }
1238 let (w, h) = (width as f32, height as f32);
1239 let (rx0, ry0, rx1, ry1) = if all_front {
1240 (
1241 (x0 - 1.0).max(0.0),
1242 (y0 - 1.0).max(0.0),
1243 (x1 + 1.0).min(w),
1244 (y1 + 1.0).min(h),
1245 )
1246 } else {
1247 (0.0, 0.0, w, h)
1249 };
1250 if rx0 >= rx1 || ry0 >= ry1 {
1251 return None;
1252 }
1253 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1254}
1255
1256pub struct ClipFlipbook {
1263 frames: Vec<Arc<SpriteDense>>,
1266}
1267
1268impl ClipFlipbook {
1269 #[must_use]
1272 pub fn empty() -> Self {
1273 Self { frames: Vec::new() }
1274 }
1275
1276 #[must_use]
1278 pub fn from_decoded(clip: &DecodedClip) -> Self {
1279 Self::from_decoded_with_materials(clip, &[])
1280 }
1281
1282 #[must_use]
1288 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(Rgb, u8)]) -> Self {
1289 let frames = clip
1290 .frames
1291 .iter()
1292 .map(|frame| {
1293 Arc::new(
1294 SpriteDense::from_voxel_frame_with_materials(
1295 frame,
1296 clip.dims,
1297 clip.pivot,
1298 material_map,
1299 )
1300 .with_voxel_world_size(clip.voxel_world_size),
1301 )
1302 })
1303 .collect();
1304 Self { frames }
1305 }
1306
1307 #[must_use]
1310 pub fn frame_count(&self) -> usize {
1311 self.frames.len()
1312 }
1313
1314 #[must_use]
1316 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1317 self.frames.get(frame).map(Arc::as_ref)
1318 }
1319
1320 #[must_use]
1323 pub fn frame_arc(&self, frame: usize) -> Option<Arc<SpriteDense>> {
1324 self.frames.get(frame).cloned()
1325 }
1326
1327 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1331 match self.frames.get_mut(frame) {
1332 Some(slot) => {
1333 *slot = Arc::new(dense);
1334 true
1335 }
1336 None => false,
1337 }
1338 }
1339
1340 #[allow(clippy::too_many_arguments)]
1344 #[must_use]
1345 pub fn draw_frame(
1346 &self,
1347 fb: &mut [u32],
1348 zb: &mut [f32],
1349 pitch_pixels: usize,
1350 width: u32,
1351 height: u32,
1352 cam: &CameraState,
1353 settings: &OpticastSettings,
1354 frame: usize,
1355 pos: [f32; 3],
1356 s: [f32; 3],
1357 h: [f32; 3],
1358 f: [f32; 3],
1359 flags: u32,
1360 ) -> u32 {
1361 self.draw_frame_shaded(
1362 fb,
1363 zb,
1364 pitch_pixels,
1365 width,
1366 height,
1367 cam,
1368 settings,
1369 frame,
1370 pos,
1371 s,
1372 h,
1373 f,
1374 flags,
1375 None,
1376 )
1377 }
1378
1379 #[allow(clippy::too_many_arguments)]
1384 #[must_use]
1385 pub fn draw_frame_shaded(
1386 &self,
1387 fb: &mut [u32],
1388 zb: &mut [f32],
1389 pitch_pixels: usize,
1390 width: u32,
1391 height: u32,
1392 cam: &CameraState,
1393 settings: &OpticastSettings,
1394 frame: usize,
1395 pos: [f32; 3],
1396 s: [f32; 3],
1397 h: [f32; 3],
1398 f: [f32; 3],
1399 flags: u32,
1400 shade_ctx: Option<SpriteShade>,
1401 ) -> u32 {
1402 let Some(dense) = self.frames.get(frame) else {
1403 return 0;
1404 };
1405 draw_sprite_dense_shaded(
1406 fb,
1407 zb,
1408 pitch_pixels,
1409 width,
1410 height,
1411 cam,
1412 settings,
1413 dense,
1414 pos,
1415 s,
1416 h,
1417 f,
1418 flags,
1419 shade_ctx,
1420 )
1421 }
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427 use crate::camera_math;
1428 use crate::Camera;
1429 use roxlap_formats::kv6::Kv6;
1430 use roxlap_formats::material::{Material, MaterialTable};
1431 use roxlap_formats::VoxColor;
1432
1433 #[test]
1436 fn sprite_light_mode_world_up_and_ambient_only() {
1437 let lights = CpuLights {
1438 enabled: true,
1439 sun: true,
1440 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0, 1.0, 1.0],
1442 sun_intensity: 1.0,
1443 sun_casts_shadow: false,
1444 points: &[],
1445 ambient: [0.2, 0.2, 0.2],
1446 bands: 0,
1447 shadow_tint: [0.0; 3],
1448 shadow_strength: 0.0,
1449 shadow_bias: 0.0,
1450 shadow_max_dist: 0.0,
1451 };
1452 let a = [1.0, 1.0, 1.0];
1453 let c = [0.0, 0.0, 0.0];
1454 let g = |packed: u32| (packed >> 8) & 0xff; let up_n = [0.0, 0.0, -1.0];
1456 let side_n = [1.0, 0.0, 0.0];
1457 let face_up = g(shade_dynamic_mode(
1458 SpriteLightMode::FaceNormal,
1459 a,
1460 up_n,
1461 c,
1462 &lights,
1463 None,
1464 ));
1465 let face_side = g(shade_dynamic_mode(
1466 SpriteLightMode::FaceNormal,
1467 a,
1468 side_n,
1469 c,
1470 &lights,
1471 None,
1472 ));
1473 let amb = g(shade_dynamic_mode(
1474 SpriteLightMode::AmbientOnly,
1475 a,
1476 up_n,
1477 c,
1478 &lights,
1479 None,
1480 ));
1481 let world_up = g(shade_dynamic_mode(
1482 SpriteLightMode::WorldUp,
1483 a,
1484 side_n,
1485 c,
1486 &lights,
1487 None,
1488 ));
1489 assert!(
1490 face_up > face_side,
1491 "a sun-facing face is brighter than a side face"
1492 );
1493 assert!(amb < face_up, "ambient-only drops the sun term");
1494 assert_eq!(
1495 world_up, face_up,
1496 "world-up shades a side-facing billboard as if it faced up"
1497 );
1498 let full = g(shade_dynamic_mode(
1499 SpriteLightMode::FullBright,
1500 a,
1501 side_n,
1502 c,
1503 &lights,
1504 None,
1505 ));
1506 assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1509 assert!(full > amb, "full-bright glow is brighter than ambient-only");
1510 }
1511
1512 #[test]
1517 fn cast_local_reports_face_normal() {
1518 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| {
1520 (z >= 4).then_some(VoxColor(0x80_C0_40_20))
1521 });
1522 let dense = SpriteDense::from_kv6(&kv6);
1523 let (_c, _t, n, cell) =
1525 cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1526 assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1527 assert!(
1528 n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1529 "z-crossing face normal points back toward the ray (-z): {n:?}",
1530 );
1531 }
1532 use roxlap_formats::sprite::Sprite;
1533 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1534
1535 fn settings(w: u32, h: u32) -> OpticastSettings {
1536 OpticastSettings::for_oracle_framebuffer(w, h)
1537 }
1538
1539 fn cam_looking_y() -> Camera {
1541 Camera {
1542 pos: [0.0, 0.0, 0.0],
1543 right: [1.0, 0.0, 0.0],
1544 down: [0.0, 0.0, 1.0],
1545 forward: [0.0, 1.0, 0.0],
1546 }
1547 }
1548
1549 #[test]
1556 fn scaled_basis_scales_drawn_extent() {
1557 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, _| Some(VoxColor(0x80_C0_40_20)));
1558 let (w, h) = (64u32, 64u32);
1559 let n = (w * h) as usize;
1560 let cam = cam_looking_y();
1561 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1562 let cfg = settings(w, h);
1563
1564 let px_at = |k: f32| -> u32 {
1565 let mut sprite = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1566 for a in 0..3 {
1567 sprite.s[a] *= k;
1568 sprite.h[a] *= k;
1569 sprite.f[a] *= k;
1570 }
1571 let mut fb = vec![0u32; n];
1572 let mut zb = vec![f32::INFINITY; n];
1573 draw_sprite_dda(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, &sprite)
1574 };
1575
1576 let (unit, double, half) = (px_at(1.0), px_at(2.0), px_at(0.5));
1577 assert!(unit > 0, "unit-scale cube must draw ({unit} px)");
1578 let r2 = f64::from(double) / f64::from(unit);
1579 let rh = f64::from(half) / f64::from(unit);
1580 assert!(
1581 (3.0..8.0).contains(&r2),
1582 "2× scale should roughly quadruple coverage: {unit} → {double} px (×{r2:.2})"
1583 );
1584 assert!(
1585 (0.08..0.5).contains(&rh),
1586 "0.5× scale should roughly quarter coverage: {unit} → {half} px (×{rh:.2})"
1587 );
1588 }
1589
1590 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1592 let owpc = dims[2].div_ceil(32).max(1) as usize;
1593 let cols = (dims[0] * dims[1]) as usize;
1594 let mut occupancy = vec![0u32; cols * owpc];
1595 let mut color_offsets = vec![0u32; cols + 1];
1596 let mut colors = Vec::new();
1597 for y in 0..dims[1] {
1598 for x in 0..dims[0] {
1599 let col = (x + y * dims[0]) as usize;
1600 color_offsets[col] = colors.len() as u32;
1601 for z in 0..dims[2] {
1602 if let Some(c) = fill(x, y, z) {
1603 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1604 colors.push(c);
1605 }
1606 }
1607 }
1608 }
1609 color_offsets[cols] = colors.len() as u32;
1610 VoxelFrame {
1611 occupancy,
1612 colors,
1613 color_offsets,
1614 }
1615 }
1616
1617 #[test]
1622 fn clip_flipbook_frames_render_differently() {
1623 let dims = [8u32, 8, 8];
1624 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(
1627 dims,
1628 [4.0, 4.0, 4.0],
1629 1.0,
1630 LoopMode::Loop,
1631 &[f0, f1],
1632 &[],
1633 33,
1634 0,
1635 );
1636 let decoded = clip.decode().expect("decode");
1637 let book = ClipFlipbook::from_decoded(&decoded);
1638 assert_eq!(book.frame_count(), 2);
1639 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1640
1641 let (w, h) = (64u32, 64u32);
1642 let n = (w * h) as usize;
1643 let cam = cam_looking_y();
1644 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1645 let cfg = settings(w, h);
1646 let pose = [0.0, 40.0, 0.0];
1647 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1648
1649 let render = |frame: usize| -> Vec<u32> {
1650 let mut fb = vec![0u32; n];
1651 let mut zb = vec![f32::INFINITY; n];
1652 let wrote = book.draw_frame(
1653 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1654 );
1655 assert!(wrote > 0, "frame {frame} should draw some pixels");
1656 fb
1657 };
1658 let fb0 = render(0);
1659 let fb1 = render(1);
1660 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1661 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1664 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1665 let mut fb = vec![0u32; n];
1667 let mut zb = vec![f32::INFINITY; n];
1668 assert_eq!(
1669 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1670 0
1671 );
1672 }
1673
1674 #[test]
1675 fn clip_flipbook_set_frame_replaces_one_frame() {
1676 let dims = [8u32, 8, 8];
1679 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 =
1682 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1683 let decoded = clip.decode().unwrap();
1684 let mut book = ClipFlipbook::from_decoded(&decoded);
1685
1686 let (w, h) = (64u32, 64u32);
1687 let n = (w * h) as usize;
1688 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1689 let cfg = settings(w, h);
1690 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1691 let mut fb = vec![0u32; n];
1692 let mut zb = vec![f32::INFINITY; n];
1693 let _ = b.draw_frame(
1694 &mut fb,
1695 &mut zb,
1696 w as usize,
1697 w,
1698 h,
1699 &cs,
1700 &cfg,
1701 0,
1702 [0.0, 40.0, 0.0],
1703 [1.0, 0.0, 0.0],
1704 [0.0, 1.0, 0.0],
1705 [0.0, 0.0, 1.0],
1706 0,
1707 );
1708 fb
1709 };
1710
1711 let before = render0(&book);
1712 assert!(
1713 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1714 "frame 0 is red"
1715 );
1716
1717 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1719 assert!(book.set_frame(0, replacement));
1720 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1721 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1722
1723 let after = render0(&book);
1724 assert!(
1725 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1726 "frame 0 now green"
1727 );
1728 assert_ne!(before, after);
1729 }
1730
1731 #[test]
1734 fn cube_sprite_renders() {
1735 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_C0_40_20));
1736 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1737 let (w, h) = (64u32, 64u32);
1738 let n = (w * h) as usize;
1739 let mut fb = vec![0u32; n];
1740 let mut zb = vec![f32::INFINITY; n];
1741 let cam = cam_looking_y();
1742 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1743 let wrote = draw_sprite_dda(
1744 &mut fb,
1745 &mut zb,
1746 w as usize,
1747 w,
1748 h,
1749 &cs,
1750 &settings(w, h),
1751 &sprite,
1752 );
1753
1754 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1755 let centre = (h / 2 * w + w / 2) as usize;
1756 assert_eq!(
1757 fb[centre] & 0x00ff_ffff,
1758 0x00_C0_40_20,
1759 "got {:08x}",
1760 fb[centre]
1761 );
1762 assert!(
1764 (zb[centre] - 36.0).abs() < 3.0,
1765 "centre depth {} not ≈ 36",
1766 zb[centre]
1767 );
1768 }
1769
1770 #[test]
1775 fn zero_high_byte_sprite_not_black() {
1776 let kv6 = Kv6::solid_cube(8, VoxColor(0x00_C0_40_20));
1777 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1778 let (w, h) = (64u32, 64u32);
1779 let n = (w * h) as usize;
1780 let mut fb = vec![0u32; n];
1781 let mut zb = vec![f32::INFINITY; n];
1782 let cam = cam_looking_y();
1783 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1784 let wrote = draw_sprite_dda(
1785 &mut fb,
1786 &mut zb,
1787 w as usize,
1788 w,
1789 h,
1790 &cs,
1791 &settings(w, h),
1792 &sprite,
1793 );
1794 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1795 let centre = (h / 2 * w + w / 2) as usize;
1796 assert_eq!(
1797 fb[centre] & 0x00ff_ffff,
1798 0x00_C0_40_20,
1799 "zero-high-byte sprite rendered as {:08x} (black bug)",
1800 fb[centre]
1801 );
1802 }
1803
1804 #[test]
1807 fn sprite_respects_zbuffer() {
1808 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1809 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1810 let (w, h) = (32u32, 32u32);
1811 let n = (w * h) as usize;
1812 let cam = cam_looking_y();
1813 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1814 let centre = (h / 2 * w + w / 2) as usize;
1815
1816 let mut fb = vec![0u32; n];
1818 let mut zb = vec![f32::INFINITY; n];
1819 fb[centre] = 0x80_11_22_33;
1820 zb[centre] = 10.0;
1821 let _ = draw_sprite_dda(
1822 &mut fb,
1823 &mut zb,
1824 w as usize,
1825 w,
1826 h,
1827 &cs,
1828 &settings(w, h),
1829 &sprite,
1830 );
1831 assert_eq!(
1832 fb[centre], 0x80_11_22_33,
1833 "near terrain must occlude sprite"
1834 );
1835
1836 let mut fb2 = vec![0u32; n];
1838 let mut zb2 = vec![f32::INFINITY; n];
1839 fb2[centre] = 0x80_11_22_33;
1840 zb2[centre] = 100.0;
1841 let _ = draw_sprite_dda(
1842 &mut fb2,
1843 &mut zb2,
1844 w as usize,
1845 w,
1846 h,
1847 &cs,
1848 &settings(w, h),
1849 &sprite,
1850 );
1851 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1852 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1853 }
1854
1855 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1858 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1859 for py in 0..h {
1860 for px in 0..w {
1861 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1862 x0 = x0.min(px);
1863 y0 = y0.min(py);
1864 x1 = x1.max(px);
1865 y1 = y1.max(py);
1866 }
1867 }
1868 }
1869 (x0, y0, x1, y1)
1870 }
1871
1872 #[test]
1877 fn posed_basis_reorients_silhouette() {
1878 let kv6 = Kv6::solid_box(16, 4, 4, VoxColor(0x80_C0_40_20));
1881 let (w, h) = (64u32, 64u32);
1882 let n = (w * h) as usize;
1883 let cam = cam_looking_y();
1884 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1885
1886 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1888 let mut fb = vec![0u32; n];
1889 let mut zb = vec![f32::INFINITY; n];
1890 let _ = draw_sprite_dda(
1891 &mut fb,
1892 &mut zb,
1893 w as usize,
1894 w,
1895 h,
1896 &cs,
1897 &settings(w, h),
1898 &aa,
1899 );
1900 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1901 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1902 assert!(
1903 aa_wide > 4,
1904 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1905 );
1906
1907 let mut posed = aa.clone();
1910 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];
1914 let mut zb2 = vec![f32::INFINITY; n];
1915 let _ = draw_sprite_dda(
1916 &mut fb2,
1917 &mut zb2,
1918 w as usize,
1919 w,
1920 h,
1921 &cs,
1922 &settings(w, h),
1923 &posed,
1924 );
1925 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1926 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1927 assert!(
1928 posed_tall > 4,
1929 "posed box should be taller than wide (got h-w={posed_tall})"
1930 );
1931 }
1932
1933 #[test]
1936 fn degenerate_basis_draws_nothing() {
1937 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1938 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1939 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1941 let n = (w * h) as usize;
1942 let mut fb = vec![0u32; n];
1943 let mut zb = vec![f32::INFINITY; n];
1944 let cam = cam_looking_y();
1945 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1946 let wrote = draw_sprite_dda(
1947 &mut fb,
1948 &mut zb,
1949 w as usize,
1950 w,
1951 h,
1952 &cs,
1953 &settings(w, h),
1954 &sprite,
1955 );
1956 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1957 }
1958
1959 #[test]
1961 fn invisible_sprite_skipped() {
1962 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1963 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1964 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1965 let (w, h) = (32u32, 32u32);
1966 let n = (w * h) as usize;
1967 let mut fb = vec![0u32; n];
1968 let mut zb = vec![f32::INFINITY; n];
1969 let cam = cam_looking_y();
1970 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1971 let wrote = draw_sprite_dda(
1972 &mut fb,
1973 &mut zb,
1974 w as usize,
1975 w,
1976 h,
1977 &cs,
1978 &settings(w, h),
1979 &sprite,
1980 );
1981 assert_eq!(wrote, 0);
1982 }
1983
1984 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1990 let mut table = MaterialTable::new();
1991 table.set(1, mat);
1992 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
1993 let (w, h) = (64u32, 64u32);
1994 let n = (w * h) as usize;
1995 let mut fb = vec![bg; n];
1996 let mut zb = vec![zb_v; n];
1997 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1998 let sh = SpriteShade {
1999 materials: &table,
2000 lights: CpuLights::default(),
2001 material: 1,
2002 alpha_mul,
2003 tint: 0x00FF_FFFF,
2004 shadow: None,
2005 };
2006 let _ = draw_sprite_dense_shaded(
2007 &mut fb,
2008 &mut zb,
2009 w as usize,
2010 w,
2011 h,
2012 &cs,
2013 &settings(w, h),
2014 &dense,
2015 [0.0, 40.0, 0.0],
2016 [1.0, 0.0, 0.0],
2017 [0.0, 1.0, 0.0],
2018 [0.0, 0.0, 1.0],
2019 0,
2020 Some(sh),
2021 );
2022 (fb[(h / 2 * w + w / 2) as usize], fb)
2023 }
2024
2025 #[test]
2028 fn additive_sprite_brightens_background() {
2029 let bg = 0x80_20_20_20;
2030 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
2031 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
2032 assert!(
2033 cr > 0x20 && cg > 0x20 && cb >= 0x20,
2034 "centre {centre:08x} should be brighter than bg"
2035 );
2036 assert!(
2038 cr >= cg && cr >= cb,
2039 "additive of a red-dominant cube stays red-dominant"
2040 );
2041 }
2042
2043 #[test]
2046 fn alpha_blend_sprite_between_bg_and_color() {
2047 let bg = 0x80_20_20_20;
2048 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
2049 let cr = (centre >> 16) & 0xff;
2050 assert!(
2051 cr > 0x20,
2052 "blended red must rise above bg 0x20 (got {cr:02x})"
2053 );
2054 assert!(
2055 cr < 0xC0,
2056 "blended red must stay below opaque 0xC0 (got {cr:02x})"
2057 );
2058 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
2060 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
2061 }
2062
2063 #[test]
2066 fn alpha_mul_scales_opacity() {
2067 let bg = 0x80_20_20_20;
2068 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
2069 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
2070 let r_full = (full >> 16) & 0xff;
2071 let r_faded = (faded >> 16) & 0xff;
2072 assert!(
2074 r_full > r_faded,
2075 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
2076 );
2077 assert!(r_faded > 0x20, "even faded lifts above bg");
2078 }
2079
2080 #[test]
2084 fn opaque_shade_ctx_matches_plain_path() {
2085 let table = MaterialTable::new();
2086 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2087 let (w, h) = (64u32, 64u32);
2088 let n = (w * h) as usize;
2089 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2090 let pose = (
2091 [0.0, 40.0, 0.0],
2092 [1.0, 0.0, 0.0],
2093 [0.0, 1.0, 0.0],
2094 [0.0, 0.0, 1.0],
2095 );
2096
2097 let mut fb_plain = vec![0u32; n];
2098 let mut zb_plain = vec![f32::INFINITY; n];
2099 let _ = draw_sprite_dense(
2100 &mut fb_plain,
2101 &mut zb_plain,
2102 w as usize,
2103 w,
2104 h,
2105 &cs,
2106 &settings(w, h),
2107 &dense,
2108 pose.0,
2109 pose.1,
2110 pose.2,
2111 pose.3,
2112 0,
2113 );
2114
2115 let mut fb_sh = vec![0u32; n];
2116 let mut zb_sh = vec![f32::INFINITY; n];
2117 let sh = SpriteShade {
2118 materials: &table,
2119 lights: CpuLights::default(),
2120 material: 0, alpha_mul: 255,
2122 tint: 0x00FF_FFFF,
2123 shadow: None,
2124 };
2125 let _ = draw_sprite_dense_shaded(
2126 &mut fb_sh,
2127 &mut zb_sh,
2128 w as usize,
2129 w,
2130 h,
2131 &cs,
2132 &settings(w, h),
2133 &dense,
2134 pose.0,
2135 pose.1,
2136 pose.2,
2137 pose.3,
2138 0,
2139 Some(sh),
2140 );
2141
2142 assert_eq!(
2143 fb_plain, fb_sh,
2144 "opaque shade-ctx must match the plain path bit-for-bit"
2145 );
2146 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2147 }
2148
2149 #[test]
2153 fn translucent_sprite_occluded_by_near_terrain() {
2154 let bg = 0x80_20_20_20;
2155 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2156 assert_eq!(
2157 centre, bg,
2158 "near terrain (z=5) must occlude the sprite at y≈36"
2159 );
2160 }
2161
2162 #[test]
2168 fn per_span_thickness_independent() {
2169 fn centre(ysiz: u32) -> u32 {
2170 let mut table = MaterialTable::new();
2171 table.set(1, Material::alpha_blend(128));
2172 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, VoxColor(0x80_C0_40_20)));
2173 let (w, h) = (64u32, 64u32);
2174 let n = (w * h) as usize;
2175 let mut fb = vec![0x80_10_10_10u32; n];
2176 let mut zb = vec![f32::INFINITY; n];
2177 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2178 let sh = SpriteShade {
2179 materials: &table,
2180 lights: CpuLights::default(),
2181 material: 1,
2182 alpha_mul: 255,
2183 tint: 0x00FF_FFFF,
2184 shadow: None,
2185 };
2186 let _ = draw_sprite_dense_shaded(
2187 &mut fb,
2188 &mut zb,
2189 w as usize,
2190 w,
2191 h,
2192 &cs,
2193 &settings(w, h),
2194 &dense,
2195 [0.0, 40.0, 0.0],
2196 [1.0, 0.0, 0.0],
2197 [0.0, 1.0, 0.0],
2198 [0.0, 0.0, 1.0],
2199 0,
2200 Some(sh),
2201 );
2202 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2203 }
2204 assert_eq!(
2208 centre(1),
2209 centre(2),
2210 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2211 );
2212 }
2213
2214 #[test]
2219 fn volumetric_thickness_deepens_opacity() {
2220 fn red_at(depth: u32) -> u32 {
2223 let mut table = MaterialTable::new();
2224 table.set(1, Material::volumetric(128));
2225 let kv6 = Kv6::from_fn_keep_interior(
2230 8,
2231 depth,
2232 8,
2233 |_, _, _| Some(VoxColor(0x80_C0_20_20)),
2234 |_| true,
2235 );
2236 let dense = SpriteDense::from_kv6(&kv6);
2237 let (w, h) = (64u32, 64u32);
2238 let n = (w * h) as usize;
2239 let mut fb = vec![0x80_10_10_10u32; n];
2240 let mut zb = vec![f32::INFINITY; n];
2241 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2242 let sh = SpriteShade {
2243 materials: &table,
2244 lights: CpuLights::default(),
2245 material: 1,
2246 alpha_mul: 255,
2247 tint: 0x00FF_FFFF,
2248 shadow: None,
2249 };
2250 let _ = draw_sprite_dense_shaded(
2251 &mut fb,
2252 &mut zb,
2253 w as usize,
2254 w,
2255 h,
2256 &cs,
2257 &settings(w, h),
2258 &dense,
2259 [0.0, 40.0, 0.0],
2260 [1.0, 0.0, 0.0],
2261 [0.0, 1.0, 0.0],
2262 [0.0, 0.0, 1.0],
2263 0,
2264 Some(sh),
2265 );
2266 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2267 }
2268 let shallow = red_at(1);
2269 let deep = red_at(12);
2270 assert!(
2273 shallow > 0x10,
2274 "even a 1-deep volume tints (got {shallow:02x})"
2275 );
2276 assert!(
2277 deep > shallow,
2278 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2279 );
2280 }
2281
2282 #[test]
2288 fn voxel_world_size_matches_scaled_basis() {
2289 use crate::dda::WorldOccluder;
2290 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_40_C0_40));
2291 let scaled = SpriteDense::from_kv6(&kv6).with_voxel_world_size(2.0);
2292 let unit = SpriteDense::from_kv6(&kv6);
2293
2294 let (w, h) = (64u32, 64u32);
2295 let n = (w * h) as usize;
2296 let draw = |dense: &SpriteDense, basis: f32| {
2297 let mut fb = vec![0x80_10_10_10u32; n];
2298 let mut zb = vec![f32::INFINITY; n];
2299 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2300 let written = draw_sprite_dense(
2301 &mut fb,
2302 &mut zb,
2303 w as usize,
2304 w,
2305 h,
2306 &cs,
2307 &settings(w, h),
2308 dense,
2309 [0.0, 40.0, 0.0],
2310 [basis, 0.0, 0.0],
2311 [0.0, basis, 0.0],
2312 [0.0, 0.0, basis],
2313 0,
2314 );
2315 assert!(written > 0, "sprite must be visible");
2316 fb
2317 };
2318 assert_eq!(
2319 draw(&scaled, 1.0),
2320 draw(&unit, 2.0),
2321 "vws=2 @ unit basis == vws=1 @ 2x basis, pixel for pixel"
2322 );
2323
2324 let occludes = |dense: &SpriteDense, basis: f32, x: f64| {
2325 let mut occ = SpriteOccluder::new();
2326 occ.push(
2327 Arc::new(dense.clone()),
2328 [0.0, 40.0, 0.0],
2329 [basis, 0.0, 0.0],
2330 [0.0, basis, 0.0],
2331 [0.0, 0.0, basis],
2332 );
2333 occ.occluded_world([x, 0.0, 0.0], [0.0, 1.0, 0.0], 100.0)
2334 };
2335 assert!(occludes(&scaled, 1.0, 6.0));
2339 assert!(occludes(&unit, 2.0, 6.0));
2340 assert!(!occludes(&unit, 1.0, 6.0), "unscaled cube ends at 4");
2341 }
2342
2343 #[test]
2348 fn sprite_occluder_blocks_ray_through_volume() {
2349 use crate::dda::WorldOccluder;
2350 let dense = Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2353 8,
2354 VoxColor(0x80_FF_FF_FF),
2355 )));
2356 let mut occ = SpriteOccluder::new();
2357 occ.push(
2358 dense,
2359 [0.0, 0.0, 0.0],
2360 [1.0, 0.0, 0.0],
2361 [0.0, 1.0, 0.0],
2362 [0.0, 0.0, 1.0],
2363 );
2364 assert!(!occ.is_empty());
2365 assert!(
2367 occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2368 "a ray through the cube must be occluded"
2369 );
2370 assert!(
2372 !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2373 "a ray missing the cube must not be occluded"
2374 );
2375 assert!(
2377 !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2378 "max_t shorter than the distance to the cube ⇒ unoccluded"
2379 );
2380 }
2381
2382 #[test]
2387 fn sprite_receives_hard_shadow() {
2388 let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2394 let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2395 (dx * dx + dy * dy + dz * dz <= 49).then_some(VoxColor(0x80_C0_C0_C0))
2396 }));
2397 let mut occ = SpriteOccluder::new();
2398 occ.push(
2399 Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2400 8,
2401 VoxColor(0x80_FF_FF_FF),
2402 ))),
2403 [0.0, 25.0, 0.0],
2404 [1.0, 0.0, 0.0],
2405 [0.0, 1.0, 0.0],
2406 [0.0, 0.0, 1.0],
2407 );
2408 let table = MaterialTable::new();
2409 let base = CpuLights {
2410 enabled: true,
2411 sun: true,
2412 sun_dir: [0.0, -1.0, 0.0], sun_color: [1.0; 3],
2414 sun_intensity: 1.0,
2415 sun_casts_shadow: true,
2416 ambient: [0.3; 3],
2417 shadow_strength: 0.85,
2418 shadow_bias: 1.5,
2419 shadow_max_dist: 128.0,
2420 ..CpuLights::default()
2421 };
2422 let (w, h) = (64u32, 64u32);
2423 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2424 let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2425 let n = (w * h) as usize;
2426 let mut fb = vec![0u32; n];
2427 let mut zb = vec![f32::INFINITY; n];
2428 let sh = SpriteShade {
2429 materials: &table,
2430 lights: base,
2431 material: 0,
2432 alpha_mul: 255,
2433 tint: 0x00FF_FFFF,
2434 shadow,
2435 };
2436 let _ = draw_sprite_dense_shaded(
2437 &mut fb,
2438 &mut zb,
2439 w as usize,
2440 w,
2441 h,
2442 &cs,
2443 &settings(w, h),
2444 &target,
2445 [0.0, 40.0, 0.0],
2446 [1.0, 0.0, 0.0],
2447 [0.0, 1.0, 0.0],
2448 [0.0, 0.0, 1.0],
2449 0,
2450 Some(sh),
2451 );
2452 fb.iter()
2453 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2454 .sum()
2455 };
2456 let lit = sum_lum(None);
2457 let shadowed = sum_lum(Some(&occ));
2458 assert!(
2459 shadowed < lit,
2460 "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2461 );
2462 }
2463
2464 #[test]
2467 fn sprite_rgb_tint_recolours() {
2468 let table = MaterialTable::new();
2469 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF)));
2470 let (w, h) = (64u32, 64u32);
2471 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2472 let centre = |tint: u32| -> u32 {
2473 let n = (w * h) as usize;
2474 let mut fb = vec![0u32; n];
2475 let mut zb = vec![f32::INFINITY; n];
2476 let sh = SpriteShade {
2477 materials: &table,
2478 lights: CpuLights::default(),
2479 material: 0,
2480 alpha_mul: 255,
2481 tint,
2482 shadow: None,
2483 };
2484 let _ = draw_sprite_dense_shaded(
2485 &mut fb,
2486 &mut zb,
2487 w as usize,
2488 w,
2489 h,
2490 &cs,
2491 &settings(w, h),
2492 &dense,
2493 [0.0, 40.0, 0.0],
2494 [1.0, 0.0, 0.0],
2495 [0.0, 1.0, 0.0],
2496 [0.0, 0.0, 1.0],
2497 0,
2498 Some(sh),
2499 );
2500 fb[(h / 2 * w + w / 2) as usize]
2501 };
2502 let r = |p: u32| (p >> 16) & 0xff;
2503 let g = |p: u32| (p >> 8) & 0xff;
2504 let b = |p: u32| p & 0xff;
2505 let white = centre(0x00FF_FFFF);
2506 let red = centre(0x00FF_0000);
2507 assert!(
2508 g(white) > 180 && b(white) > 180 && r(white) > 180,
2509 "white tint must be a no-op: {white:#08x}"
2510 );
2511 assert!(
2512 r(red) > 180 && g(red) < 20 && b(red) < 20,
2513 "red tint zeroes green/blue, keeps red: {red:#08x}"
2514 );
2515 }
2516
2517 #[test]
2522 fn translucent_sprite_layers_are_lit() {
2523 fn center_red(lights: CpuLights) -> u32 {
2524 let mut table = MaterialTable::new();
2525 table.set(1, Material::alpha_blend(160));
2526 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, VoxColor(0x80_E0_30_30)));
2527 let (w, h) = (64u32, 64u32);
2528 let n = (w * h) as usize;
2529 let mut fb = vec![0x80_10_10_10u32; n];
2530 let mut zb = vec![f32::INFINITY; n];
2531 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2532 let sh = SpriteShade {
2533 materials: &table,
2534 lights,
2535 material: 1,
2536 alpha_mul: 255,
2537 tint: 0x00FF_FFFF,
2538 shadow: None,
2539 };
2540 let _ = draw_sprite_dense_shaded(
2541 &mut fb,
2542 &mut zb,
2543 w as usize,
2544 w,
2545 h,
2546 &cs,
2547 &settings(w, h),
2548 &dense,
2549 [0.0, 40.0, 0.0],
2550 [1.0, 0.0, 0.0],
2551 [0.0, 1.0, 0.0],
2552 [0.0, 0.0, 1.0],
2553 0,
2554 Some(sh),
2555 );
2556 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2557 }
2558 let baked = center_red(CpuLights::default()); let dim = center_red(CpuLights {
2560 enabled: true,
2561 ambient: [0.3; 3], ..CpuLights::default()
2563 });
2564 assert!(
2565 dim < baked,
2566 "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2567 );
2568 }
2569
2570 #[test]
2575 fn translucent_sprite_tints_opaque_sprite_behind() {
2576 let mut table = MaterialTable::new();
2577 table.set(1, Material::alpha_blend(128));
2578 let (w, h) = (64u32, 64u32);
2579 let n = (w * h) as usize;
2580 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
2582 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2583 let cfg = settings(w, h);
2584 let id = [1.0, 0.0, 0.0];
2585 let up = [0.0, 1.0, 0.0];
2586 let fw = [0.0, 0.0, 1.0];
2587 let centre = (h / 2 * w + w / 2) as usize;
2588
2589 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_FF_00_00)));
2591 let sh_op = SpriteShade {
2592 materials: &table,
2593 lights: CpuLights::default(),
2594 material: 0,
2595 alpha_mul: 255,
2596 tint: 0x00FF_FFFF,
2597 shadow: None,
2598 };
2599 let _ = draw_sprite_dense_shaded(
2600 &mut fb,
2601 &mut zb,
2602 w as usize,
2603 w,
2604 h,
2605 &cs,
2606 &cfg,
2607 &backdrop,
2608 [0.0, 80.0, 0.0],
2609 id,
2610 up,
2611 fw,
2612 0,
2613 Some(sh_op),
2614 );
2615 let after_backdrop = fb[centre];
2616 assert_eq!(
2617 after_backdrop & 0x00ff_ffff,
2618 0x00FF_0000,
2619 "backdrop red must be drawn first"
2620 );
2621
2622 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_00_FF_FF)));
2624 let sh_gl = SpriteShade {
2625 materials: &table,
2626 lights: CpuLights::default(),
2627 material: 1,
2628 alpha_mul: 255,
2629 tint: 0x00FF_FFFF,
2630 shadow: None,
2631 };
2632 let wrote = draw_sprite_dense_shaded(
2633 &mut fb,
2634 &mut zb,
2635 w as usize,
2636 w,
2637 h,
2638 &cs,
2639 &cfg,
2640 &glass,
2641 [0.0, 40.0, 0.0],
2642 id,
2643 up,
2644 fw,
2645 0,
2646 Some(sh_gl),
2647 );
2648 let _ = wrote;
2649 let after_glass = fb[centre];
2650 assert_ne!(
2651 after_glass, after_backdrop,
2652 "glass must tint the backdrop (composite over it)"
2653 );
2654 assert!(
2656 (after_glass >> 16) & 0xff < 0xFF,
2657 "glass should reduce the backdrop's red (got {after_glass:08x})"
2658 );
2659 }
2660
2661 #[test]
2664 fn from_kv6_with_materials_classifies_by_color() {
2665 let col = VoxColor(0x80_AA_BB_CC);
2666 let kv6 = Kv6::solid_cube(6, col);
2667 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
2668 assert_eq!(
2669 dense.mat.len(),
2670 dense.col.len(),
2671 "per-voxel mat array sized"
2672 );
2673 let mut solids = 0;
2674 for idx in 0..dense.occ.len() {
2675 if dense.occ[idx] {
2676 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2677 solids += 1;
2678 }
2679 }
2680 assert!(solids > 0, "cube has solid voxels");
2681 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x0012_3456), 5)]);
2683 assert!(
2684 dense0.mat.iter().all(|&m| m == 0),
2685 "unmapped colour → material 0"
2686 );
2687 }
2688
2689 #[test]
2694 fn per_voxel_material_matches_uniform_when_homogeneous() {
2695 let mut table = MaterialTable::new();
2696 table.set(1, Material::alpha_blend(120));
2697 let col = VoxColor(0x80_30_A0_F0);
2698 let kv6 = Kv6::solid_cube(10, col);
2699 let (w, h) = (64u32, 64u32);
2700 let n = (w * h) as usize;
2701 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2702 let cfg = settings(w, h);
2703 let (pos, s, hh, f) = (
2704 [0.0, 40.0, 0.0],
2705 [1.0, 0.0, 0.0],
2706 [0.0, 1.0, 0.0],
2707 [0.0, 0.0, 1.0],
2708 );
2709 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2710 let mut fb = vec![0x80_10_10_10u32; n];
2711 let mut zb = vec![f32::INFINITY; n];
2712 let sh = SpriteShade {
2713 materials: &table,
2714 lights: CpuLights::default(),
2715 material,
2716 alpha_mul: 255,
2717 tint: 0x00FF_FFFF,
2718 shadow: None,
2719 };
2720 let _ = draw_sprite_dense_shaded(
2721 &mut fb,
2722 &mut zb,
2723 w as usize,
2724 w,
2725 h,
2726 &cs,
2727 &cfg,
2728 dense,
2729 pos,
2730 s,
2731 hh,
2732 f,
2733 0,
2734 Some(sh),
2735 );
2736 fb
2737 };
2738 let pv = render(
2741 &SpriteDense::from_kv6_with_materials(&kv6, &[(col.rgb_part(), 1)]),
2742 0,
2743 );
2744 let un = render(&SpriteDense::from_kv6(&kv6), 1);
2746 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2747 let centre = (h / 2 * w + w / 2) as usize;
2749 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2750 }
2751
2752 #[test]
2757 fn clip_flipbook_with_materials_classifies_every_frame() {
2758 let dims = [6u32, 6, 6];
2759 let glass = Rgb(0x00AA_BBCC);
2760 let glass_lit = 0x80AA_BBCC;
2761 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2763 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2764 let clip = VoxelClip::from_frames(
2765 dims,
2766 [3.0, 3.0, 3.0],
2767 1.0,
2768 LoopMode::Loop,
2769 &[f0, f1],
2770 &[],
2771 33,
2772 0,
2773 );
2774 let decoded = clip.decode().expect("decode");
2775
2776 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2777 assert_eq!(book.frame_count(), 2);
2778 for fr in 0..2 {
2779 let dense = book.frame(fr).expect("frame in range");
2780 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2781 let mut solids = 0;
2782 for idx in 0..dense.occ.len() {
2783 if dense.occ[idx] {
2784 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2785 solids += 1;
2786 }
2787 }
2788 assert!(solids > 0, "frame {fr} has solid voxels");
2789 }
2790
2791 let plain = ClipFlipbook::from_decoded(&decoded);
2793 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2794 for fr in 0..2 {
2795 assert!(plain.frame(fr).unwrap().mat.is_empty());
2796 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2797 }
2798 }
2799}