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, emissive_shade, intersect_aabb, min_axis, pixel_ray, shade, shade_dynamic,
36 CpuLights, ShadowTester, 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], emissive: u8| -> u32 {
707 if emissive > 0 {
712 return tint_packed(emissive_shade(dense.col[idx], emissive), tint);
713 }
714 if !lights.enabled {
715 return tint_packed(shade(dense.col[idx], 0), tint);
716 }
717 let to_world = |v: [f32; 3]| {
718 [
719 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
720 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
721 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
722 ]
723 };
724 let n_world = to_world(n_local);
725 let rel = [
726 cell[0] as f32 + 0.5 - dense.pivot[0],
727 cell[1] as f32 + 0.5 - dense.pivot[1],
728 cell[2] as f32 + 0.5 - dense.pivot[2],
729 ];
730 let wc = to_world(rel);
731 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
732 let albedo = [
733 ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
734 ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
735 (dense.col[idx] & 0xff) as f32 / 255.0,
736 ];
737 let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
738 tint_packed(
739 shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
740 tint,
741 )
742 };
743
744 for _ in 0..max_steps {
745 if cell[0] < 0
746 || cell[0] >= dense.dims[0]
747 || cell[1] < 0
748 || cell[1] >= dense.dims[1]
749 || cell[2] < 0
750 || cell[2] >= dense.dims[2]
751 || t_curr > t1
752 {
753 break;
754 }
755 let depth = t_curr * fwd_dot;
758 if depth >= max_t {
759 break;
760 }
761 let exit_axis = min_axis(t_max);
764 let t_exit = t_max[exit_axis];
765 let idx = dense.idx_of(cell);
766 let solid_here = dense.occ[idx];
767 if solid_here && depth >= NEAR_Z {
768 let mat_id = if dense.mat.is_empty() {
769 shade_ctx.material
770 } else {
771 dense.mat[idx]
772 };
773 let m = shade_ctx.materials.get(mat_id);
774 if m.is_opaque() {
775 acc.opaque = Some((shade_layer(idx, cell, normal, m.emissive), t_curr));
776 touched = true;
777 break;
778 }
779 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
780 if m.mode == BlendMode::Volumetric {
781 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
785 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
786 let lit = rgb_to_f32(shade_layer(idx, cell, normal, m.emissive));
787 acc.rgb[0] += acc.trans * eff_a * lit[0];
788 acc.rgb[1] += acc.trans * eff_a * lit[1];
789 acc.rgb[2] += acc.trans * eff_a * lit[2];
790 acc.trans *= 1.0 - eff_a;
791 touched = true;
792 prev_mat = mat_id;
793 if acc.trans < 1.0 / 256.0 {
794 break;
795 }
796 } else if !prev_solid || mat_id != prev_mat {
797 let lit = rgb_to_f32(shade_layer(idx, cell, normal, m.emissive));
800 acc.rgb[0] += acc.trans * a * lit[0];
801 acc.rgb[1] += acc.trans * a * lit[1];
802 acc.rgb[2] += acc.trans * a * lit[2];
803 if m.mode == BlendMode::AlphaBlend {
804 acc.trans *= 1.0 - a; }
806 touched = true;
807 prev_mat = mat_id;
808 if acc.trans < 1.0 / 256.0 {
809 break;
810 }
811 }
812 }
813 prev_solid = solid_here;
814 t_curr = t_exit;
815 cell[exit_axis] += step[exit_axis];
816 t_max[exit_axis] += t_delta[exit_axis];
817 normal = [0.0; 3];
818 normal[exit_axis] = -(step[exit_axis] as f32);
819 }
820
821 touched.then_some(acc)
822}
823
824#[allow(
834 clippy::too_many_arguments,
835 clippy::cast_possible_truncation,
836 clippy::cast_sign_loss
837)]
838#[must_use]
839pub fn draw_sprite_dda(
840 fb: &mut [u32],
841 zb: &mut [f32],
842 pitch_pixels: usize,
843 width: u32,
844 height: u32,
845 cam: &CameraState,
846 settings: &OpticastSettings,
847 sprite: &Sprite,
848) -> u32 {
849 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
850 return 0;
851 }
852 draw_sprite_dda_shaded(
853 fb,
854 zb,
855 pitch_pixels,
856 width,
857 height,
858 cam,
859 settings,
860 sprite,
861 None,
862 )
863}
864
865#[allow(clippy::too_many_arguments)]
870#[must_use]
871pub fn draw_sprite_dda_shaded(
872 fb: &mut [u32],
873 zb: &mut [f32],
874 pitch_pixels: usize,
875 width: u32,
876 height: u32,
877 cam: &CameraState,
878 settings: &OpticastSettings,
879 sprite: &Sprite,
880 shade_ctx: Option<SpriteShade>,
881) -> u32 {
882 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
883 return 0;
884 }
885 let dense = if sprite.material_map.is_empty() {
890 SpriteDense::from_kv6(&sprite.kv6)
891 } else {
892 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
893 };
894 draw_sprite_dense_shaded(
895 fb,
896 zb,
897 pitch_pixels,
898 width,
899 height,
900 cam,
901 settings,
902 &dense,
903 sprite.p,
904 sprite.s,
905 sprite.h,
906 sprite.f,
907 sprite.flags,
908 shade_ctx,
909 )
910}
911
912#[allow(clippy::too_many_arguments)]
921#[must_use]
922pub fn draw_sprite_dense(
923 fb: &mut [u32],
924 zb: &mut [f32],
925 pitch_pixels: usize,
926 width: u32,
927 height: u32,
928 cam: &CameraState,
929 settings: &OpticastSettings,
930 dense: &SpriteDense,
931 pos: [f32; 3],
932 s: [f32; 3],
933 h: [f32; 3],
934 f: [f32; 3],
935 flags: u32,
936) -> u32 {
937 draw_sprite_dense_shaded(
938 fb,
939 zb,
940 pitch_pixels,
941 width,
942 height,
943 cam,
944 settings,
945 dense,
946 pos,
947 s,
948 h,
949 f,
950 flags,
951 None,
952 )
953}
954
955#[allow(
967 clippy::too_many_arguments,
968 clippy::cast_possible_truncation,
969 clippy::cast_sign_loss
970)]
971#[must_use]
972pub fn draw_sprite_dense_shaded(
973 fb: &mut [u32],
974 zb: &mut [f32],
975 pitch_pixels: usize,
976 width: u32,
977 height: u32,
978 cam: &CameraState,
979 settings: &OpticastSettings,
980 dense: &SpriteDense,
981 pos: [f32; 3],
982 s: [f32; 3],
983 h: [f32; 3],
984 f: [f32; 3],
985 flags: u32,
986 shade_ctx: Option<SpriteShade>,
987) -> u32 {
988 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
989 return 0;
990 }
991 let sc = dense.voxel_world_size;
996 let (s, h, f) = (bb_scale3(s, sc), bb_scale3(h, sc), bb_scale3(f, sc));
997 let Some(minv) = invert_basis(s, h, f) else {
998 return 0;
999 };
1000 let pivot = dense.pivot;
1001 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
1002 let light_mode = SpriteLightMode::from_flags(flags);
1004
1005 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
1007 return 0;
1008 };
1009
1010 let layers =
1016 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
1017
1018 debug_assert_eq!(fb.len(), zb.len());
1019 let target = RasterTarget::new(fb, zb);
1020 let draw_row = |py: u32| -> u32 {
1024 let mut written = 0u32;
1025 let row = py as usize * pitch_pixels;
1026 for px in rect.0..rect.2 {
1027 let (origin, dir) = pixel_ray(cam, settings, px, py);
1028 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
1030 let ol = mat_apply(&minv, rel);
1031 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
1032 let dir_local = mat_apply(&minv, dir);
1033 let fwd_dot =
1034 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
1035 let idx = row + px as usize;
1036
1037 if let Some(shade_ctx) = layers {
1038 if fwd_dot <= 1e-6 {
1040 continue;
1041 }
1042 let max_t = if no_z {
1047 f32::INFINITY
1048 } else {
1049 unsafe { target.read_depth(idx) }
1050 };
1051 let Some(acc) = cast_local_layers(
1052 dense,
1053 origin_local,
1054 dir_local,
1055 fwd_dot,
1056 max_t,
1057 shade_ctx,
1058 s,
1059 h,
1060 f,
1061 pos,
1062 light_mode,
1063 ) else {
1064 continue;
1065 };
1066 let wrote = unsafe {
1068 match acc.opaque {
1069 Some((bg_color, t)) => {
1070 let bg = rgb_to_f32(bg_color);
1073 let out = f32_to_rgb([
1074 acc.rgb[0] + acc.trans * bg[0],
1075 acc.rgb[1] + acc.trans * bg[1],
1076 acc.rgb[2] + acc.trans * bg[2],
1077 ]);
1078 let depth = t * fwd_dot;
1079 if no_z {
1080 target.write_color(idx, out);
1081 target.write_depth(idx, depth);
1082 true
1083 } else {
1084 target.z_test_write(idx, out, depth)
1085 }
1086 }
1087 None => {
1088 let bg = rgb_to_f32(target.read_color(idx));
1093 let out = f32_to_rgb([
1094 acc.rgb[0] + acc.trans * bg[0],
1095 acc.rgb[1] + acc.trans * bg[1],
1096 acc.rgb[2] + acc.trans * bg[2],
1097 ]);
1098 target.write_color(idx, out);
1099 true
1100 }
1101 }
1102 };
1103 written += u32::from(wrote);
1104 } else {
1105 let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1107 else {
1108 continue;
1109 };
1110 let depth = t * fwd_dot;
1111 if depth < NEAR_Z {
1112 continue;
1113 }
1114 let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1119 let emissive = shade_ctx.map_or(0, |sc| {
1124 let mat_id = if dense.mat.is_empty() {
1125 sc.material
1126 } else {
1127 dense.mat[dense.idx_of(cell)]
1128 };
1129 sc.materials.get(mat_id).emissive
1130 });
1131 let lit = if emissive > 0 {
1132 emissive_shade(color, emissive)
1133 } else if dl.enabled {
1134 let to_world = |v: [f32; 3]| {
1135 [
1136 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1137 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1138 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1139 ]
1140 };
1141 let n_world = to_world(n_local);
1142 let rel = [
1143 cell[0] as f32 + 0.5 - pivot[0],
1144 cell[1] as f32 + 0.5 - pivot[1],
1145 cell[2] as f32 + 0.5 - pivot[2],
1146 ];
1147 let wc = to_world(rel);
1148 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1149 let albedo = [
1150 ((color >> 16) & 0xff) as f32 / 255.0,
1151 ((color >> 8) & 0xff) as f32 / 255.0,
1152 (color & 0xff) as f32 / 255.0,
1153 ];
1154 let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1158 ctx: WorldShadowCtx::identity(occ),
1159 });
1160 let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1161 shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1162 } else {
1163 shade(color, 0)
1164 };
1165 let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1167 let wrote = unsafe {
1170 if no_z {
1171 target.write_color(idx, lit);
1172 target.write_depth(idx, depth);
1173 true
1174 } else {
1175 target.z_test_write(idx, lit, depth)
1176 }
1177 };
1178 written += u32::from(wrote);
1179 }
1180 }
1181 written
1182 };
1183 let rows = rect.3.saturating_sub(rect.1) as usize;
1188 let cols = rect.2.saturating_sub(rect.0) as usize;
1189 const SPRITE_PAR_MIN_PIXELS: usize = 64 * 64;
1190 if rows >= 2 && rows * cols >= SPRITE_PAR_MIN_PIXELS {
1191 use rayon::prelude::*;
1192 (rect.1..rect.3).into_par_iter().map(draw_row).sum()
1193 } else {
1194 (rect.1..rect.3).map(draw_row).sum()
1195 }
1196}
1197
1198#[allow(
1202 clippy::cast_possible_truncation,
1203 clippy::cast_sign_loss,
1204 clippy::cast_precision_loss
1205)]
1206fn project_screen_rect(
1207 dense: &SpriteDense,
1208 pos: [f32; 3],
1209 s: [f32; 3],
1210 h: [f32; 3],
1211 f: [f32; 3],
1212 cam: &CameraState,
1213 settings: &OpticastSettings,
1214 width: u32,
1215 height: u32,
1216) -> Option<(u32, u32, u32, u32)> {
1217 let (xs, ys, zs) = (
1218 dense.dims[0] as f32,
1219 dense.dims[1] as f32,
1220 dense.dims[2] as f32,
1221 );
1222 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1223 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1224 let mut all_front = true;
1225 for &cx in &[0.0, xs] {
1226 for &cy in &[0.0, ys] {
1227 for &cz in &[0.0, zs] {
1228 let lx = cx - xp;
1230 let ly = cy - yp;
1231 let lz = cz - zp;
1232 let world = [
1233 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1234 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1235 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1236 ];
1237 let rel = [
1238 world[0] - cam.pos[0],
1239 world[1] - cam.pos[1],
1240 world[2] - cam.pos[2],
1241 ];
1242 let cz_cam =
1243 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1244 if cz_cam < NEAR_Z {
1245 all_front = false;
1246 continue;
1247 }
1248 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1249 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1250 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1251 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1252 x0 = x0.min(sx);
1253 y0 = y0.min(sy);
1254 x1 = x1.max(sx);
1255 y1 = y1.max(sy);
1256 }
1257 }
1258 }
1259 let (w, h) = (width as f32, height as f32);
1260 let (rx0, ry0, rx1, ry1) = if all_front {
1261 (
1262 (x0 - 1.0).max(0.0),
1263 (y0 - 1.0).max(0.0),
1264 (x1 + 1.0).min(w),
1265 (y1 + 1.0).min(h),
1266 )
1267 } else {
1268 (0.0, 0.0, w, h)
1270 };
1271 if rx0 >= rx1 || ry0 >= ry1 {
1272 return None;
1273 }
1274 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1275}
1276
1277pub struct ClipFlipbook {
1284 frames: Vec<Arc<SpriteDense>>,
1287}
1288
1289impl ClipFlipbook {
1290 #[must_use]
1293 pub fn empty() -> Self {
1294 Self { frames: Vec::new() }
1295 }
1296
1297 #[must_use]
1299 pub fn from_decoded(clip: &DecodedClip) -> Self {
1300 Self::from_decoded_with_materials(clip, &[])
1301 }
1302
1303 #[must_use]
1309 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(Rgb, u8)]) -> Self {
1310 let frames = clip
1311 .frames
1312 .iter()
1313 .map(|frame| {
1314 Arc::new(
1315 SpriteDense::from_voxel_frame_with_materials(
1316 frame,
1317 clip.dims,
1318 clip.pivot,
1319 material_map,
1320 )
1321 .with_voxel_world_size(clip.voxel_world_size),
1322 )
1323 })
1324 .collect();
1325 Self { frames }
1326 }
1327
1328 #[must_use]
1331 pub fn frame_count(&self) -> usize {
1332 self.frames.len()
1333 }
1334
1335 #[must_use]
1337 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1338 self.frames.get(frame).map(Arc::as_ref)
1339 }
1340
1341 #[must_use]
1344 pub fn frame_arc(&self, frame: usize) -> Option<Arc<SpriteDense>> {
1345 self.frames.get(frame).cloned()
1346 }
1347
1348 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1352 match self.frames.get_mut(frame) {
1353 Some(slot) => {
1354 *slot = Arc::new(dense);
1355 true
1356 }
1357 None => false,
1358 }
1359 }
1360
1361 #[allow(clippy::too_many_arguments)]
1365 #[must_use]
1366 pub fn draw_frame(
1367 &self,
1368 fb: &mut [u32],
1369 zb: &mut [f32],
1370 pitch_pixels: usize,
1371 width: u32,
1372 height: u32,
1373 cam: &CameraState,
1374 settings: &OpticastSettings,
1375 frame: usize,
1376 pos: [f32; 3],
1377 s: [f32; 3],
1378 h: [f32; 3],
1379 f: [f32; 3],
1380 flags: u32,
1381 ) -> u32 {
1382 self.draw_frame_shaded(
1383 fb,
1384 zb,
1385 pitch_pixels,
1386 width,
1387 height,
1388 cam,
1389 settings,
1390 frame,
1391 pos,
1392 s,
1393 h,
1394 f,
1395 flags,
1396 None,
1397 )
1398 }
1399
1400 #[allow(clippy::too_many_arguments)]
1405 #[must_use]
1406 pub fn draw_frame_shaded(
1407 &self,
1408 fb: &mut [u32],
1409 zb: &mut [f32],
1410 pitch_pixels: usize,
1411 width: u32,
1412 height: u32,
1413 cam: &CameraState,
1414 settings: &OpticastSettings,
1415 frame: usize,
1416 pos: [f32; 3],
1417 s: [f32; 3],
1418 h: [f32; 3],
1419 f: [f32; 3],
1420 flags: u32,
1421 shade_ctx: Option<SpriteShade>,
1422 ) -> u32 {
1423 let Some(dense) = self.frames.get(frame) else {
1424 return 0;
1425 };
1426 draw_sprite_dense_shaded(
1427 fb,
1428 zb,
1429 pitch_pixels,
1430 width,
1431 height,
1432 cam,
1433 settings,
1434 dense,
1435 pos,
1436 s,
1437 h,
1438 f,
1439 flags,
1440 shade_ctx,
1441 )
1442 }
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447 use super::*;
1448 use crate::camera_math;
1449 use crate::Camera;
1450 use roxlap_formats::kv6::Kv6;
1451 use roxlap_formats::material::{Material, MaterialTable};
1452 use roxlap_formats::VoxColor;
1453
1454 #[test]
1457 fn sprite_light_mode_world_up_and_ambient_only() {
1458 let lights = CpuLights {
1459 enabled: true,
1460 sun: true,
1461 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0, 1.0, 1.0],
1463 sun_intensity: 1.0,
1464 sun_casts_shadow: false,
1465 points: &[],
1466 ambient: [0.2, 0.2, 0.2],
1467 bands: 0,
1468 shadow_tint: [0.0; 3],
1469 shadow_strength: 0.0,
1470 shadow_bias: 0.0,
1471 shadow_max_dist: 0.0,
1472 };
1473 let a = [1.0, 1.0, 1.0];
1474 let c = [0.0, 0.0, 0.0];
1475 let g = |packed: u32| (packed >> 8) & 0xff; let up_n = [0.0, 0.0, -1.0];
1477 let side_n = [1.0, 0.0, 0.0];
1478 let face_up = g(shade_dynamic_mode(
1479 SpriteLightMode::FaceNormal,
1480 a,
1481 up_n,
1482 c,
1483 &lights,
1484 None,
1485 ));
1486 let face_side = g(shade_dynamic_mode(
1487 SpriteLightMode::FaceNormal,
1488 a,
1489 side_n,
1490 c,
1491 &lights,
1492 None,
1493 ));
1494 let amb = g(shade_dynamic_mode(
1495 SpriteLightMode::AmbientOnly,
1496 a,
1497 up_n,
1498 c,
1499 &lights,
1500 None,
1501 ));
1502 let world_up = g(shade_dynamic_mode(
1503 SpriteLightMode::WorldUp,
1504 a,
1505 side_n,
1506 c,
1507 &lights,
1508 None,
1509 ));
1510 assert!(
1511 face_up > face_side,
1512 "a sun-facing face is brighter than a side face"
1513 );
1514 assert!(amb < face_up, "ambient-only drops the sun term");
1515 assert_eq!(
1516 world_up, face_up,
1517 "world-up shades a side-facing billboard as if it faced up"
1518 );
1519 let full = g(shade_dynamic_mode(
1520 SpriteLightMode::FullBright,
1521 a,
1522 side_n,
1523 c,
1524 &lights,
1525 None,
1526 ));
1527 assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1530 assert!(full > amb, "full-bright glow is brighter than ambient-only");
1531 }
1532
1533 #[test]
1538 fn cast_local_reports_face_normal() {
1539 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| {
1541 (z >= 4).then_some(VoxColor(0x80_C0_40_20))
1542 });
1543 let dense = SpriteDense::from_kv6(&kv6);
1544 let (_c, _t, n, cell) =
1546 cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1547 assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1548 assert!(
1549 n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1550 "z-crossing face normal points back toward the ray (-z): {n:?}",
1551 );
1552 }
1553 use roxlap_formats::sprite::Sprite;
1554 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1555
1556 fn settings(w: u32, h: u32) -> OpticastSettings {
1557 OpticastSettings::for_oracle_framebuffer(w, h)
1558 }
1559
1560 fn cam_looking_y() -> Camera {
1562 Camera {
1563 pos: [0.0, 0.0, 0.0],
1564 right: [1.0, 0.0, 0.0],
1565 down: [0.0, 0.0, 1.0],
1566 forward: [0.0, 1.0, 0.0],
1567 }
1568 }
1569
1570 #[test]
1577 fn scaled_basis_scales_drawn_extent() {
1578 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, _| Some(VoxColor(0x80_C0_40_20)));
1579 let (w, h) = (64u32, 64u32);
1580 let n = (w * h) as usize;
1581 let cam = cam_looking_y();
1582 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1583 let cfg = settings(w, h);
1584
1585 let px_at = |k: f32| -> u32 {
1586 let mut sprite = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1587 for a in 0..3 {
1588 sprite.s[a] *= k;
1589 sprite.h[a] *= k;
1590 sprite.f[a] *= k;
1591 }
1592 let mut fb = vec![0u32; n];
1593 let mut zb = vec![f32::INFINITY; n];
1594 draw_sprite_dda(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, &sprite)
1595 };
1596
1597 let (unit, double, half) = (px_at(1.0), px_at(2.0), px_at(0.5));
1598 assert!(unit > 0, "unit-scale cube must draw ({unit} px)");
1599 let r2 = f64::from(double) / f64::from(unit);
1600 let rh = f64::from(half) / f64::from(unit);
1601 assert!(
1602 (3.0..8.0).contains(&r2),
1603 "2× scale should roughly quadruple coverage: {unit} → {double} px (×{r2:.2})"
1604 );
1605 assert!(
1606 (0.08..0.5).contains(&rh),
1607 "0.5× scale should roughly quarter coverage: {unit} → {half} px (×{rh:.2})"
1608 );
1609 }
1610
1611 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1613 let owpc = dims[2].div_ceil(32).max(1) as usize;
1614 let cols = (dims[0] * dims[1]) as usize;
1615 let mut occupancy = vec![0u32; cols * owpc];
1616 let mut color_offsets = vec![0u32; cols + 1];
1617 let mut colors = Vec::new();
1618 for y in 0..dims[1] {
1619 for x in 0..dims[0] {
1620 let col = (x + y * dims[0]) as usize;
1621 color_offsets[col] = colors.len() as u32;
1622 for z in 0..dims[2] {
1623 if let Some(c) = fill(x, y, z) {
1624 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1625 colors.push(c);
1626 }
1627 }
1628 }
1629 }
1630 color_offsets[cols] = colors.len() as u32;
1631 VoxelFrame {
1632 occupancy,
1633 colors,
1634 color_offsets,
1635 }
1636 }
1637
1638 #[test]
1643 fn clip_flipbook_frames_render_differently() {
1644 let dims = [8u32, 8, 8];
1645 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(
1648 dims,
1649 [4.0, 4.0, 4.0],
1650 1.0,
1651 LoopMode::Loop,
1652 &[f0, f1],
1653 &[],
1654 33,
1655 0,
1656 );
1657 let decoded = clip.decode().expect("decode");
1658 let book = ClipFlipbook::from_decoded(&decoded);
1659 assert_eq!(book.frame_count(), 2);
1660 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1661
1662 let (w, h) = (64u32, 64u32);
1663 let n = (w * h) as usize;
1664 let cam = cam_looking_y();
1665 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1666 let cfg = settings(w, h);
1667 let pose = [0.0, 40.0, 0.0];
1668 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1669
1670 let render = |frame: usize| -> Vec<u32> {
1671 let mut fb = vec![0u32; n];
1672 let mut zb = vec![f32::INFINITY; n];
1673 let wrote = book.draw_frame(
1674 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1675 );
1676 assert!(wrote > 0, "frame {frame} should draw some pixels");
1677 fb
1678 };
1679 let fb0 = render(0);
1680 let fb1 = render(1);
1681 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1682 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1685 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1686 let mut fb = vec![0u32; n];
1688 let mut zb = vec![f32::INFINITY; n];
1689 assert_eq!(
1690 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1691 0
1692 );
1693 }
1694
1695 #[test]
1696 fn clip_flipbook_set_frame_replaces_one_frame() {
1697 let dims = [8u32, 8, 8];
1700 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 =
1703 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1704 let decoded = clip.decode().unwrap();
1705 let mut book = ClipFlipbook::from_decoded(&decoded);
1706
1707 let (w, h) = (64u32, 64u32);
1708 let n = (w * h) as usize;
1709 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1710 let cfg = settings(w, h);
1711 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1712 let mut fb = vec![0u32; n];
1713 let mut zb = vec![f32::INFINITY; n];
1714 let _ = b.draw_frame(
1715 &mut fb,
1716 &mut zb,
1717 w as usize,
1718 w,
1719 h,
1720 &cs,
1721 &cfg,
1722 0,
1723 [0.0, 40.0, 0.0],
1724 [1.0, 0.0, 0.0],
1725 [0.0, 1.0, 0.0],
1726 [0.0, 0.0, 1.0],
1727 0,
1728 );
1729 fb
1730 };
1731
1732 let before = render0(&book);
1733 assert!(
1734 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1735 "frame 0 is red"
1736 );
1737
1738 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1740 assert!(book.set_frame(0, replacement));
1741 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1742 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1743
1744 let after = render0(&book);
1745 assert!(
1746 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1747 "frame 0 now green"
1748 );
1749 assert_ne!(before, after);
1750 }
1751
1752 #[test]
1755 fn cube_sprite_renders() {
1756 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_C0_40_20));
1757 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1758 let (w, h) = (64u32, 64u32);
1759 let n = (w * h) as usize;
1760 let mut fb = vec![0u32; n];
1761 let mut zb = vec![f32::INFINITY; n];
1762 let cam = cam_looking_y();
1763 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1764 let wrote = draw_sprite_dda(
1765 &mut fb,
1766 &mut zb,
1767 w as usize,
1768 w,
1769 h,
1770 &cs,
1771 &settings(w, h),
1772 &sprite,
1773 );
1774
1775 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1776 let centre = (h / 2 * w + w / 2) as usize;
1777 assert_eq!(
1778 fb[centre] & 0x00ff_ffff,
1779 0x00_C0_40_20,
1780 "got {:08x}",
1781 fb[centre]
1782 );
1783 assert!(
1785 (zb[centre] - 36.0).abs() < 3.0,
1786 "centre depth {} not ≈ 36",
1787 zb[centre]
1788 );
1789 }
1790
1791 #[test]
1796 fn zero_high_byte_sprite_not_black() {
1797 let kv6 = Kv6::solid_cube(8, VoxColor(0x00_C0_40_20));
1798 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1799 let (w, h) = (64u32, 64u32);
1800 let n = (w * h) as usize;
1801 let mut fb = vec![0u32; n];
1802 let mut zb = vec![f32::INFINITY; n];
1803 let cam = cam_looking_y();
1804 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1805 let wrote = draw_sprite_dda(
1806 &mut fb,
1807 &mut zb,
1808 w as usize,
1809 w,
1810 h,
1811 &cs,
1812 &settings(w, h),
1813 &sprite,
1814 );
1815 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1816 let centre = (h / 2 * w + w / 2) as usize;
1817 assert_eq!(
1818 fb[centre] & 0x00ff_ffff,
1819 0x00_C0_40_20,
1820 "zero-high-byte sprite rendered as {:08x} (black bug)",
1821 fb[centre]
1822 );
1823 }
1824
1825 #[test]
1828 fn sprite_respects_zbuffer() {
1829 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1830 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1831 let (w, h) = (32u32, 32u32);
1832 let n = (w * h) as usize;
1833 let cam = cam_looking_y();
1834 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1835 let centre = (h / 2 * w + w / 2) as usize;
1836
1837 let mut fb = vec![0u32; n];
1839 let mut zb = vec![f32::INFINITY; n];
1840 fb[centre] = 0x80_11_22_33;
1841 zb[centre] = 10.0;
1842 let _ = draw_sprite_dda(
1843 &mut fb,
1844 &mut zb,
1845 w as usize,
1846 w,
1847 h,
1848 &cs,
1849 &settings(w, h),
1850 &sprite,
1851 );
1852 assert_eq!(
1853 fb[centre], 0x80_11_22_33,
1854 "near terrain must occlude sprite"
1855 );
1856
1857 let mut fb2 = vec![0u32; n];
1859 let mut zb2 = vec![f32::INFINITY; n];
1860 fb2[centre] = 0x80_11_22_33;
1861 zb2[centre] = 100.0;
1862 let _ = draw_sprite_dda(
1863 &mut fb2,
1864 &mut zb2,
1865 w as usize,
1866 w,
1867 h,
1868 &cs,
1869 &settings(w, h),
1870 &sprite,
1871 );
1872 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1873 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1874 }
1875
1876 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1879 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1880 for py in 0..h {
1881 for px in 0..w {
1882 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1883 x0 = x0.min(px);
1884 y0 = y0.min(py);
1885 x1 = x1.max(px);
1886 y1 = y1.max(py);
1887 }
1888 }
1889 }
1890 (x0, y0, x1, y1)
1891 }
1892
1893 #[test]
1898 fn posed_basis_reorients_silhouette() {
1899 let kv6 = Kv6::solid_box(16, 4, 4, VoxColor(0x80_C0_40_20));
1902 let (w, h) = (64u32, 64u32);
1903 let n = (w * h) as usize;
1904 let cam = cam_looking_y();
1905 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1906
1907 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1909 let mut fb = vec![0u32; n];
1910 let mut zb = vec![f32::INFINITY; n];
1911 let _ = draw_sprite_dda(
1912 &mut fb,
1913 &mut zb,
1914 w as usize,
1915 w,
1916 h,
1917 &cs,
1918 &settings(w, h),
1919 &aa,
1920 );
1921 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1922 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1923 assert!(
1924 aa_wide > 4,
1925 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1926 );
1927
1928 let mut posed = aa.clone();
1931 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];
1935 let mut zb2 = vec![f32::INFINITY; n];
1936 let _ = draw_sprite_dda(
1937 &mut fb2,
1938 &mut zb2,
1939 w as usize,
1940 w,
1941 h,
1942 &cs,
1943 &settings(w, h),
1944 &posed,
1945 );
1946 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1947 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1948 assert!(
1949 posed_tall > 4,
1950 "posed box should be taller than wide (got h-w={posed_tall})"
1951 );
1952 }
1953
1954 #[test]
1957 fn degenerate_basis_draws_nothing() {
1958 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1959 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1960 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1962 let n = (w * h) as usize;
1963 let mut fb = vec![0u32; n];
1964 let mut zb = vec![f32::INFINITY; n];
1965 let cam = cam_looking_y();
1966 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1967 let wrote = draw_sprite_dda(
1968 &mut fb,
1969 &mut zb,
1970 w as usize,
1971 w,
1972 h,
1973 &cs,
1974 &settings(w, h),
1975 &sprite,
1976 );
1977 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1978 }
1979
1980 #[test]
1982 fn invisible_sprite_skipped() {
1983 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1984 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1985 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1986 let (w, h) = (32u32, 32u32);
1987 let n = (w * h) as usize;
1988 let mut fb = vec![0u32; n];
1989 let mut zb = vec![f32::INFINITY; n];
1990 let cam = cam_looking_y();
1991 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1992 let wrote = draw_sprite_dda(
1993 &mut fb,
1994 &mut zb,
1995 w as usize,
1996 w,
1997 h,
1998 &cs,
1999 &settings(w, h),
2000 &sprite,
2001 );
2002 assert_eq!(wrote, 0);
2003 }
2004
2005 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
2011 let mut table = MaterialTable::new();
2012 table.set(1, mat);
2013 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2014 let (w, h) = (64u32, 64u32);
2015 let n = (w * h) as usize;
2016 let mut fb = vec![bg; n];
2017 let mut zb = vec![zb_v; n];
2018 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2019 let sh = SpriteShade {
2020 materials: &table,
2021 lights: CpuLights::default(),
2022 material: 1,
2023 alpha_mul,
2024 tint: 0x00FF_FFFF,
2025 shadow: None,
2026 };
2027 let _ = draw_sprite_dense_shaded(
2028 &mut fb,
2029 &mut zb,
2030 w as usize,
2031 w,
2032 h,
2033 &cs,
2034 &settings(w, h),
2035 &dense,
2036 [0.0, 40.0, 0.0],
2037 [1.0, 0.0, 0.0],
2038 [0.0, 1.0, 0.0],
2039 [0.0, 0.0, 1.0],
2040 0,
2041 Some(sh),
2042 );
2043 (fb[(h / 2 * w + w / 2) as usize], fb)
2044 }
2045
2046 #[test]
2049 fn additive_sprite_brightens_background() {
2050 let bg = 0x80_20_20_20;
2051 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
2052 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
2053 assert!(
2054 cr > 0x20 && cg > 0x20 && cb >= 0x20,
2055 "centre {centre:08x} should be brighter than bg"
2056 );
2057 assert!(
2059 cr >= cg && cr >= cb,
2060 "additive of a red-dominant cube stays red-dominant"
2061 );
2062 }
2063
2064 #[test]
2067 fn alpha_blend_sprite_between_bg_and_color() {
2068 let bg = 0x80_20_20_20;
2069 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
2070 let cr = (centre >> 16) & 0xff;
2071 assert!(
2072 cr > 0x20,
2073 "blended red must rise above bg 0x20 (got {cr:02x})"
2074 );
2075 assert!(
2076 cr < 0xC0,
2077 "blended red must stay below opaque 0xC0 (got {cr:02x})"
2078 );
2079 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
2081 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
2082 }
2083
2084 #[test]
2087 fn alpha_mul_scales_opacity() {
2088 let bg = 0x80_20_20_20;
2089 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
2090 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
2091 let r_full = (full >> 16) & 0xff;
2092 let r_faded = (faded >> 16) & 0xff;
2093 assert!(
2095 r_full > r_faded,
2096 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
2097 );
2098 assert!(r_faded > 0x20, "even faded lifts above bg");
2099 }
2100
2101 fn draw_cube_lit(mat: Material, lights: CpuLights) -> u32 {
2107 let mut table = MaterialTable::new();
2108 table.set(1, mat);
2109 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2110 let (w, h) = (64u32, 64u32);
2111 let n = (w * h) as usize;
2112 let mut fb = vec![0u32; n];
2113 let mut zb = vec![f32::INFINITY; n];
2114 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2115 let sh = SpriteShade {
2116 materials: &table,
2117 lights,
2118 material: 1,
2119 alpha_mul: 255,
2120 tint: 0x00FF_FFFF,
2121 shadow: None,
2122 };
2123 let _ = draw_sprite_dense_shaded(
2124 &mut fb,
2125 &mut zb,
2126 w as usize,
2127 w,
2128 h,
2129 &cs,
2130 &settings(w, h),
2131 &dense,
2132 [0.0, 40.0, 0.0],
2133 [1.0, 0.0, 0.0],
2134 [0.0, 1.0, 0.0],
2135 [0.0, 0.0, 1.0],
2136 0,
2137 Some(sh),
2138 );
2139 fb[(h / 2 * w + w / 2) as usize]
2140 }
2141
2142 #[test]
2148 fn sprite_emissive_ignores_lighting() {
2149 let dark = CpuLights {
2151 enabled: true,
2152 ambient: [0.0; 3],
2153 ..CpuLights::default()
2154 };
2155 let plain = draw_cube_lit(Material::OPAQUE, dark);
2156 assert_eq!(
2157 plain & 0x00ff_ffff,
2158 0,
2159 "zero rig must black out a plain cube: {plain:#010x}"
2160 );
2161
2162 let expect = emissive_shade(0x80_C0_40_20, 255) & 0x00ff_ffff;
2163 let glow = draw_cube_lit(Material::OPAQUE.with_emissive(255), dark);
2164 assert_eq!(
2165 glow & 0x00ff_ffff,
2166 expect,
2167 "emissive must outrank the rig: {glow:#010x} vs {expect:#010x}"
2168 );
2169
2170 let unlit = draw_cube_lit(Material::OPAQUE.with_emissive(255), CpuLights::default());
2172 assert_eq!(unlit & 0x00ff_ffff, expect, "emissive is rig-invariant");
2173 }
2174
2175 #[test]
2179 fn sprite_emissive_glows_through_alpha_blend() {
2180 let (plain, _) =
2181 draw_cube_shaded(Material::alpha_blend(128), 255, 0x8000_0000, f32::INFINITY);
2182 let (glow, _) = draw_cube_shaded(
2183 Material::alpha_blend(128).with_emissive(255),
2184 255,
2185 0x8000_0000,
2186 f32::INFINITY,
2187 );
2188 let r = |p: u32| (p >> 16) & 0xff;
2189 assert!(
2190 r(glow) > r(plain),
2191 "emissive glass must beat plain glass over black: {glow:#010x} vs {plain:#010x}"
2192 );
2193 }
2194
2195 #[test]
2199 fn opaque_shade_ctx_matches_plain_path() {
2200 let table = MaterialTable::new();
2201 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2202 let (w, h) = (64u32, 64u32);
2203 let n = (w * h) as usize;
2204 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2205 let pose = (
2206 [0.0, 40.0, 0.0],
2207 [1.0, 0.0, 0.0],
2208 [0.0, 1.0, 0.0],
2209 [0.0, 0.0, 1.0],
2210 );
2211
2212 let mut fb_plain = vec![0u32; n];
2213 let mut zb_plain = vec![f32::INFINITY; n];
2214 let _ = draw_sprite_dense(
2215 &mut fb_plain,
2216 &mut zb_plain,
2217 w as usize,
2218 w,
2219 h,
2220 &cs,
2221 &settings(w, h),
2222 &dense,
2223 pose.0,
2224 pose.1,
2225 pose.2,
2226 pose.3,
2227 0,
2228 );
2229
2230 let mut fb_sh = vec![0u32; n];
2231 let mut zb_sh = vec![f32::INFINITY; n];
2232 let sh = SpriteShade {
2233 materials: &table,
2234 lights: CpuLights::default(),
2235 material: 0, alpha_mul: 255,
2237 tint: 0x00FF_FFFF,
2238 shadow: None,
2239 };
2240 let _ = draw_sprite_dense_shaded(
2241 &mut fb_sh,
2242 &mut zb_sh,
2243 w as usize,
2244 w,
2245 h,
2246 &cs,
2247 &settings(w, h),
2248 &dense,
2249 pose.0,
2250 pose.1,
2251 pose.2,
2252 pose.3,
2253 0,
2254 Some(sh),
2255 );
2256
2257 assert_eq!(
2258 fb_plain, fb_sh,
2259 "opaque shade-ctx must match the plain path bit-for-bit"
2260 );
2261 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2262 }
2263
2264 #[test]
2268 fn translucent_sprite_occluded_by_near_terrain() {
2269 let bg = 0x80_20_20_20;
2270 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2271 assert_eq!(
2272 centre, bg,
2273 "near terrain (z=5) must occlude the sprite at y≈36"
2274 );
2275 }
2276
2277 #[test]
2283 fn per_span_thickness_independent() {
2284 fn centre(ysiz: u32) -> u32 {
2285 let mut table = MaterialTable::new();
2286 table.set(1, Material::alpha_blend(128));
2287 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, VoxColor(0x80_C0_40_20)));
2288 let (w, h) = (64u32, 64u32);
2289 let n = (w * h) as usize;
2290 let mut fb = vec![0x80_10_10_10u32; n];
2291 let mut zb = vec![f32::INFINITY; n];
2292 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2293 let sh = SpriteShade {
2294 materials: &table,
2295 lights: CpuLights::default(),
2296 material: 1,
2297 alpha_mul: 255,
2298 tint: 0x00FF_FFFF,
2299 shadow: None,
2300 };
2301 let _ = draw_sprite_dense_shaded(
2302 &mut fb,
2303 &mut zb,
2304 w as usize,
2305 w,
2306 h,
2307 &cs,
2308 &settings(w, h),
2309 &dense,
2310 [0.0, 40.0, 0.0],
2311 [1.0, 0.0, 0.0],
2312 [0.0, 1.0, 0.0],
2313 [0.0, 0.0, 1.0],
2314 0,
2315 Some(sh),
2316 );
2317 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2318 }
2319 assert_eq!(
2323 centre(1),
2324 centre(2),
2325 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2326 );
2327 }
2328
2329 #[test]
2334 fn volumetric_thickness_deepens_opacity() {
2335 fn red_at(depth: u32) -> u32 {
2338 let mut table = MaterialTable::new();
2339 table.set(1, Material::volumetric(128));
2340 let kv6 = Kv6::from_fn_keep_interior(
2345 8,
2346 depth,
2347 8,
2348 |_, _, _| Some(VoxColor(0x80_C0_20_20)),
2349 |_| true,
2350 );
2351 let dense = SpriteDense::from_kv6(&kv6);
2352 let (w, h) = (64u32, 64u32);
2353 let n = (w * h) as usize;
2354 let mut fb = vec![0x80_10_10_10u32; n];
2355 let mut zb = vec![f32::INFINITY; n];
2356 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2357 let sh = SpriteShade {
2358 materials: &table,
2359 lights: CpuLights::default(),
2360 material: 1,
2361 alpha_mul: 255,
2362 tint: 0x00FF_FFFF,
2363 shadow: None,
2364 };
2365 let _ = draw_sprite_dense_shaded(
2366 &mut fb,
2367 &mut zb,
2368 w as usize,
2369 w,
2370 h,
2371 &cs,
2372 &settings(w, h),
2373 &dense,
2374 [0.0, 40.0, 0.0],
2375 [1.0, 0.0, 0.0],
2376 [0.0, 1.0, 0.0],
2377 [0.0, 0.0, 1.0],
2378 0,
2379 Some(sh),
2380 );
2381 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2382 }
2383 let shallow = red_at(1);
2384 let deep = red_at(12);
2385 assert!(
2388 shallow > 0x10,
2389 "even a 1-deep volume tints (got {shallow:02x})"
2390 );
2391 assert!(
2392 deep > shallow,
2393 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2394 );
2395 }
2396
2397 #[test]
2403 fn voxel_world_size_matches_scaled_basis() {
2404 use crate::dda::WorldOccluder;
2405 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_40_C0_40));
2406 let scaled = SpriteDense::from_kv6(&kv6).with_voxel_world_size(2.0);
2407 let unit = SpriteDense::from_kv6(&kv6);
2408
2409 let (w, h) = (64u32, 64u32);
2410 let n = (w * h) as usize;
2411 let draw = |dense: &SpriteDense, basis: f32| {
2412 let mut fb = vec![0x80_10_10_10u32; n];
2413 let mut zb = vec![f32::INFINITY; n];
2414 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2415 let written = draw_sprite_dense(
2416 &mut fb,
2417 &mut zb,
2418 w as usize,
2419 w,
2420 h,
2421 &cs,
2422 &settings(w, h),
2423 dense,
2424 [0.0, 40.0, 0.0],
2425 [basis, 0.0, 0.0],
2426 [0.0, basis, 0.0],
2427 [0.0, 0.0, basis],
2428 0,
2429 );
2430 assert!(written > 0, "sprite must be visible");
2431 fb
2432 };
2433 assert_eq!(
2434 draw(&scaled, 1.0),
2435 draw(&unit, 2.0),
2436 "vws=2 @ unit basis == vws=1 @ 2x basis, pixel for pixel"
2437 );
2438
2439 let occludes = |dense: &SpriteDense, basis: f32, x: f64| {
2440 let mut occ = SpriteOccluder::new();
2441 occ.push(
2442 Arc::new(dense.clone()),
2443 [0.0, 40.0, 0.0],
2444 [basis, 0.0, 0.0],
2445 [0.0, basis, 0.0],
2446 [0.0, 0.0, basis],
2447 );
2448 occ.occluded_world([x, 0.0, 0.0], [0.0, 1.0, 0.0], 100.0)
2449 };
2450 assert!(occludes(&scaled, 1.0, 6.0));
2454 assert!(occludes(&unit, 2.0, 6.0));
2455 assert!(!occludes(&unit, 1.0, 6.0), "unscaled cube ends at 4");
2456 }
2457
2458 #[test]
2463 fn sprite_occluder_blocks_ray_through_volume() {
2464 use crate::dda::WorldOccluder;
2465 let dense = Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2468 8,
2469 VoxColor(0x80_FF_FF_FF),
2470 )));
2471 let mut occ = SpriteOccluder::new();
2472 occ.push(
2473 dense,
2474 [0.0, 0.0, 0.0],
2475 [1.0, 0.0, 0.0],
2476 [0.0, 1.0, 0.0],
2477 [0.0, 0.0, 1.0],
2478 );
2479 assert!(!occ.is_empty());
2480 assert!(
2482 occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2483 "a ray through the cube must be occluded"
2484 );
2485 assert!(
2487 !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2488 "a ray missing the cube must not be occluded"
2489 );
2490 assert!(
2492 !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2493 "max_t shorter than the distance to the cube ⇒ unoccluded"
2494 );
2495 }
2496
2497 #[test]
2502 fn sprite_receives_hard_shadow() {
2503 let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2509 let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2510 (dx * dx + dy * dy + dz * dz <= 49).then_some(VoxColor(0x80_C0_C0_C0))
2511 }));
2512 let mut occ = SpriteOccluder::new();
2513 occ.push(
2514 Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2515 8,
2516 VoxColor(0x80_FF_FF_FF),
2517 ))),
2518 [0.0, 25.0, 0.0],
2519 [1.0, 0.0, 0.0],
2520 [0.0, 1.0, 0.0],
2521 [0.0, 0.0, 1.0],
2522 );
2523 let table = MaterialTable::new();
2524 let base = CpuLights {
2525 enabled: true,
2526 sun: true,
2527 sun_dir: [0.0, -1.0, 0.0], sun_color: [1.0; 3],
2529 sun_intensity: 1.0,
2530 sun_casts_shadow: true,
2531 ambient: [0.3; 3],
2532 shadow_strength: 0.85,
2533 shadow_bias: 1.5,
2534 shadow_max_dist: 128.0,
2535 ..CpuLights::default()
2536 };
2537 let (w, h) = (64u32, 64u32);
2538 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2539 let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2540 let n = (w * h) as usize;
2541 let mut fb = vec![0u32; n];
2542 let mut zb = vec![f32::INFINITY; n];
2543 let sh = SpriteShade {
2544 materials: &table,
2545 lights: base,
2546 material: 0,
2547 alpha_mul: 255,
2548 tint: 0x00FF_FFFF,
2549 shadow,
2550 };
2551 let _ = draw_sprite_dense_shaded(
2552 &mut fb,
2553 &mut zb,
2554 w as usize,
2555 w,
2556 h,
2557 &cs,
2558 &settings(w, h),
2559 &target,
2560 [0.0, 40.0, 0.0],
2561 [1.0, 0.0, 0.0],
2562 [0.0, 1.0, 0.0],
2563 [0.0, 0.0, 1.0],
2564 0,
2565 Some(sh),
2566 );
2567 fb.iter()
2568 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2569 .sum()
2570 };
2571 let lit = sum_lum(None);
2572 let shadowed = sum_lum(Some(&occ));
2573 assert!(
2574 shadowed < lit,
2575 "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2576 );
2577 }
2578
2579 #[test]
2582 fn sprite_rgb_tint_recolours() {
2583 let table = MaterialTable::new();
2584 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF)));
2585 let (w, h) = (64u32, 64u32);
2586 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2587 let centre = |tint: u32| -> u32 {
2588 let n = (w * h) as usize;
2589 let mut fb = vec![0u32; n];
2590 let mut zb = vec![f32::INFINITY; n];
2591 let sh = SpriteShade {
2592 materials: &table,
2593 lights: CpuLights::default(),
2594 material: 0,
2595 alpha_mul: 255,
2596 tint,
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 &settings(w, h),
2607 &dense,
2608 [0.0, 40.0, 0.0],
2609 [1.0, 0.0, 0.0],
2610 [0.0, 1.0, 0.0],
2611 [0.0, 0.0, 1.0],
2612 0,
2613 Some(sh),
2614 );
2615 fb[(h / 2 * w + w / 2) as usize]
2616 };
2617 let r = |p: u32| (p >> 16) & 0xff;
2618 let g = |p: u32| (p >> 8) & 0xff;
2619 let b = |p: u32| p & 0xff;
2620 let white = centre(0x00FF_FFFF);
2621 let red = centre(0x00FF_0000);
2622 assert!(
2623 g(white) > 180 && b(white) > 180 && r(white) > 180,
2624 "white tint must be a no-op: {white:#08x}"
2625 );
2626 assert!(
2627 r(red) > 180 && g(red) < 20 && b(red) < 20,
2628 "red tint zeroes green/blue, keeps red: {red:#08x}"
2629 );
2630 }
2631
2632 #[test]
2637 fn translucent_sprite_layers_are_lit() {
2638 fn center_red(lights: CpuLights) -> u32 {
2639 let mut table = MaterialTable::new();
2640 table.set(1, Material::alpha_blend(160));
2641 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, VoxColor(0x80_E0_30_30)));
2642 let (w, h) = (64u32, 64u32);
2643 let n = (w * h) as usize;
2644 let mut fb = vec![0x80_10_10_10u32; n];
2645 let mut zb = vec![f32::INFINITY; n];
2646 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2647 let sh = SpriteShade {
2648 materials: &table,
2649 lights,
2650 material: 1,
2651 alpha_mul: 255,
2652 tint: 0x00FF_FFFF,
2653 shadow: None,
2654 };
2655 let _ = draw_sprite_dense_shaded(
2656 &mut fb,
2657 &mut zb,
2658 w as usize,
2659 w,
2660 h,
2661 &cs,
2662 &settings(w, h),
2663 &dense,
2664 [0.0, 40.0, 0.0],
2665 [1.0, 0.0, 0.0],
2666 [0.0, 1.0, 0.0],
2667 [0.0, 0.0, 1.0],
2668 0,
2669 Some(sh),
2670 );
2671 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2672 }
2673 let baked = center_red(CpuLights::default()); let dim = center_red(CpuLights {
2675 enabled: true,
2676 ambient: [0.3; 3], ..CpuLights::default()
2678 });
2679 assert!(
2680 dim < baked,
2681 "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2682 );
2683 }
2684
2685 #[test]
2690 fn translucent_sprite_tints_opaque_sprite_behind() {
2691 let mut table = MaterialTable::new();
2692 table.set(1, Material::alpha_blend(128));
2693 let (w, h) = (64u32, 64u32);
2694 let n = (w * h) as usize;
2695 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
2697 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2698 let cfg = settings(w, h);
2699 let id = [1.0, 0.0, 0.0];
2700 let up = [0.0, 1.0, 0.0];
2701 let fw = [0.0, 0.0, 1.0];
2702 let centre = (h / 2 * w + w / 2) as usize;
2703
2704 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_FF_00_00)));
2706 let sh_op = SpriteShade {
2707 materials: &table,
2708 lights: CpuLights::default(),
2709 material: 0,
2710 alpha_mul: 255,
2711 tint: 0x00FF_FFFF,
2712 shadow: None,
2713 };
2714 let _ = draw_sprite_dense_shaded(
2715 &mut fb,
2716 &mut zb,
2717 w as usize,
2718 w,
2719 h,
2720 &cs,
2721 &cfg,
2722 &backdrop,
2723 [0.0, 80.0, 0.0],
2724 id,
2725 up,
2726 fw,
2727 0,
2728 Some(sh_op),
2729 );
2730 let after_backdrop = fb[centre];
2731 assert_eq!(
2732 after_backdrop & 0x00ff_ffff,
2733 0x00FF_0000,
2734 "backdrop red must be drawn first"
2735 );
2736
2737 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_00_FF_FF)));
2739 let sh_gl = SpriteShade {
2740 materials: &table,
2741 lights: CpuLights::default(),
2742 material: 1,
2743 alpha_mul: 255,
2744 tint: 0x00FF_FFFF,
2745 shadow: None,
2746 };
2747 let wrote = draw_sprite_dense_shaded(
2748 &mut fb,
2749 &mut zb,
2750 w as usize,
2751 w,
2752 h,
2753 &cs,
2754 &cfg,
2755 &glass,
2756 [0.0, 40.0, 0.0],
2757 id,
2758 up,
2759 fw,
2760 0,
2761 Some(sh_gl),
2762 );
2763 let _ = wrote;
2764 let after_glass = fb[centre];
2765 assert_ne!(
2766 after_glass, after_backdrop,
2767 "glass must tint the backdrop (composite over it)"
2768 );
2769 assert!(
2771 (after_glass >> 16) & 0xff < 0xFF,
2772 "glass should reduce the backdrop's red (got {after_glass:08x})"
2773 );
2774 }
2775
2776 #[test]
2779 fn from_kv6_with_materials_classifies_by_color() {
2780 let col = VoxColor(0x80_AA_BB_CC);
2781 let kv6 = Kv6::solid_cube(6, col);
2782 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
2783 assert_eq!(
2784 dense.mat.len(),
2785 dense.col.len(),
2786 "per-voxel mat array sized"
2787 );
2788 let mut solids = 0;
2789 for idx in 0..dense.occ.len() {
2790 if dense.occ[idx] {
2791 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2792 solids += 1;
2793 }
2794 }
2795 assert!(solids > 0, "cube has solid voxels");
2796 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x0012_3456), 5)]);
2798 assert!(
2799 dense0.mat.iter().all(|&m| m == 0),
2800 "unmapped colour → material 0"
2801 );
2802 }
2803
2804 #[test]
2809 fn per_voxel_material_matches_uniform_when_homogeneous() {
2810 let mut table = MaterialTable::new();
2811 table.set(1, Material::alpha_blend(120));
2812 let col = VoxColor(0x80_30_A0_F0);
2813 let kv6 = Kv6::solid_cube(10, col);
2814 let (w, h) = (64u32, 64u32);
2815 let n = (w * h) as usize;
2816 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2817 let cfg = settings(w, h);
2818 let (pos, s, hh, f) = (
2819 [0.0, 40.0, 0.0],
2820 [1.0, 0.0, 0.0],
2821 [0.0, 1.0, 0.0],
2822 [0.0, 0.0, 1.0],
2823 );
2824 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2825 let mut fb = vec![0x80_10_10_10u32; n];
2826 let mut zb = vec![f32::INFINITY; n];
2827 let sh = SpriteShade {
2828 materials: &table,
2829 lights: CpuLights::default(),
2830 material,
2831 alpha_mul: 255,
2832 tint: 0x00FF_FFFF,
2833 shadow: None,
2834 };
2835 let _ = draw_sprite_dense_shaded(
2836 &mut fb,
2837 &mut zb,
2838 w as usize,
2839 w,
2840 h,
2841 &cs,
2842 &cfg,
2843 dense,
2844 pos,
2845 s,
2846 hh,
2847 f,
2848 0,
2849 Some(sh),
2850 );
2851 fb
2852 };
2853 let pv = render(
2856 &SpriteDense::from_kv6_with_materials(&kv6, &[(col.rgb_part(), 1)]),
2857 0,
2858 );
2859 let un = render(&SpriteDense::from_kv6(&kv6), 1);
2861 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2862 let centre = (h / 2 * w + w / 2) as usize;
2864 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2865 }
2866
2867 #[test]
2872 fn clip_flipbook_with_materials_classifies_every_frame() {
2873 let dims = [6u32, 6, 6];
2874 let glass = Rgb(0x00AA_BBCC);
2875 let glass_lit = 0x80AA_BBCC;
2876 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2878 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2879 let clip = VoxelClip::from_frames(
2880 dims,
2881 [3.0, 3.0, 3.0],
2882 1.0,
2883 LoopMode::Loop,
2884 &[f0, f1],
2885 &[],
2886 33,
2887 0,
2888 );
2889 let decoded = clip.decode().expect("decode");
2890
2891 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2892 assert_eq!(book.frame_count(), 2);
2893 for fr in 0..2 {
2894 let dense = book.frame(fr).expect("frame in range");
2895 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2896 let mut solids = 0;
2897 for idx in 0..dense.occ.len() {
2898 if dense.occ[idx] {
2899 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2900 solids += 1;
2901 }
2902 }
2903 assert!(solids > 0, "frame {fr} has solid voxels");
2904 }
2905
2906 let plain = ClipFlipbook::from_decoded(&decoded);
2908 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2909 for fr in 0..2 {
2910 assert!(plain.frame(fr).unwrap().mat.is_empty());
2911 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2912 }
2913 }
2914}