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: [f32; 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: [f32; 3], dw: [f32; 3], max_t: f32) -> bool {
413 let rel = [ow[0] - e.pos[0], ow[1] - e.pos[1], ow[2] - e.pos[2]];
415 let ol = mat_apply(&e.minv, rel);
416 let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
417 let dir = mat_apply(&e.minv, dw);
418
419 let hi = [
420 e.dense.dims[0] as f32,
421 e.dense.dims[1] as f32,
422 e.dense.dims[2] as f32,
423 ];
424 let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
425 return false;
426 };
427 let t_enter = t0.max(0.0);
428 let t_exit = t1.min(max_t);
429 if t_enter > t_exit {
430 return false;
431 }
432 let start = t_enter + 1e-4;
433 let p = [
434 origin[0] + dir[0] * start,
435 origin[1] + dir[1] * start,
436 origin[2] + dir[2] * start,
437 ];
438 let mut cell = [
439 (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
440 (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
441 (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
442 ];
443 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
444 let mut t_curr = t_enter;
445 let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
446 for _ in 0..max_steps {
447 if cell[0] < 0
448 || cell[0] >= e.dense.dims[0]
449 || cell[1] < 0
450 || cell[1] >= e.dense.dims[1]
451 || cell[2] < 0
452 || cell[2] >= e.dense.dims[2]
453 || t_curr > t_exit
454 {
455 return false;
456 }
457 if e.dense.occ[e.dense.idx_of(cell)] {
458 return true;
459 }
460 let a = min_axis(t_max);
461 t_curr = t_max[a];
462 cell[a] += step[a];
463 t_max[a] += t_delta[a];
464 }
465 false
466}
467
468#[derive(Clone, Copy)]
475pub struct SpriteShade<'a> {
476 pub materials: &'a MaterialTable,
478 pub material: u8,
481 pub alpha_mul: u8,
484 pub tint: u32,
487 pub lights: CpuLights<'a>,
491 pub shadow: Option<&'a dyn WorldOccluder>,
496}
497
498struct LayerAccum {
500 rgb: [f32; 3],
503 trans: f32,
505 opaque: Option<(u32, f32)>,
510}
511
512#[inline]
516fn tint_packed(color: u32, tint: u32) -> u32 {
517 if tint & 0x00FF_FFFF == 0x00FF_FFFF {
518 return color;
519 }
520 let mul = |shift: u32| {
521 let c = (color >> shift) & 0xff;
522 let t = (tint >> shift) & 0xff;
523 ((c * t) / 255) & 0xff
524 };
525 (color & 0xff00_0000) | (mul(16) << 16) | (mul(8) << 8) | mul(0)
526}
527
528#[inline]
531fn rgb_to_f32(c: u32) -> [f32; 3] {
532 [
533 ((c >> 16) & 0xff) as f32 / 255.0,
534 ((c >> 8) & 0xff) as f32 / 255.0,
535 (c & 0xff) as f32 / 255.0,
536 ]
537}
538
539#[inline]
542#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
543fn f32_to_rgb(c: [f32; 3]) -> u32 {
544 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
545 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
546}
547
548const SPRITE_WORLD_UP: [f32; 3] = [0.0, 0.0, -1.0];
551
552#[derive(Clone, Copy, PartialEq, Eq)]
557pub enum SpriteLightMode {
558 FaceNormal,
560 WorldUp,
562 AmbientOnly,
564 FullBright,
567}
568
569impl SpriteLightMode {
570 #[must_use]
576 pub fn from_flags(flags: u32) -> Self {
577 let world_up = flags & SPRITE_FLAG_LIGHT_WORLD_UP != 0;
578 let ambient_only = flags & SPRITE_FLAG_LIGHT_AMBIENT_ONLY != 0;
579 match (ambient_only, world_up) {
580 (true, true) => Self::FullBright, (true, false) => Self::AmbientOnly,
582 (false, true) => Self::WorldUp,
583 (false, false) => Self::FaceNormal,
584 }
585 }
586}
587
588fn shade_dynamic_mode(
593 mode: SpriteLightMode,
594 albedo: [f32; 3],
595 n_world: [f32; 3],
596 center: [f32; 3],
597 lights: &CpuLights<'_>,
598 tester: Option<&mut dyn ShadowTester>,
599) -> u32 {
600 match mode {
601 SpriteLightMode::FaceNormal => shade_dynamic(albedo, 1.0, n_world, center, lights, tester),
602 SpriteLightMode::WorldUp => {
603 shade_dynamic(albedo, 1.0, SPRITE_WORLD_UP, center, lights, tester)
604 }
605 SpriteLightMode::AmbientOnly => {
606 let mut amb = *lights;
607 amb.sun = false;
608 amb.points = &[];
609 amb.bands = 0; shade_dynamic(albedo, 1.0, n_world, center, &amb, None)
611 }
612 SpriteLightMode::FullBright => f32_to_rgb(albedo),
614 }
615}
616
617#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
624fn cast_local_layers(
625 dense: &SpriteDense,
626 origin: [f32; 3],
627 dir: [f32; 3],
628 fwd_dot: f32,
629 max_t: f32,
630 shade_ctx: SpriteShade,
631 s: [f32; 3],
635 h: [f32; 3],
636 f: [f32; 3],
637 pos: [f32; 3],
638 light_mode: SpriteLightMode,
639) -> Option<LayerAccum> {
640 #[allow(clippy::cast_precision_loss)]
641 let hi = [
642 dense.dims[0] as f32,
643 dense.dims[1] as f32,
644 dense.dims[2] as f32,
645 ];
646 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
647 let start = t0 + 1e-4;
648 let p = [
649 origin[0] + dir[0] * start,
650 origin[1] + dir[1] * start,
651 origin[2] + dir[2] * start,
652 ];
653 let mut cell = [
654 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
655 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
656 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
657 ];
658 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
659 let mut t_curr = t0;
660 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
661
662 let mut acc = LayerAccum {
663 rgb: [0.0; 3],
664 trans: 1.0,
665 opaque: None,
666 };
667 let mut touched = false;
668 let mut prev_solid = false;
679 let mut prev_mat = 0u8;
680 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
683 let mut normal = [0.0f32; 3];
687
688 let lights = shade_ctx.lights;
694 let tint = shade_ctx.tint;
695 let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
696 ctx: WorldShadowCtx::identity(occ),
697 });
698 let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3]| -> u32 {
699 if !lights.enabled {
700 return tint_packed(shade(dense.col[idx], 0), tint);
701 }
702 let to_world = |v: [f32; 3]| {
703 [
704 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
705 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
706 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
707 ]
708 };
709 let n_world = to_world(n_local);
710 let rel = [
711 cell[0] as f32 + 0.5 - dense.pivot[0],
712 cell[1] as f32 + 0.5 - dense.pivot[1],
713 cell[2] as f32 + 0.5 - dense.pivot[2],
714 ];
715 let wc = to_world(rel);
716 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
717 let albedo = [
718 ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
719 ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
720 (dense.col[idx] & 0xff) as f32 / 255.0,
721 ];
722 let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
723 tint_packed(
724 shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
725 tint,
726 )
727 };
728
729 for _ in 0..max_steps {
730 if cell[0] < 0
731 || cell[0] >= dense.dims[0]
732 || cell[1] < 0
733 || cell[1] >= dense.dims[1]
734 || cell[2] < 0
735 || cell[2] >= dense.dims[2]
736 || t_curr > t1
737 {
738 break;
739 }
740 let depth = t_curr * fwd_dot;
743 if depth >= max_t {
744 break;
745 }
746 let exit_axis = min_axis(t_max);
749 let t_exit = t_max[exit_axis];
750 let idx = dense.idx_of(cell);
751 let solid_here = dense.occ[idx];
752 if solid_here && depth >= NEAR_Z {
753 let mat_id = if dense.mat.is_empty() {
754 shade_ctx.material
755 } else {
756 dense.mat[idx]
757 };
758 let m = shade_ctx.materials.get(mat_id);
759 if m.is_opaque() {
760 acc.opaque = Some((shade_layer(idx, cell, normal), t_curr));
761 touched = true;
762 break;
763 }
764 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
765 if m.mode == BlendMode::Volumetric {
766 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
770 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
771 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
772 acc.rgb[0] += acc.trans * eff_a * lit[0];
773 acc.rgb[1] += acc.trans * eff_a * lit[1];
774 acc.rgb[2] += acc.trans * eff_a * lit[2];
775 acc.trans *= 1.0 - eff_a;
776 touched = true;
777 prev_mat = mat_id;
778 if acc.trans < 1.0 / 256.0 {
779 break;
780 }
781 } else if !prev_solid || mat_id != prev_mat {
782 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
785 acc.rgb[0] += acc.trans * a * lit[0];
786 acc.rgb[1] += acc.trans * a * lit[1];
787 acc.rgb[2] += acc.trans * a * lit[2];
788 if m.mode == BlendMode::AlphaBlend {
789 acc.trans *= 1.0 - a; }
791 touched = true;
792 prev_mat = mat_id;
793 if acc.trans < 1.0 / 256.0 {
794 break;
795 }
796 }
797 }
798 prev_solid = solid_here;
799 t_curr = t_exit;
800 cell[exit_axis] += step[exit_axis];
801 t_max[exit_axis] += t_delta[exit_axis];
802 normal = [0.0; 3];
803 normal[exit_axis] = -(step[exit_axis] as f32);
804 }
805
806 touched.then_some(acc)
807}
808
809#[allow(
819 clippy::too_many_arguments,
820 clippy::cast_possible_truncation,
821 clippy::cast_sign_loss
822)]
823#[must_use]
824pub fn draw_sprite_dda(
825 fb: &mut [u32],
826 zb: &mut [f32],
827 pitch_pixels: usize,
828 width: u32,
829 height: u32,
830 cam: &CameraState,
831 settings: &OpticastSettings,
832 sprite: &Sprite,
833) -> u32 {
834 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
835 return 0;
836 }
837 draw_sprite_dda_shaded(
838 fb,
839 zb,
840 pitch_pixels,
841 width,
842 height,
843 cam,
844 settings,
845 sprite,
846 None,
847 )
848}
849
850#[allow(clippy::too_many_arguments)]
855#[must_use]
856pub fn draw_sprite_dda_shaded(
857 fb: &mut [u32],
858 zb: &mut [f32],
859 pitch_pixels: usize,
860 width: u32,
861 height: u32,
862 cam: &CameraState,
863 settings: &OpticastSettings,
864 sprite: &Sprite,
865 shade_ctx: Option<SpriteShade>,
866) -> u32 {
867 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
868 return 0;
869 }
870 let dense = if sprite.material_map.is_empty() {
875 SpriteDense::from_kv6(&sprite.kv6)
876 } else {
877 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
878 };
879 draw_sprite_dense_shaded(
880 fb,
881 zb,
882 pitch_pixels,
883 width,
884 height,
885 cam,
886 settings,
887 &dense,
888 sprite.p,
889 sprite.s,
890 sprite.h,
891 sprite.f,
892 sprite.flags,
893 shade_ctx,
894 )
895}
896
897#[allow(clippy::too_many_arguments)]
906#[must_use]
907pub fn draw_sprite_dense(
908 fb: &mut [u32],
909 zb: &mut [f32],
910 pitch_pixels: usize,
911 width: u32,
912 height: u32,
913 cam: &CameraState,
914 settings: &OpticastSettings,
915 dense: &SpriteDense,
916 pos: [f32; 3],
917 s: [f32; 3],
918 h: [f32; 3],
919 f: [f32; 3],
920 flags: u32,
921) -> u32 {
922 draw_sprite_dense_shaded(
923 fb,
924 zb,
925 pitch_pixels,
926 width,
927 height,
928 cam,
929 settings,
930 dense,
931 pos,
932 s,
933 h,
934 f,
935 flags,
936 None,
937 )
938}
939
940#[allow(
952 clippy::too_many_arguments,
953 clippy::cast_possible_truncation,
954 clippy::cast_sign_loss
955)]
956#[must_use]
957pub fn draw_sprite_dense_shaded(
958 fb: &mut [u32],
959 zb: &mut [f32],
960 pitch_pixels: usize,
961 width: u32,
962 height: u32,
963 cam: &CameraState,
964 settings: &OpticastSettings,
965 dense: &SpriteDense,
966 pos: [f32; 3],
967 s: [f32; 3],
968 h: [f32; 3],
969 f: [f32; 3],
970 flags: u32,
971 shade_ctx: Option<SpriteShade>,
972) -> u32 {
973 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
974 return 0;
975 }
976 let sc = dense.voxel_world_size;
981 let (s, h, f) = (bb_scale3(s, sc), bb_scale3(h, sc), bb_scale3(f, sc));
982 let Some(minv) = invert_basis(s, h, f) else {
983 return 0;
984 };
985 let pivot = dense.pivot;
986 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
987 let light_mode = SpriteLightMode::from_flags(flags);
989
990 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
992 return 0;
993 };
994
995 let layers =
1001 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
1002
1003 debug_assert_eq!(fb.len(), zb.len());
1004 let target = RasterTarget::new(fb, zb);
1005 let draw_row = |py: u32| -> u32 {
1009 let mut written = 0u32;
1010 let row = py as usize * pitch_pixels;
1011 for px in rect.0..rect.2 {
1012 let (origin, dir) = pixel_ray(cam, settings, px, py);
1013 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
1015 let ol = mat_apply(&minv, rel);
1016 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
1017 let dir_local = mat_apply(&minv, dir);
1018 let fwd_dot =
1019 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
1020 let idx = row + px as usize;
1021
1022 if let Some(shade_ctx) = layers {
1023 if fwd_dot <= 1e-6 {
1025 continue;
1026 }
1027 let max_t = if no_z {
1032 f32::INFINITY
1033 } else {
1034 unsafe { target.read_depth(idx) }
1035 };
1036 let Some(acc) = cast_local_layers(
1037 dense,
1038 origin_local,
1039 dir_local,
1040 fwd_dot,
1041 max_t,
1042 shade_ctx,
1043 s,
1044 h,
1045 f,
1046 pos,
1047 light_mode,
1048 ) else {
1049 continue;
1050 };
1051 let wrote = unsafe {
1053 match acc.opaque {
1054 Some((bg_color, t)) => {
1055 let bg = rgb_to_f32(bg_color);
1058 let out = f32_to_rgb([
1059 acc.rgb[0] + acc.trans * bg[0],
1060 acc.rgb[1] + acc.trans * bg[1],
1061 acc.rgb[2] + acc.trans * bg[2],
1062 ]);
1063 let depth = t * fwd_dot;
1064 if no_z {
1065 target.write_color(idx, out);
1066 target.write_depth(idx, depth);
1067 true
1068 } else {
1069 target.z_test_write(idx, out, depth)
1070 }
1071 }
1072 None => {
1073 let bg = rgb_to_f32(target.read_color(idx));
1078 let out = f32_to_rgb([
1079 acc.rgb[0] + acc.trans * bg[0],
1080 acc.rgb[1] + acc.trans * bg[1],
1081 acc.rgb[2] + acc.trans * bg[2],
1082 ]);
1083 target.write_color(idx, out);
1084 true
1085 }
1086 }
1087 };
1088 written += u32::from(wrote);
1089 } else {
1090 let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1092 else {
1093 continue;
1094 };
1095 let depth = t * fwd_dot;
1096 if depth < NEAR_Z {
1097 continue;
1098 }
1099 let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1104 let lit = if dl.enabled {
1105 let to_world = |v: [f32; 3]| {
1106 [
1107 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1108 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1109 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1110 ]
1111 };
1112 let n_world = to_world(n_local);
1113 let rel = [
1114 cell[0] as f32 + 0.5 - pivot[0],
1115 cell[1] as f32 + 0.5 - pivot[1],
1116 cell[2] as f32 + 0.5 - pivot[2],
1117 ];
1118 let wc = to_world(rel);
1119 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1120 let albedo = [
1121 ((color >> 16) & 0xff) as f32 / 255.0,
1122 ((color >> 8) & 0xff) as f32 / 255.0,
1123 (color & 0xff) as f32 / 255.0,
1124 ];
1125 let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1129 ctx: WorldShadowCtx::identity(occ),
1130 });
1131 let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1132 shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1133 } else {
1134 shade(color, 0)
1135 };
1136 let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1138 let wrote = unsafe {
1141 if no_z {
1142 target.write_color(idx, lit);
1143 target.write_depth(idx, depth);
1144 true
1145 } else {
1146 target.z_test_write(idx, lit, depth)
1147 }
1148 };
1149 written += u32::from(wrote);
1150 }
1151 }
1152 written
1153 };
1154 let rows = rect.3.saturating_sub(rect.1) as usize;
1159 let cols = rect.2.saturating_sub(rect.0) as usize;
1160 const SPRITE_PAR_MIN_PIXELS: usize = 64 * 64;
1161 if rows >= 2 && rows * cols >= SPRITE_PAR_MIN_PIXELS {
1162 use rayon::prelude::*;
1163 (rect.1..rect.3).into_par_iter().map(draw_row).sum()
1164 } else {
1165 (rect.1..rect.3).map(draw_row).sum()
1166 }
1167}
1168
1169#[allow(
1173 clippy::cast_possible_truncation,
1174 clippy::cast_sign_loss,
1175 clippy::cast_precision_loss
1176)]
1177fn project_screen_rect(
1178 dense: &SpriteDense,
1179 pos: [f32; 3],
1180 s: [f32; 3],
1181 h: [f32; 3],
1182 f: [f32; 3],
1183 cam: &CameraState,
1184 settings: &OpticastSettings,
1185 width: u32,
1186 height: u32,
1187) -> Option<(u32, u32, u32, u32)> {
1188 let (xs, ys, zs) = (
1189 dense.dims[0] as f32,
1190 dense.dims[1] as f32,
1191 dense.dims[2] as f32,
1192 );
1193 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1194 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1195 let mut all_front = true;
1196 for &cx in &[0.0, xs] {
1197 for &cy in &[0.0, ys] {
1198 for &cz in &[0.0, zs] {
1199 let lx = cx - xp;
1201 let ly = cy - yp;
1202 let lz = cz - zp;
1203 let world = [
1204 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1205 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1206 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1207 ];
1208 let rel = [
1209 world[0] - cam.pos[0],
1210 world[1] - cam.pos[1],
1211 world[2] - cam.pos[2],
1212 ];
1213 let cz_cam =
1214 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1215 if cz_cam < NEAR_Z {
1216 all_front = false;
1217 continue;
1218 }
1219 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1220 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1221 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1222 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1223 x0 = x0.min(sx);
1224 y0 = y0.min(sy);
1225 x1 = x1.max(sx);
1226 y1 = y1.max(sy);
1227 }
1228 }
1229 }
1230 let (w, h) = (width as f32, height as f32);
1231 let (rx0, ry0, rx1, ry1) = if all_front {
1232 (
1233 (x0 - 1.0).max(0.0),
1234 (y0 - 1.0).max(0.0),
1235 (x1 + 1.0).min(w),
1236 (y1 + 1.0).min(h),
1237 )
1238 } else {
1239 (0.0, 0.0, w, h)
1241 };
1242 if rx0 >= rx1 || ry0 >= ry1 {
1243 return None;
1244 }
1245 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1246}
1247
1248pub struct ClipFlipbook {
1255 frames: Vec<Arc<SpriteDense>>,
1258}
1259
1260impl ClipFlipbook {
1261 #[must_use]
1264 pub fn empty() -> Self {
1265 Self { frames: Vec::new() }
1266 }
1267
1268 #[must_use]
1270 pub fn from_decoded(clip: &DecodedClip) -> Self {
1271 Self::from_decoded_with_materials(clip, &[])
1272 }
1273
1274 #[must_use]
1280 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(Rgb, u8)]) -> Self {
1281 let frames = clip
1282 .frames
1283 .iter()
1284 .map(|frame| {
1285 Arc::new(
1286 SpriteDense::from_voxel_frame_with_materials(
1287 frame,
1288 clip.dims,
1289 clip.pivot,
1290 material_map,
1291 )
1292 .with_voxel_world_size(clip.voxel_world_size),
1293 )
1294 })
1295 .collect();
1296 Self { frames }
1297 }
1298
1299 #[must_use]
1302 pub fn frame_count(&self) -> usize {
1303 self.frames.len()
1304 }
1305
1306 #[must_use]
1308 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1309 self.frames.get(frame).map(Arc::as_ref)
1310 }
1311
1312 #[must_use]
1315 pub fn frame_arc(&self, frame: usize) -> Option<Arc<SpriteDense>> {
1316 self.frames.get(frame).cloned()
1317 }
1318
1319 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1323 match self.frames.get_mut(frame) {
1324 Some(slot) => {
1325 *slot = Arc::new(dense);
1326 true
1327 }
1328 None => false,
1329 }
1330 }
1331
1332 #[allow(clippy::too_many_arguments)]
1336 #[must_use]
1337 pub fn draw_frame(
1338 &self,
1339 fb: &mut [u32],
1340 zb: &mut [f32],
1341 pitch_pixels: usize,
1342 width: u32,
1343 height: u32,
1344 cam: &CameraState,
1345 settings: &OpticastSettings,
1346 frame: usize,
1347 pos: [f32; 3],
1348 s: [f32; 3],
1349 h: [f32; 3],
1350 f: [f32; 3],
1351 flags: u32,
1352 ) -> u32 {
1353 self.draw_frame_shaded(
1354 fb,
1355 zb,
1356 pitch_pixels,
1357 width,
1358 height,
1359 cam,
1360 settings,
1361 frame,
1362 pos,
1363 s,
1364 h,
1365 f,
1366 flags,
1367 None,
1368 )
1369 }
1370
1371 #[allow(clippy::too_many_arguments)]
1376 #[must_use]
1377 pub fn draw_frame_shaded(
1378 &self,
1379 fb: &mut [u32],
1380 zb: &mut [f32],
1381 pitch_pixels: usize,
1382 width: u32,
1383 height: u32,
1384 cam: &CameraState,
1385 settings: &OpticastSettings,
1386 frame: usize,
1387 pos: [f32; 3],
1388 s: [f32; 3],
1389 h: [f32; 3],
1390 f: [f32; 3],
1391 flags: u32,
1392 shade_ctx: Option<SpriteShade>,
1393 ) -> u32 {
1394 let Some(dense) = self.frames.get(frame) else {
1395 return 0;
1396 };
1397 draw_sprite_dense_shaded(
1398 fb,
1399 zb,
1400 pitch_pixels,
1401 width,
1402 height,
1403 cam,
1404 settings,
1405 dense,
1406 pos,
1407 s,
1408 h,
1409 f,
1410 flags,
1411 shade_ctx,
1412 )
1413 }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::*;
1419 use crate::camera_math;
1420 use crate::Camera;
1421 use roxlap_formats::kv6::Kv6;
1422 use roxlap_formats::material::{Material, MaterialTable};
1423 use roxlap_formats::VoxColor;
1424
1425 #[test]
1428 fn sprite_light_mode_world_up_and_ambient_only() {
1429 let lights = CpuLights {
1430 enabled: true,
1431 sun: true,
1432 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0, 1.0, 1.0],
1434 sun_intensity: 1.0,
1435 sun_casts_shadow: false,
1436 points: &[],
1437 ambient: [0.2, 0.2, 0.2],
1438 bands: 0,
1439 shadow_tint: [0.0; 3],
1440 shadow_strength: 0.0,
1441 shadow_bias: 0.0,
1442 shadow_max_dist: 0.0,
1443 };
1444 let a = [1.0, 1.0, 1.0];
1445 let c = [0.0, 0.0, 0.0];
1446 let g = |packed: u32| (packed >> 8) & 0xff; let up_n = [0.0, 0.0, -1.0];
1448 let side_n = [1.0, 0.0, 0.0];
1449 let face_up = g(shade_dynamic_mode(
1450 SpriteLightMode::FaceNormal,
1451 a,
1452 up_n,
1453 c,
1454 &lights,
1455 None,
1456 ));
1457 let face_side = g(shade_dynamic_mode(
1458 SpriteLightMode::FaceNormal,
1459 a,
1460 side_n,
1461 c,
1462 &lights,
1463 None,
1464 ));
1465 let amb = g(shade_dynamic_mode(
1466 SpriteLightMode::AmbientOnly,
1467 a,
1468 up_n,
1469 c,
1470 &lights,
1471 None,
1472 ));
1473 let world_up = g(shade_dynamic_mode(
1474 SpriteLightMode::WorldUp,
1475 a,
1476 side_n,
1477 c,
1478 &lights,
1479 None,
1480 ));
1481 assert!(
1482 face_up > face_side,
1483 "a sun-facing face is brighter than a side face"
1484 );
1485 assert!(amb < face_up, "ambient-only drops the sun term");
1486 assert_eq!(
1487 world_up, face_up,
1488 "world-up shades a side-facing billboard as if it faced up"
1489 );
1490 let full = g(shade_dynamic_mode(
1491 SpriteLightMode::FullBright,
1492 a,
1493 side_n,
1494 c,
1495 &lights,
1496 None,
1497 ));
1498 assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1501 assert!(full > amb, "full-bright glow is brighter than ambient-only");
1502 }
1503
1504 #[test]
1509 fn cast_local_reports_face_normal() {
1510 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| {
1512 (z >= 4).then_some(VoxColor(0x80_C0_40_20))
1513 });
1514 let dense = SpriteDense::from_kv6(&kv6);
1515 let (_c, _t, n, cell) =
1517 cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1518 assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1519 assert!(
1520 n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1521 "z-crossing face normal points back toward the ray (-z): {n:?}",
1522 );
1523 }
1524 use roxlap_formats::sprite::Sprite;
1525 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1526
1527 fn settings(w: u32, h: u32) -> OpticastSettings {
1528 OpticastSettings::for_oracle_framebuffer(w, h)
1529 }
1530
1531 fn cam_looking_y() -> Camera {
1533 Camera {
1534 pos: [0.0, 0.0, 0.0],
1535 right: [1.0, 0.0, 0.0],
1536 down: [0.0, 0.0, 1.0],
1537 forward: [0.0, 1.0, 0.0],
1538 }
1539 }
1540
1541 #[test]
1548 fn scaled_basis_scales_drawn_extent() {
1549 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, _| Some(VoxColor(0x80_C0_40_20)));
1550 let (w, h) = (64u32, 64u32);
1551 let n = (w * h) as usize;
1552 let cam = cam_looking_y();
1553 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1554 let cfg = settings(w, h);
1555
1556 let px_at = |k: f32| -> u32 {
1557 let mut sprite = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1558 for a in 0..3 {
1559 sprite.s[a] *= k;
1560 sprite.h[a] *= k;
1561 sprite.f[a] *= k;
1562 }
1563 let mut fb = vec![0u32; n];
1564 let mut zb = vec![f32::INFINITY; n];
1565 draw_sprite_dda(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, &sprite)
1566 };
1567
1568 let (unit, double, half) = (px_at(1.0), px_at(2.0), px_at(0.5));
1569 assert!(unit > 0, "unit-scale cube must draw ({unit} px)");
1570 let r2 = f64::from(double) / f64::from(unit);
1571 let rh = f64::from(half) / f64::from(unit);
1572 assert!(
1573 (3.0..8.0).contains(&r2),
1574 "2× scale should roughly quadruple coverage: {unit} → {double} px (×{r2:.2})"
1575 );
1576 assert!(
1577 (0.08..0.5).contains(&rh),
1578 "0.5× scale should roughly quarter coverage: {unit} → {half} px (×{rh:.2})"
1579 );
1580 }
1581
1582 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1584 let owpc = dims[2].div_ceil(32).max(1) as usize;
1585 let cols = (dims[0] * dims[1]) as usize;
1586 let mut occupancy = vec![0u32; cols * owpc];
1587 let mut color_offsets = vec![0u32; cols + 1];
1588 let mut colors = Vec::new();
1589 for y in 0..dims[1] {
1590 for x in 0..dims[0] {
1591 let col = (x + y * dims[0]) as usize;
1592 color_offsets[col] = colors.len() as u32;
1593 for z in 0..dims[2] {
1594 if let Some(c) = fill(x, y, z) {
1595 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1596 colors.push(c);
1597 }
1598 }
1599 }
1600 }
1601 color_offsets[cols] = colors.len() as u32;
1602 VoxelFrame {
1603 occupancy,
1604 colors,
1605 color_offsets,
1606 }
1607 }
1608
1609 #[test]
1614 fn clip_flipbook_frames_render_differently() {
1615 let dims = [8u32, 8, 8];
1616 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(
1619 dims,
1620 [4.0, 4.0, 4.0],
1621 1.0,
1622 LoopMode::Loop,
1623 &[f0, f1],
1624 &[],
1625 33,
1626 0,
1627 );
1628 let decoded = clip.decode().expect("decode");
1629 let book = ClipFlipbook::from_decoded(&decoded);
1630 assert_eq!(book.frame_count(), 2);
1631 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1632
1633 let (w, h) = (64u32, 64u32);
1634 let n = (w * h) as usize;
1635 let cam = cam_looking_y();
1636 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1637 let cfg = settings(w, h);
1638 let pose = [0.0, 40.0, 0.0];
1639 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1640
1641 let render = |frame: usize| -> Vec<u32> {
1642 let mut fb = vec![0u32; n];
1643 let mut zb = vec![f32::INFINITY; n];
1644 let wrote = book.draw_frame(
1645 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1646 );
1647 assert!(wrote > 0, "frame {frame} should draw some pixels");
1648 fb
1649 };
1650 let fb0 = render(0);
1651 let fb1 = render(1);
1652 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1653 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1656 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1657 let mut fb = vec![0u32; n];
1659 let mut zb = vec![f32::INFINITY; n];
1660 assert_eq!(
1661 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1662 0
1663 );
1664 }
1665
1666 #[test]
1667 fn clip_flipbook_set_frame_replaces_one_frame() {
1668 let dims = [8u32, 8, 8];
1671 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 =
1674 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1675 let decoded = clip.decode().unwrap();
1676 let mut book = ClipFlipbook::from_decoded(&decoded);
1677
1678 let (w, h) = (64u32, 64u32);
1679 let n = (w * h) as usize;
1680 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1681 let cfg = settings(w, h);
1682 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1683 let mut fb = vec![0u32; n];
1684 let mut zb = vec![f32::INFINITY; n];
1685 let _ = b.draw_frame(
1686 &mut fb,
1687 &mut zb,
1688 w as usize,
1689 w,
1690 h,
1691 &cs,
1692 &cfg,
1693 0,
1694 [0.0, 40.0, 0.0],
1695 [1.0, 0.0, 0.0],
1696 [0.0, 1.0, 0.0],
1697 [0.0, 0.0, 1.0],
1698 0,
1699 );
1700 fb
1701 };
1702
1703 let before = render0(&book);
1704 assert!(
1705 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1706 "frame 0 is red"
1707 );
1708
1709 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1711 assert!(book.set_frame(0, replacement));
1712 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1713 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1714
1715 let after = render0(&book);
1716 assert!(
1717 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1718 "frame 0 now green"
1719 );
1720 assert_ne!(before, after);
1721 }
1722
1723 #[test]
1726 fn cube_sprite_renders() {
1727 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_C0_40_20));
1728 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1729 let (w, h) = (64u32, 64u32);
1730 let n = (w * h) as usize;
1731 let mut fb = vec![0u32; n];
1732 let mut zb = vec![f32::INFINITY; n];
1733 let cam = cam_looking_y();
1734 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1735 let wrote = draw_sprite_dda(
1736 &mut fb,
1737 &mut zb,
1738 w as usize,
1739 w,
1740 h,
1741 &cs,
1742 &settings(w, h),
1743 &sprite,
1744 );
1745
1746 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1747 let centre = (h / 2 * w + w / 2) as usize;
1748 assert_eq!(
1749 fb[centre] & 0x00ff_ffff,
1750 0x00_C0_40_20,
1751 "got {:08x}",
1752 fb[centre]
1753 );
1754 assert!(
1756 (zb[centre] - 36.0).abs() < 3.0,
1757 "centre depth {} not ≈ 36",
1758 zb[centre]
1759 );
1760 }
1761
1762 #[test]
1767 fn zero_high_byte_sprite_not_black() {
1768 let kv6 = Kv6::solid_cube(8, VoxColor(0x00_C0_40_20));
1769 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1770 let (w, h) = (64u32, 64u32);
1771 let n = (w * h) as usize;
1772 let mut fb = vec![0u32; n];
1773 let mut zb = vec![f32::INFINITY; n];
1774 let cam = cam_looking_y();
1775 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1776 let wrote = draw_sprite_dda(
1777 &mut fb,
1778 &mut zb,
1779 w as usize,
1780 w,
1781 h,
1782 &cs,
1783 &settings(w, h),
1784 &sprite,
1785 );
1786 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1787 let centre = (h / 2 * w + w / 2) as usize;
1788 assert_eq!(
1789 fb[centre] & 0x00ff_ffff,
1790 0x00_C0_40_20,
1791 "zero-high-byte sprite rendered as {:08x} (black bug)",
1792 fb[centre]
1793 );
1794 }
1795
1796 #[test]
1799 fn sprite_respects_zbuffer() {
1800 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1801 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1802 let (w, h) = (32u32, 32u32);
1803 let n = (w * h) as usize;
1804 let cam = cam_looking_y();
1805 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1806 let centre = (h / 2 * w + w / 2) as usize;
1807
1808 let mut fb = vec![0u32; n];
1810 let mut zb = vec![f32::INFINITY; n];
1811 fb[centre] = 0x80_11_22_33;
1812 zb[centre] = 10.0;
1813 let _ = draw_sprite_dda(
1814 &mut fb,
1815 &mut zb,
1816 w as usize,
1817 w,
1818 h,
1819 &cs,
1820 &settings(w, h),
1821 &sprite,
1822 );
1823 assert_eq!(
1824 fb[centre], 0x80_11_22_33,
1825 "near terrain must occlude sprite"
1826 );
1827
1828 let mut fb2 = vec![0u32; n];
1830 let mut zb2 = vec![f32::INFINITY; n];
1831 fb2[centre] = 0x80_11_22_33;
1832 zb2[centre] = 100.0;
1833 let _ = draw_sprite_dda(
1834 &mut fb2,
1835 &mut zb2,
1836 w as usize,
1837 w,
1838 h,
1839 &cs,
1840 &settings(w, h),
1841 &sprite,
1842 );
1843 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1844 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1845 }
1846
1847 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1850 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1851 for py in 0..h {
1852 for px in 0..w {
1853 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1854 x0 = x0.min(px);
1855 y0 = y0.min(py);
1856 x1 = x1.max(px);
1857 y1 = y1.max(py);
1858 }
1859 }
1860 }
1861 (x0, y0, x1, y1)
1862 }
1863
1864 #[test]
1869 fn posed_basis_reorients_silhouette() {
1870 let kv6 = Kv6::solid_box(16, 4, 4, VoxColor(0x80_C0_40_20));
1873 let (w, h) = (64u32, 64u32);
1874 let n = (w * h) as usize;
1875 let cam = cam_looking_y();
1876 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1877
1878 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1880 let mut fb = vec![0u32; n];
1881 let mut zb = vec![f32::INFINITY; n];
1882 let _ = draw_sprite_dda(
1883 &mut fb,
1884 &mut zb,
1885 w as usize,
1886 w,
1887 h,
1888 &cs,
1889 &settings(w, h),
1890 &aa,
1891 );
1892 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1893 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1894 assert!(
1895 aa_wide > 4,
1896 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1897 );
1898
1899 let mut posed = aa.clone();
1902 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];
1906 let mut zb2 = vec![f32::INFINITY; n];
1907 let _ = draw_sprite_dda(
1908 &mut fb2,
1909 &mut zb2,
1910 w as usize,
1911 w,
1912 h,
1913 &cs,
1914 &settings(w, h),
1915 &posed,
1916 );
1917 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1918 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1919 assert!(
1920 posed_tall > 4,
1921 "posed box should be taller than wide (got h-w={posed_tall})"
1922 );
1923 }
1924
1925 #[test]
1928 fn degenerate_basis_draws_nothing() {
1929 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1930 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1931 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1933 let n = (w * h) as usize;
1934 let mut fb = vec![0u32; n];
1935 let mut zb = vec![f32::INFINITY; n];
1936 let cam = cam_looking_y();
1937 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1938 let wrote = draw_sprite_dda(
1939 &mut fb,
1940 &mut zb,
1941 w as usize,
1942 w,
1943 h,
1944 &cs,
1945 &settings(w, h),
1946 &sprite,
1947 );
1948 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1949 }
1950
1951 #[test]
1953 fn invisible_sprite_skipped() {
1954 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF));
1955 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1956 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1957 let (w, h) = (32u32, 32u32);
1958 let n = (w * h) as usize;
1959 let mut fb = vec![0u32; n];
1960 let mut zb = vec![f32::INFINITY; n];
1961 let cam = cam_looking_y();
1962 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1963 let wrote = draw_sprite_dda(
1964 &mut fb,
1965 &mut zb,
1966 w as usize,
1967 w,
1968 h,
1969 &cs,
1970 &settings(w, h),
1971 &sprite,
1972 );
1973 assert_eq!(wrote, 0);
1974 }
1975
1976 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1982 let mut table = MaterialTable::new();
1983 table.set(1, mat);
1984 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
1985 let (w, h) = (64u32, 64u32);
1986 let n = (w * h) as usize;
1987 let mut fb = vec![bg; n];
1988 let mut zb = vec![zb_v; n];
1989 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1990 let sh = SpriteShade {
1991 materials: &table,
1992 lights: CpuLights::default(),
1993 material: 1,
1994 alpha_mul,
1995 tint: 0x00FF_FFFF,
1996 shadow: None,
1997 };
1998 let _ = draw_sprite_dense_shaded(
1999 &mut fb,
2000 &mut zb,
2001 w as usize,
2002 w,
2003 h,
2004 &cs,
2005 &settings(w, h),
2006 &dense,
2007 [0.0, 40.0, 0.0],
2008 [1.0, 0.0, 0.0],
2009 [0.0, 1.0, 0.0],
2010 [0.0, 0.0, 1.0],
2011 0,
2012 Some(sh),
2013 );
2014 (fb[(h / 2 * w + w / 2) as usize], fb)
2015 }
2016
2017 #[test]
2020 fn additive_sprite_brightens_background() {
2021 let bg = 0x80_20_20_20;
2022 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
2023 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
2024 assert!(
2025 cr > 0x20 && cg > 0x20 && cb >= 0x20,
2026 "centre {centre:08x} should be brighter than bg"
2027 );
2028 assert!(
2030 cr >= cg && cr >= cb,
2031 "additive of a red-dominant cube stays red-dominant"
2032 );
2033 }
2034
2035 #[test]
2038 fn alpha_blend_sprite_between_bg_and_color() {
2039 let bg = 0x80_20_20_20;
2040 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
2041 let cr = (centre >> 16) & 0xff;
2042 assert!(
2043 cr > 0x20,
2044 "blended red must rise above bg 0x20 (got {cr:02x})"
2045 );
2046 assert!(
2047 cr < 0xC0,
2048 "blended red must stay below opaque 0xC0 (got {cr:02x})"
2049 );
2050 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
2052 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
2053 }
2054
2055 #[test]
2058 fn alpha_mul_scales_opacity() {
2059 let bg = 0x80_20_20_20;
2060 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
2061 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
2062 let r_full = (full >> 16) & 0xff;
2063 let r_faded = (faded >> 16) & 0xff;
2064 assert!(
2066 r_full > r_faded,
2067 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
2068 );
2069 assert!(r_faded > 0x20, "even faded lifts above bg");
2070 }
2071
2072 #[test]
2076 fn opaque_shade_ctx_matches_plain_path() {
2077 let table = MaterialTable::new();
2078 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_C0_40_20)));
2079 let (w, h) = (64u32, 64u32);
2080 let n = (w * h) as usize;
2081 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2082 let pose = (
2083 [0.0, 40.0, 0.0],
2084 [1.0, 0.0, 0.0],
2085 [0.0, 1.0, 0.0],
2086 [0.0, 0.0, 1.0],
2087 );
2088
2089 let mut fb_plain = vec![0u32; n];
2090 let mut zb_plain = vec![f32::INFINITY; n];
2091 let _ = draw_sprite_dense(
2092 &mut fb_plain,
2093 &mut zb_plain,
2094 w as usize,
2095 w,
2096 h,
2097 &cs,
2098 &settings(w, h),
2099 &dense,
2100 pose.0,
2101 pose.1,
2102 pose.2,
2103 pose.3,
2104 0,
2105 );
2106
2107 let mut fb_sh = vec![0u32; n];
2108 let mut zb_sh = vec![f32::INFINITY; n];
2109 let sh = SpriteShade {
2110 materials: &table,
2111 lights: CpuLights::default(),
2112 material: 0, alpha_mul: 255,
2114 tint: 0x00FF_FFFF,
2115 shadow: None,
2116 };
2117 let _ = draw_sprite_dense_shaded(
2118 &mut fb_sh,
2119 &mut zb_sh,
2120 w as usize,
2121 w,
2122 h,
2123 &cs,
2124 &settings(w, h),
2125 &dense,
2126 pose.0,
2127 pose.1,
2128 pose.2,
2129 pose.3,
2130 0,
2131 Some(sh),
2132 );
2133
2134 assert_eq!(
2135 fb_plain, fb_sh,
2136 "opaque shade-ctx must match the plain path bit-for-bit"
2137 );
2138 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2139 }
2140
2141 #[test]
2145 fn translucent_sprite_occluded_by_near_terrain() {
2146 let bg = 0x80_20_20_20;
2147 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2148 assert_eq!(
2149 centre, bg,
2150 "near terrain (z=5) must occlude the sprite at y≈36"
2151 );
2152 }
2153
2154 #[test]
2160 fn per_span_thickness_independent() {
2161 fn centre(ysiz: u32) -> u32 {
2162 let mut table = MaterialTable::new();
2163 table.set(1, Material::alpha_blend(128));
2164 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, VoxColor(0x80_C0_40_20)));
2165 let (w, h) = (64u32, 64u32);
2166 let n = (w * h) as usize;
2167 let mut fb = vec![0x80_10_10_10u32; n];
2168 let mut zb = vec![f32::INFINITY; n];
2169 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2170 let sh = SpriteShade {
2171 materials: &table,
2172 lights: CpuLights::default(),
2173 material: 1,
2174 alpha_mul: 255,
2175 tint: 0x00FF_FFFF,
2176 shadow: None,
2177 };
2178 let _ = draw_sprite_dense_shaded(
2179 &mut fb,
2180 &mut zb,
2181 w as usize,
2182 w,
2183 h,
2184 &cs,
2185 &settings(w, h),
2186 &dense,
2187 [0.0, 40.0, 0.0],
2188 [1.0, 0.0, 0.0],
2189 [0.0, 1.0, 0.0],
2190 [0.0, 0.0, 1.0],
2191 0,
2192 Some(sh),
2193 );
2194 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2195 }
2196 assert_eq!(
2200 centre(1),
2201 centre(2),
2202 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2203 );
2204 }
2205
2206 #[test]
2211 fn volumetric_thickness_deepens_opacity() {
2212 fn red_at(depth: u32) -> u32 {
2215 let mut table = MaterialTable::new();
2216 table.set(1, Material::volumetric(128));
2217 let kv6 = Kv6::from_fn_keep_interior(
2222 8,
2223 depth,
2224 8,
2225 |_, _, _| Some(VoxColor(0x80_C0_20_20)),
2226 |_| true,
2227 );
2228 let dense = SpriteDense::from_kv6(&kv6);
2229 let (w, h) = (64u32, 64u32);
2230 let n = (w * h) as usize;
2231 let mut fb = vec![0x80_10_10_10u32; n];
2232 let mut zb = vec![f32::INFINITY; n];
2233 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2234 let sh = SpriteShade {
2235 materials: &table,
2236 lights: CpuLights::default(),
2237 material: 1,
2238 alpha_mul: 255,
2239 tint: 0x00FF_FFFF,
2240 shadow: None,
2241 };
2242 let _ = draw_sprite_dense_shaded(
2243 &mut fb,
2244 &mut zb,
2245 w as usize,
2246 w,
2247 h,
2248 &cs,
2249 &settings(w, h),
2250 &dense,
2251 [0.0, 40.0, 0.0],
2252 [1.0, 0.0, 0.0],
2253 [0.0, 1.0, 0.0],
2254 [0.0, 0.0, 1.0],
2255 0,
2256 Some(sh),
2257 );
2258 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2259 }
2260 let shallow = red_at(1);
2261 let deep = red_at(12);
2262 assert!(
2265 shallow > 0x10,
2266 "even a 1-deep volume tints (got {shallow:02x})"
2267 );
2268 assert!(
2269 deep > shallow,
2270 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2271 );
2272 }
2273
2274 #[test]
2280 fn voxel_world_size_matches_scaled_basis() {
2281 use crate::dda::WorldOccluder;
2282 let kv6 = Kv6::solid_cube(8, VoxColor(0x80_40_C0_40));
2283 let scaled = SpriteDense::from_kv6(&kv6).with_voxel_world_size(2.0);
2284 let unit = SpriteDense::from_kv6(&kv6);
2285
2286 let (w, h) = (64u32, 64u32);
2287 let n = (w * h) as usize;
2288 let draw = |dense: &SpriteDense, basis: f32| {
2289 let mut fb = vec![0x80_10_10_10u32; n];
2290 let mut zb = vec![f32::INFINITY; n];
2291 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2292 let written = draw_sprite_dense(
2293 &mut fb,
2294 &mut zb,
2295 w as usize,
2296 w,
2297 h,
2298 &cs,
2299 &settings(w, h),
2300 dense,
2301 [0.0, 40.0, 0.0],
2302 [basis, 0.0, 0.0],
2303 [0.0, basis, 0.0],
2304 [0.0, 0.0, basis],
2305 0,
2306 );
2307 assert!(written > 0, "sprite must be visible");
2308 fb
2309 };
2310 assert_eq!(
2311 draw(&scaled, 1.0),
2312 draw(&unit, 2.0),
2313 "vws=2 @ unit basis == vws=1 @ 2x basis, pixel for pixel"
2314 );
2315
2316 let occludes = |dense: &SpriteDense, basis: f32, x: f32| {
2317 let mut occ = SpriteOccluder::new();
2318 occ.push(
2319 Arc::new(dense.clone()),
2320 [0.0, 40.0, 0.0],
2321 [basis, 0.0, 0.0],
2322 [0.0, basis, 0.0],
2323 [0.0, 0.0, basis],
2324 );
2325 occ.occluded_world([x, 0.0, 0.0], [0.0, 1.0, 0.0], 100.0)
2326 };
2327 assert!(occludes(&scaled, 1.0, 6.0));
2331 assert!(occludes(&unit, 2.0, 6.0));
2332 assert!(!occludes(&unit, 1.0, 6.0), "unscaled cube ends at 4");
2333 }
2334
2335 #[test]
2340 fn sprite_occluder_blocks_ray_through_volume() {
2341 use crate::dda::WorldOccluder;
2342 let dense = Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2345 8,
2346 VoxColor(0x80_FF_FF_FF),
2347 )));
2348 let mut occ = SpriteOccluder::new();
2349 occ.push(
2350 dense,
2351 [0.0, 0.0, 0.0],
2352 [1.0, 0.0, 0.0],
2353 [0.0, 1.0, 0.0],
2354 [0.0, 0.0, 1.0],
2355 );
2356 assert!(!occ.is_empty());
2357 assert!(
2359 occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2360 "a ray through the cube must be occluded"
2361 );
2362 assert!(
2364 !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2365 "a ray missing the cube must not be occluded"
2366 );
2367 assert!(
2369 !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2370 "max_t shorter than the distance to the cube ⇒ unoccluded"
2371 );
2372 }
2373
2374 #[test]
2379 fn sprite_receives_hard_shadow() {
2380 let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2386 let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2387 (dx * dx + dy * dy + dz * dz <= 49).then_some(VoxColor(0x80_C0_C0_C0))
2388 }));
2389 let mut occ = SpriteOccluder::new();
2390 occ.push(
2391 Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(
2392 8,
2393 VoxColor(0x80_FF_FF_FF),
2394 ))),
2395 [0.0, 25.0, 0.0],
2396 [1.0, 0.0, 0.0],
2397 [0.0, 1.0, 0.0],
2398 [0.0, 0.0, 1.0],
2399 );
2400 let table = MaterialTable::new();
2401 let base = CpuLights {
2402 enabled: true,
2403 sun: true,
2404 sun_dir: [0.0, -1.0, 0.0], sun_color: [1.0; 3],
2406 sun_intensity: 1.0,
2407 sun_casts_shadow: true,
2408 ambient: [0.3; 3],
2409 shadow_strength: 0.85,
2410 shadow_bias: 1.5,
2411 shadow_max_dist: 128.0,
2412 ..CpuLights::default()
2413 };
2414 let (w, h) = (64u32, 64u32);
2415 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2416 let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2417 let n = (w * h) as usize;
2418 let mut fb = vec![0u32; n];
2419 let mut zb = vec![f32::INFINITY; n];
2420 let sh = SpriteShade {
2421 materials: &table,
2422 lights: base,
2423 material: 0,
2424 alpha_mul: 255,
2425 tint: 0x00FF_FFFF,
2426 shadow,
2427 };
2428 let _ = draw_sprite_dense_shaded(
2429 &mut fb,
2430 &mut zb,
2431 w as usize,
2432 w,
2433 h,
2434 &cs,
2435 &settings(w, h),
2436 &target,
2437 [0.0, 40.0, 0.0],
2438 [1.0, 0.0, 0.0],
2439 [0.0, 1.0, 0.0],
2440 [0.0, 0.0, 1.0],
2441 0,
2442 Some(sh),
2443 );
2444 fb.iter()
2445 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2446 .sum()
2447 };
2448 let lit = sum_lum(None);
2449 let shadowed = sum_lum(Some(&occ));
2450 assert!(
2451 shadowed < lit,
2452 "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2453 );
2454 }
2455
2456 #[test]
2459 fn sprite_rgb_tint_recolours() {
2460 let table = MaterialTable::new();
2461 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, VoxColor(0x80_FF_FF_FF)));
2462 let (w, h) = (64u32, 64u32);
2463 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2464 let centre = |tint: u32| -> u32 {
2465 let n = (w * h) as usize;
2466 let mut fb = vec![0u32; n];
2467 let mut zb = vec![f32::INFINITY; n];
2468 let sh = SpriteShade {
2469 materials: &table,
2470 lights: CpuLights::default(),
2471 material: 0,
2472 alpha_mul: 255,
2473 tint,
2474 shadow: None,
2475 };
2476 let _ = draw_sprite_dense_shaded(
2477 &mut fb,
2478 &mut zb,
2479 w as usize,
2480 w,
2481 h,
2482 &cs,
2483 &settings(w, h),
2484 &dense,
2485 [0.0, 40.0, 0.0],
2486 [1.0, 0.0, 0.0],
2487 [0.0, 1.0, 0.0],
2488 [0.0, 0.0, 1.0],
2489 0,
2490 Some(sh),
2491 );
2492 fb[(h / 2 * w + w / 2) as usize]
2493 };
2494 let r = |p: u32| (p >> 16) & 0xff;
2495 let g = |p: u32| (p >> 8) & 0xff;
2496 let b = |p: u32| p & 0xff;
2497 let white = centre(0x00FF_FFFF);
2498 let red = centre(0x00FF_0000);
2499 assert!(
2500 g(white) > 180 && b(white) > 180 && r(white) > 180,
2501 "white tint must be a no-op: {white:#08x}"
2502 );
2503 assert!(
2504 r(red) > 180 && g(red) < 20 && b(red) < 20,
2505 "red tint zeroes green/blue, keeps red: {red:#08x}"
2506 );
2507 }
2508
2509 #[test]
2514 fn translucent_sprite_layers_are_lit() {
2515 fn center_red(lights: CpuLights) -> u32 {
2516 let mut table = MaterialTable::new();
2517 table.set(1, Material::alpha_blend(160));
2518 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, VoxColor(0x80_E0_30_30)));
2519 let (w, h) = (64u32, 64u32);
2520 let n = (w * h) as usize;
2521 let mut fb = vec![0x80_10_10_10u32; n];
2522 let mut zb = vec![f32::INFINITY; n];
2523 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2524 let sh = SpriteShade {
2525 materials: &table,
2526 lights,
2527 material: 1,
2528 alpha_mul: 255,
2529 tint: 0x00FF_FFFF,
2530 shadow: None,
2531 };
2532 let _ = draw_sprite_dense_shaded(
2533 &mut fb,
2534 &mut zb,
2535 w as usize,
2536 w,
2537 h,
2538 &cs,
2539 &settings(w, h),
2540 &dense,
2541 [0.0, 40.0, 0.0],
2542 [1.0, 0.0, 0.0],
2543 [0.0, 1.0, 0.0],
2544 [0.0, 0.0, 1.0],
2545 0,
2546 Some(sh),
2547 );
2548 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2549 }
2550 let baked = center_red(CpuLights::default()); let dim = center_red(CpuLights {
2552 enabled: true,
2553 ambient: [0.3; 3], ..CpuLights::default()
2555 });
2556 assert!(
2557 dim < baked,
2558 "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2559 );
2560 }
2561
2562 #[test]
2567 fn translucent_sprite_tints_opaque_sprite_behind() {
2568 let mut table = MaterialTable::new();
2569 table.set(1, Material::alpha_blend(128));
2570 let (w, h) = (64u32, 64u32);
2571 let n = (w * h) as usize;
2572 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
2574 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2575 let cfg = settings(w, h);
2576 let id = [1.0, 0.0, 0.0];
2577 let up = [0.0, 1.0, 0.0];
2578 let fw = [0.0, 0.0, 1.0];
2579 let centre = (h / 2 * w + w / 2) as usize;
2580
2581 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_FF_00_00)));
2583 let sh_op = SpriteShade {
2584 materials: &table,
2585 lights: CpuLights::default(),
2586 material: 0,
2587 alpha_mul: 255,
2588 tint: 0x00FF_FFFF,
2589 shadow: None,
2590 };
2591 let _ = draw_sprite_dense_shaded(
2592 &mut fb,
2593 &mut zb,
2594 w as usize,
2595 w,
2596 h,
2597 &cs,
2598 &cfg,
2599 &backdrop,
2600 [0.0, 80.0, 0.0],
2601 id,
2602 up,
2603 fw,
2604 0,
2605 Some(sh_op),
2606 );
2607 let after_backdrop = fb[centre];
2608 assert_eq!(
2609 after_backdrop & 0x00ff_ffff,
2610 0x00FF_0000,
2611 "backdrop red must be drawn first"
2612 );
2613
2614 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, VoxColor(0x80_00_FF_FF)));
2616 let sh_gl = SpriteShade {
2617 materials: &table,
2618 lights: CpuLights::default(),
2619 material: 1,
2620 alpha_mul: 255,
2621 tint: 0x00FF_FFFF,
2622 shadow: None,
2623 };
2624 let wrote = draw_sprite_dense_shaded(
2625 &mut fb,
2626 &mut zb,
2627 w as usize,
2628 w,
2629 h,
2630 &cs,
2631 &cfg,
2632 &glass,
2633 [0.0, 40.0, 0.0],
2634 id,
2635 up,
2636 fw,
2637 0,
2638 Some(sh_gl),
2639 );
2640 let _ = wrote;
2641 let after_glass = fb[centre];
2642 assert_ne!(
2643 after_glass, after_backdrop,
2644 "glass must tint the backdrop (composite over it)"
2645 );
2646 assert!(
2648 (after_glass >> 16) & 0xff < 0xFF,
2649 "glass should reduce the backdrop's red (got {after_glass:08x})"
2650 );
2651 }
2652
2653 #[test]
2656 fn from_kv6_with_materials_classifies_by_color() {
2657 let col = VoxColor(0x80_AA_BB_CC);
2658 let kv6 = Kv6::solid_cube(6, col);
2659 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x00AA_BBCC), 2)]);
2660 assert_eq!(
2661 dense.mat.len(),
2662 dense.col.len(),
2663 "per-voxel mat array sized"
2664 );
2665 let mut solids = 0;
2666 for idx in 0..dense.occ.len() {
2667 if dense.occ[idx] {
2668 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2669 solids += 1;
2670 }
2671 }
2672 assert!(solids > 0, "cube has solid voxels");
2673 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(Rgb(0x0012_3456), 5)]);
2675 assert!(
2676 dense0.mat.iter().all(|&m| m == 0),
2677 "unmapped colour → material 0"
2678 );
2679 }
2680
2681 #[test]
2686 fn per_voxel_material_matches_uniform_when_homogeneous() {
2687 let mut table = MaterialTable::new();
2688 table.set(1, Material::alpha_blend(120));
2689 let col = VoxColor(0x80_30_A0_F0);
2690 let kv6 = Kv6::solid_cube(10, col);
2691 let (w, h) = (64u32, 64u32);
2692 let n = (w * h) as usize;
2693 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2694 let cfg = settings(w, h);
2695 let (pos, s, hh, f) = (
2696 [0.0, 40.0, 0.0],
2697 [1.0, 0.0, 0.0],
2698 [0.0, 1.0, 0.0],
2699 [0.0, 0.0, 1.0],
2700 );
2701 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2702 let mut fb = vec![0x80_10_10_10u32; n];
2703 let mut zb = vec![f32::INFINITY; n];
2704 let sh = SpriteShade {
2705 materials: &table,
2706 lights: CpuLights::default(),
2707 material,
2708 alpha_mul: 255,
2709 tint: 0x00FF_FFFF,
2710 shadow: None,
2711 };
2712 let _ = draw_sprite_dense_shaded(
2713 &mut fb,
2714 &mut zb,
2715 w as usize,
2716 w,
2717 h,
2718 &cs,
2719 &cfg,
2720 dense,
2721 pos,
2722 s,
2723 hh,
2724 f,
2725 0,
2726 Some(sh),
2727 );
2728 fb
2729 };
2730 let pv = render(
2733 &SpriteDense::from_kv6_with_materials(&kv6, &[(col.rgb_part(), 1)]),
2734 0,
2735 );
2736 let un = render(&SpriteDense::from_kv6(&kv6), 1);
2738 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2739 let centre = (h / 2 * w + w / 2) as usize;
2741 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2742 }
2743
2744 #[test]
2749 fn clip_flipbook_with_materials_classifies_every_frame() {
2750 let dims = [6u32, 6, 6];
2751 let glass = Rgb(0x00AA_BBCC);
2752 let glass_lit = 0x80AA_BBCC;
2753 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2755 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2756 let clip = VoxelClip::from_frames(
2757 dims,
2758 [3.0, 3.0, 3.0],
2759 1.0,
2760 LoopMode::Loop,
2761 &[f0, f1],
2762 &[],
2763 33,
2764 0,
2765 );
2766 let decoded = clip.decode().expect("decode");
2767
2768 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2769 assert_eq!(book.frame_count(), 2);
2770 for fr in 0..2 {
2771 let dense = book.frame(fr).expect("frame in range");
2772 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2773 let mut solids = 0;
2774 for idx in 0..dense.occ.len() {
2775 if dense.occ[idx] {
2776 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2777 solids += 1;
2778 }
2779 }
2780 assert!(solids > 0, "frame {fr} has solid voxels");
2781 }
2782
2783 let plain = ClipFlipbook::from_decoded(&decoded);
2785 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2786 for fr in 0..2 {
2787 assert!(plain.frame(fr).unwrap().mat.is_empty());
2788 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2789 }
2790 }
2791}