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};
29
30use std::sync::Arc;
31
32use crate::camera_math::CameraState;
33use crate::dda::{
34 dda_setup, intersect_aabb, min_axis, pixel_ray, shade, shade_dynamic, CpuLights, ShadowTester,
35 WorldOccluder, WorldShadow, WorldShadowCtx,
36};
37use crate::opticast::OpticastSettings;
38use crate::raster_target::RasterTarget;
39
40const NEAR_Z: f32 = 1.0;
43
44#[inline]
50fn full_bright(col: u32) -> u32 {
51 (col & 0x00ff_ffff) | 0x8000_0000
52}
53
54#[derive(Clone)]
63pub struct SpriteDense {
64 dims: [i32; 3],
65 occ: Vec<bool>,
66 col: Vec<u32>,
67 mat: Vec<u8>,
73 pivot: [f32; 3],
74}
75
76impl SpriteDense {
77 #[must_use]
79 #[allow(clippy::cast_possible_wrap)]
80 pub fn from_kv6(kv6: &Kv6) -> Self {
81 let dims = [kv6.xsiz as i32, kv6.ysiz as i32, kv6.zsiz as i32];
82 let n = (dims[0].max(0) * dims[1].max(0) * dims[2].max(0)) as usize;
83 let mut occ = vec![false; n];
84 let mut col = vec![0u32; n];
85 let mut vi = 0usize;
86 for x in 0..kv6.xsiz as usize {
87 for y in 0..kv6.ysiz as usize {
88 let cnt = usize::from(kv6.ylen[x][y]);
89 for _ in 0..cnt {
90 let v = kv6.voxels[vi];
91 vi += 1;
92 let z = i32::from(v.z);
93 if z >= 0 && z < dims[2] {
94 let idx = ((x as i32 * dims[1] + y as i32) * dims[2] + z) as usize;
95 occ[idx] = true;
96 col[idx] = full_bright(v.col);
97 }
98 }
99 }
100 }
101 Self {
102 dims,
103 occ,
104 col,
105 mat: Vec::new(),
106 pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
107 }
108 }
109
110 #[must_use]
118 #[allow(clippy::cast_possible_wrap)]
119 pub fn from_kv6_with_materials(kv6: &Kv6, material_map: &[(u32, u8)]) -> Self {
120 let mut dense = Self::from_kv6(kv6);
121 if !material_map.is_empty() {
122 let n = dense.col.len();
123 let mut mat = vec![0u8; n];
124 for (idx, slot) in mat.iter_mut().enumerate() {
125 if dense.occ[idx] {
126 *slot = material_for_color(material_map, dense.col[idx]);
127 }
128 }
129 dense.mat = mat;
130 }
131 dense
132 }
133
134 #[must_use]
140 #[allow(clippy::cast_possible_wrap)]
141 pub fn from_voxel_frame(frame: &VoxelFrame, dims: [u32; 3], pivot: [f32; 3]) -> Self {
142 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
143 let owpc = mz.div_ceil(32).max(1) as usize;
144 let n = (mx * my * mz) as usize;
145 let mut occ = vec![false; n];
146 let mut col = vec![0u32; n];
147 for col_idx in 0..(mx * my) as usize {
148 let x = col_idx as u32 % mx;
149 let y = col_idx as u32 / mx;
150 let run_start = frame.color_offsets[col_idx] as usize;
151 let mut k = 0usize;
152 for z in 0..mz {
153 let word = frame.occupancy[col_idx * owpc + (z >> 5) as usize];
154 if (word >> (z & 31)) & 1 != 0 {
155 let idx = (((x * my + y) * mz) + z) as usize;
156 occ[idx] = true;
157 col[idx] = full_bright(frame.colors[run_start + k]);
158 k += 1;
159 }
160 }
161 }
162 Self {
163 dims: [mx as i32, my as i32, mz as i32],
164 occ,
165 col,
166 mat: Vec::new(),
167 pivot,
168 }
169 }
170
171 #[must_use]
177 pub fn from_voxel_frame_with_materials(
178 frame: &VoxelFrame,
179 dims: [u32; 3],
180 pivot: [f32; 3],
181 material_map: &[(u32, u8)],
182 ) -> Self {
183 let mut dense = Self::from_voxel_frame(frame, dims, pivot);
184 if !material_map.is_empty() {
185 let n = dense.col.len();
186 let mut mat = vec![0u8; n];
187 for (idx, slot) in mat.iter_mut().enumerate() {
188 if dense.occ[idx] {
189 *slot = material_for_color(material_map, dense.col[idx]);
190 }
191 }
192 dense.mat = mat;
193 }
194 dense
195 }
196
197 #[inline]
198 #[allow(clippy::cast_sign_loss)]
199 fn idx_of(&self, c: [i32; 3]) -> usize {
200 ((c[0] * self.dims[1] + c[1]) * self.dims[2] + c[2]) as usize
201 }
202
203 #[inline]
204 fn at(&self, c: [i32; 3]) -> Option<u32> {
205 let idx = self.idx_of(c);
206 self.occ[idx].then(|| self.col[idx])
207 }
208}
209
210fn invert_basis(s: [f32; 3], h: [f32; 3], f: [f32; 3]) -> Option<[[f32; 3]; 3]> {
213 let det = s[0] * (h[1] * f[2] - f[1] * h[2]) - h[0] * (s[1] * f[2] - f[1] * s[2])
214 + f[0] * (s[1] * h[2] - h[1] * s[2]);
215 if det.abs() < 1e-12 {
216 return None;
217 }
218 let inv = 1.0 / det;
219 Some([
220 [
221 (h[1] * f[2] - f[1] * h[2]) * inv,
222 -(h[0] * f[2] - f[0] * h[2]) * inv,
223 (h[0] * f[1] - f[0] * h[1]) * inv,
224 ],
225 [
226 -(s[1] * f[2] - f[1] * s[2]) * inv,
227 (s[0] * f[2] - f[0] * s[2]) * inv,
228 -(s[0] * f[1] - f[0] * s[1]) * inv,
229 ],
230 [
231 (s[1] * h[2] - h[1] * s[2]) * inv,
232 -(s[0] * h[2] - h[0] * s[2]) * inv,
233 (s[0] * h[1] - h[0] * s[1]) * inv,
234 ],
235 ])
236}
237
238#[inline]
239fn mat_apply(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
240 [
241 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
242 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
243 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
244 ]
245}
246
247#[allow(clippy::cast_possible_truncation)]
251fn cast_local(
257 dense: &SpriteDense,
258 origin: [f32; 3],
259 dir: [f32; 3],
260) -> Option<(u32, f32, [f32; 3], [i32; 3])> {
261 #[allow(clippy::cast_precision_loss)]
262 let hi = [
263 dense.dims[0] as f32,
264 dense.dims[1] as f32,
265 dense.dims[2] as f32,
266 ];
267 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
268 let start = t0 + 1e-4;
269 let p = [
270 origin[0] + dir[0] * start,
271 origin[1] + dir[1] * start,
272 origin[2] + dir[2] * start,
273 ];
274 let mut cell = [
275 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
276 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
277 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
278 ];
279 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
280 let mut t_curr = t0;
281 let mut normal = [0.0f32; 3];
284 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
285 for _ in 0..max_steps {
286 if cell[0] < 0
287 || cell[0] >= dense.dims[0]
288 || cell[1] < 0
289 || cell[1] >= dense.dims[1]
290 || cell[2] < 0
291 || cell[2] >= dense.dims[2]
292 || t_curr > t1
293 {
294 return None;
295 }
296 if let Some(color) = dense.at(cell) {
297 return Some((color, t_curr, normal, cell));
298 }
299 let axis = min_axis(t_max);
300 t_curr = t_max[axis];
301 cell[axis] += step[axis];
302 t_max[axis] += t_delta[axis];
303 normal = [0.0; 3];
304 normal[axis] = -(step[axis] as f32);
305 }
306 None
307}
308
309struct SpriteOccEntry {
315 dense: Arc<SpriteDense>,
316 pos: [f32; 3],
317 pivot: [f32; 3],
318 minv: [[f32; 3]; 3],
319}
320
321#[derive(Default)]
331pub struct SpriteOccluder {
332 entries: Vec<SpriteOccEntry>,
333}
334
335impl SpriteOccluder {
336 #[must_use]
337 pub fn new() -> Self {
338 Self::default()
339 }
340
341 #[must_use]
343 pub fn is_empty(&self) -> bool {
344 self.entries.is_empty()
345 }
346
347 pub fn push(
353 &mut self,
354 dense: Arc<SpriteDense>,
355 pos: [f32; 3],
356 s: [f32; 3],
357 h: [f32; 3],
358 f: [f32; 3],
359 ) {
360 let Some(minv) = invert_basis(s, h, f) else {
361 return;
362 };
363 let pivot = dense.pivot;
364 self.entries.push(SpriteOccEntry {
365 dense,
366 pos,
367 pivot,
368 minv,
369 });
370 }
371}
372
373impl WorldOccluder for SpriteOccluder {
374 fn occluded_world(&self, origin: [f32; 3], dir: [f32; 3], max_t: f32) -> bool {
375 self.entries
376 .iter()
377 .any(|e| sprite_entry_occluded(e, origin, dir, max_t))
378 }
379}
380
381#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
384fn sprite_entry_occluded(e: &SpriteOccEntry, ow: [f32; 3], dw: [f32; 3], max_t: f32) -> bool {
385 let rel = [ow[0] - e.pos[0], ow[1] - e.pos[1], ow[2] - e.pos[2]];
387 let ol = mat_apply(&e.minv, rel);
388 let origin = [ol[0] + e.pivot[0], ol[1] + e.pivot[1], ol[2] + e.pivot[2]];
389 let dir = mat_apply(&e.minv, dw);
390
391 let hi = [
392 e.dense.dims[0] as f32,
393 e.dense.dims[1] as f32,
394 e.dense.dims[2] as f32,
395 ];
396 let Some((t0, t1)) = intersect_aabb(origin, dir, [0.0; 3], hi) else {
397 return false;
398 };
399 let t_enter = t0.max(0.0);
400 let t_exit = t1.min(max_t);
401 if t_enter > t_exit {
402 return false;
403 }
404 let start = t_enter + 1e-4;
405 let p = [
406 origin[0] + dir[0] * start,
407 origin[1] + dir[1] * start,
408 origin[2] + dir[2] * start,
409 ];
410 let mut cell = [
411 (p[0].floor() as i32).clamp(0, e.dense.dims[0] - 1),
412 (p[1].floor() as i32).clamp(0, e.dense.dims[1] - 1),
413 (p[2].floor() as i32).clamp(0, e.dense.dims[2] - 1),
414 ];
415 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
416 let mut t_curr = t_enter;
417 let max_steps = (e.dense.dims[0] + e.dense.dims[1] + e.dense.dims[2]) as usize + 8;
418 for _ in 0..max_steps {
419 if cell[0] < 0
420 || cell[0] >= e.dense.dims[0]
421 || cell[1] < 0
422 || cell[1] >= e.dense.dims[1]
423 || cell[2] < 0
424 || cell[2] >= e.dense.dims[2]
425 || t_curr > t_exit
426 {
427 return false;
428 }
429 if e.dense.occ[e.dense.idx_of(cell)] {
430 return true;
431 }
432 let a = min_axis(t_max);
433 t_curr = t_max[a];
434 cell[a] += step[a];
435 t_max[a] += t_delta[a];
436 }
437 false
438}
439
440#[derive(Clone, Copy)]
447pub struct SpriteShade<'a> {
448 pub materials: &'a MaterialTable,
450 pub material: u8,
453 pub alpha_mul: u8,
456 pub tint: u32,
459 pub lights: CpuLights<'a>,
463 pub shadow: Option<&'a dyn WorldOccluder>,
468}
469
470struct LayerAccum {
472 rgb: [f32; 3],
475 trans: f32,
477 opaque: Option<(u32, f32)>,
482}
483
484#[inline]
488fn tint_packed(color: u32, tint: u32) -> u32 {
489 if tint & 0x00FF_FFFF == 0x00FF_FFFF {
490 return color;
491 }
492 let mul = |shift: u32| {
493 let c = (color >> shift) & 0xff;
494 let t = (tint >> shift) & 0xff;
495 ((c * t) / 255) & 0xff
496 };
497 (color & 0xff00_0000) | (mul(16) << 16) | (mul(8) << 8) | mul(0)
498}
499
500#[inline]
503fn rgb_to_f32(c: u32) -> [f32; 3] {
504 [
505 ((c >> 16) & 0xff) as f32 / 255.0,
506 ((c >> 8) & 0xff) as f32 / 255.0,
507 (c & 0xff) as f32 / 255.0,
508 ]
509}
510
511#[inline]
514#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
515fn f32_to_rgb(c: [f32; 3]) -> u32 {
516 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
517 0x8000_0000 | (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
518}
519
520const SPRITE_WORLD_UP: [f32; 3] = [0.0, 0.0, -1.0];
523
524#[derive(Clone, Copy, PartialEq, Eq)]
529pub enum SpriteLightMode {
530 FaceNormal,
532 WorldUp,
534 AmbientOnly,
536 FullBright,
539}
540
541impl SpriteLightMode {
542 #[must_use]
543 pub fn from_flags(flags: u32) -> Self {
544 let world_up = flags & SPRITE_FLAG_LIGHT_WORLD_UP != 0;
545 let ambient_only = flags & SPRITE_FLAG_LIGHT_AMBIENT_ONLY != 0;
546 match (ambient_only, world_up) {
547 (true, true) => Self::FullBright, (true, false) => Self::AmbientOnly,
549 (false, true) => Self::WorldUp,
550 (false, false) => Self::FaceNormal,
551 }
552 }
553}
554
555fn shade_dynamic_mode(
560 mode: SpriteLightMode,
561 albedo: [f32; 3],
562 n_world: [f32; 3],
563 center: [f32; 3],
564 lights: &CpuLights<'_>,
565 tester: Option<&mut dyn ShadowTester>,
566) -> u32 {
567 match mode {
568 SpriteLightMode::FaceNormal => shade_dynamic(albedo, 1.0, n_world, center, lights, tester),
569 SpriteLightMode::WorldUp => {
570 shade_dynamic(albedo, 1.0, SPRITE_WORLD_UP, center, lights, tester)
571 }
572 SpriteLightMode::AmbientOnly => {
573 let mut amb = *lights;
574 amb.sun = false;
575 amb.points = &[];
576 amb.bands = 0; shade_dynamic(albedo, 1.0, n_world, center, &amb, None)
578 }
579 SpriteLightMode::FullBright => f32_to_rgb(albedo),
581 }
582}
583
584#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
591fn cast_local_layers(
592 dense: &SpriteDense,
593 origin: [f32; 3],
594 dir: [f32; 3],
595 fwd_dot: f32,
596 max_t: f32,
597 shade_ctx: SpriteShade,
598 s: [f32; 3],
602 h: [f32; 3],
603 f: [f32; 3],
604 pos: [f32; 3],
605 light_mode: SpriteLightMode,
606) -> Option<LayerAccum> {
607 #[allow(clippy::cast_precision_loss)]
608 let hi = [
609 dense.dims[0] as f32,
610 dense.dims[1] as f32,
611 dense.dims[2] as f32,
612 ];
613 let (t0, t1) = intersect_aabb(origin, dir, [0.0; 3], hi)?;
614 let start = t0 + 1e-4;
615 let p = [
616 origin[0] + dir[0] * start,
617 origin[1] + dir[1] * start,
618 origin[2] + dir[2] * start,
619 ];
620 let mut cell = [
621 (p[0].floor() as i32).clamp(0, dense.dims[0] - 1),
622 (p[1].floor() as i32).clamp(0, dense.dims[1] - 1),
623 (p[2].floor() as i32).clamp(0, dense.dims[2] - 1),
624 ];
625 let (step, mut t_max, t_delta) = dda_setup(origin, dir, cell, 1.0);
626 let mut t_curr = t0;
627 let max_steps = (dense.dims[0] + dense.dims[1] + dense.dims[2]) as usize + 8;
628
629 let mut acc = LayerAccum {
630 rgb: [0.0; 3],
631 trans: 1.0,
632 opaque: None,
633 };
634 let mut touched = false;
635 let mut prev_solid = false;
646 let mut prev_mat = 0u8;
647 let dir_len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
650 let mut normal = [0.0f32; 3];
654
655 let lights = shade_ctx.lights;
661 let tint = shade_ctx.tint;
662 let mut tester = shade_ctx.shadow.map(|occ| WorldShadow {
663 ctx: WorldShadowCtx::identity(occ),
664 });
665 let mut shade_layer = |idx: usize, cell: [i32; 3], n_local: [f32; 3]| -> u32 {
666 if !lights.enabled {
667 return tint_packed(shade(dense.col[idx], 0), tint);
668 }
669 let to_world = |v: [f32; 3]| {
670 [
671 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
672 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
673 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
674 ]
675 };
676 let n_world = to_world(n_local);
677 let rel = [
678 cell[0] as f32 + 0.5 - dense.pivot[0],
679 cell[1] as f32 + 0.5 - dense.pivot[1],
680 cell[2] as f32 + 0.5 - dense.pivot[2],
681 ];
682 let wc = to_world(rel);
683 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
684 let albedo = [
685 ((dense.col[idx] >> 16) & 0xff) as f32 / 255.0,
686 ((dense.col[idx] >> 8) & 0xff) as f32 / 255.0,
687 (dense.col[idx] & 0xff) as f32 / 255.0,
688 ];
689 let t = tester.as_mut().map(|t| t as &mut dyn ShadowTester);
690 tint_packed(
691 shade_dynamic_mode(light_mode, albedo, n_world, center, &lights, t),
692 tint,
693 )
694 };
695
696 for _ in 0..max_steps {
697 if cell[0] < 0
698 || cell[0] >= dense.dims[0]
699 || cell[1] < 0
700 || cell[1] >= dense.dims[1]
701 || cell[2] < 0
702 || cell[2] >= dense.dims[2]
703 || t_curr > t1
704 {
705 break;
706 }
707 let depth = t_curr * fwd_dot;
710 if depth >= max_t {
711 break;
712 }
713 let exit_axis = min_axis(t_max);
716 let t_exit = t_max[exit_axis];
717 let idx = dense.idx_of(cell);
718 let solid_here = dense.occ[idx];
719 if solid_here && depth >= NEAR_Z {
720 let mat_id = if dense.mat.is_empty() {
721 shade_ctx.material
722 } else {
723 dense.mat[idx]
724 };
725 let m = shade_ctx.materials.get(mat_id);
726 if m.is_opaque() {
727 acc.opaque = Some((shade_layer(idx, cell, normal), t_curr));
728 touched = true;
729 break;
730 }
731 let a = f32::from(m.alpha) / 255.0 * (f32::from(shade_ctx.alpha_mul) / 255.0);
732 if m.mode == BlendMode::Volumetric {
733 let seg_len = (t_exit - t_curr).max(0.0) * dir_len;
737 let eff_a = 1.0 - (1.0 - a).powf(seg_len);
738 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
739 acc.rgb[0] += acc.trans * eff_a * lit[0];
740 acc.rgb[1] += acc.trans * eff_a * lit[1];
741 acc.rgb[2] += acc.trans * eff_a * lit[2];
742 acc.trans *= 1.0 - eff_a;
743 touched = true;
744 prev_mat = mat_id;
745 if acc.trans < 1.0 / 256.0 {
746 break;
747 }
748 } else if !prev_solid || mat_id != prev_mat {
749 let lit = rgb_to_f32(shade_layer(idx, cell, normal));
752 acc.rgb[0] += acc.trans * a * lit[0];
753 acc.rgb[1] += acc.trans * a * lit[1];
754 acc.rgb[2] += acc.trans * a * lit[2];
755 if m.mode == BlendMode::AlphaBlend {
756 acc.trans *= 1.0 - a; }
758 touched = true;
759 prev_mat = mat_id;
760 if acc.trans < 1.0 / 256.0 {
761 break;
762 }
763 }
764 }
765 prev_solid = solid_here;
766 t_curr = t_exit;
767 cell[exit_axis] += step[exit_axis];
768 t_max[exit_axis] += t_delta[exit_axis];
769 normal = [0.0; 3];
770 normal[exit_axis] = -(step[exit_axis] as f32);
771 }
772
773 touched.then_some(acc)
774}
775
776#[allow(
786 clippy::too_many_arguments,
787 clippy::cast_possible_truncation,
788 clippy::cast_sign_loss
789)]
790#[must_use]
791pub fn draw_sprite_dda(
792 fb: &mut [u32],
793 zb: &mut [f32],
794 pitch_pixels: usize,
795 width: u32,
796 height: u32,
797 cam: &CameraState,
798 settings: &OpticastSettings,
799 sprite: &Sprite,
800) -> u32 {
801 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
802 return 0;
803 }
804 draw_sprite_dda_shaded(
805 fb,
806 zb,
807 pitch_pixels,
808 width,
809 height,
810 cam,
811 settings,
812 sprite,
813 None,
814 )
815}
816
817#[allow(clippy::too_many_arguments)]
822#[must_use]
823pub fn draw_sprite_dda_shaded(
824 fb: &mut [u32],
825 zb: &mut [f32],
826 pitch_pixels: usize,
827 width: u32,
828 height: u32,
829 cam: &CameraState,
830 settings: &OpticastSettings,
831 sprite: &Sprite,
832 shade_ctx: Option<SpriteShade>,
833) -> u32 {
834 if sprite.flags & SPRITE_FLAG_INVISIBLE != 0 {
835 return 0;
836 }
837 let dense = if sprite.material_map.is_empty() {
842 SpriteDense::from_kv6(&sprite.kv6)
843 } else {
844 SpriteDense::from_kv6_with_materials(&sprite.kv6, &sprite.material_map)
845 };
846 draw_sprite_dense_shaded(
847 fb,
848 zb,
849 pitch_pixels,
850 width,
851 height,
852 cam,
853 settings,
854 &dense,
855 sprite.p,
856 sprite.s,
857 sprite.h,
858 sprite.f,
859 sprite.flags,
860 shade_ctx,
861 )
862}
863
864#[allow(clippy::too_many_arguments)]
873#[must_use]
874pub fn draw_sprite_dense(
875 fb: &mut [u32],
876 zb: &mut [f32],
877 pitch_pixels: usize,
878 width: u32,
879 height: u32,
880 cam: &CameraState,
881 settings: &OpticastSettings,
882 dense: &SpriteDense,
883 pos: [f32; 3],
884 s: [f32; 3],
885 h: [f32; 3],
886 f: [f32; 3],
887 flags: u32,
888) -> u32 {
889 draw_sprite_dense_shaded(
890 fb,
891 zb,
892 pitch_pixels,
893 width,
894 height,
895 cam,
896 settings,
897 dense,
898 pos,
899 s,
900 h,
901 f,
902 flags,
903 None,
904 )
905}
906
907#[allow(
919 clippy::too_many_arguments,
920 clippy::cast_possible_truncation,
921 clippy::cast_sign_loss
922)]
923#[must_use]
924pub fn draw_sprite_dense_shaded(
925 fb: &mut [u32],
926 zb: &mut [f32],
927 pitch_pixels: usize,
928 width: u32,
929 height: u32,
930 cam: &CameraState,
931 settings: &OpticastSettings,
932 dense: &SpriteDense,
933 pos: [f32; 3],
934 s: [f32; 3],
935 h: [f32; 3],
936 f: [f32; 3],
937 flags: u32,
938 shade_ctx: Option<SpriteShade>,
939) -> u32 {
940 if flags & SPRITE_FLAG_INVISIBLE != 0 || dense.occ.is_empty() {
941 return 0;
942 }
943 let Some(minv) = invert_basis(s, h, f) else {
944 return 0;
945 };
946 let pivot = dense.pivot;
947 let no_z = flags & SPRITE_FLAG_NO_Z != 0;
948 let light_mode = SpriteLightMode::from_flags(flags);
950
951 let Some(rect) = project_screen_rect(dense, pos, s, h, f, cam, settings, width, height) else {
953 return 0;
954 };
955
956 let layers =
962 shade_ctx.filter(|s| !dense.mat.is_empty() || !s.materials.get(s.material).is_opaque());
963
964 debug_assert_eq!(fb.len(), zb.len());
965 let target = RasterTarget::new(fb, zb);
966 let draw_row = |py: u32| -> u32 {
970 let mut written = 0u32;
971 let row = py as usize * pitch_pixels;
972 for px in rect.0..rect.2 {
973 let (origin, dir) = pixel_ray(cam, settings, px, py);
974 let rel = [origin[0] - pos[0], origin[1] - pos[1], origin[2] - pos[2]];
976 let ol = mat_apply(&minv, rel);
977 let origin_local = [ol[0] + pivot[0], ol[1] + pivot[1], ol[2] + pivot[2]];
978 let dir_local = mat_apply(&minv, dir);
979 let fwd_dot =
980 dir[0] * cam.forward[0] + dir[1] * cam.forward[1] + dir[2] * cam.forward[2];
981 let idx = row + px as usize;
982
983 if let Some(shade_ctx) = layers {
984 if fwd_dot <= 1e-6 {
986 continue;
987 }
988 let max_t = if no_z {
993 f32::INFINITY
994 } else {
995 unsafe { target.read_depth(idx) }
996 };
997 let Some(acc) = cast_local_layers(
998 dense,
999 origin_local,
1000 dir_local,
1001 fwd_dot,
1002 max_t,
1003 shade_ctx,
1004 s,
1005 h,
1006 f,
1007 pos,
1008 light_mode,
1009 ) else {
1010 continue;
1011 };
1012 let wrote = unsafe {
1014 match acc.opaque {
1015 Some((bg_color, t)) => {
1016 let bg = rgb_to_f32(bg_color);
1019 let out = f32_to_rgb([
1020 acc.rgb[0] + acc.trans * bg[0],
1021 acc.rgb[1] + acc.trans * bg[1],
1022 acc.rgb[2] + acc.trans * bg[2],
1023 ]);
1024 let depth = t * fwd_dot;
1025 if no_z {
1026 target.write_color(idx, out);
1027 target.write_depth(idx, depth);
1028 true
1029 } else {
1030 target.z_test_write(idx, out, depth)
1031 }
1032 }
1033 None => {
1034 let bg = rgb_to_f32(target.read_color(idx));
1039 let out = f32_to_rgb([
1040 acc.rgb[0] + acc.trans * bg[0],
1041 acc.rgb[1] + acc.trans * bg[1],
1042 acc.rgb[2] + acc.trans * bg[2],
1043 ]);
1044 target.write_color(idx, out);
1045 true
1046 }
1047 }
1048 };
1049 written += u32::from(wrote);
1050 } else {
1051 let Some((color, t, n_local, cell)) = cast_local(dense, origin_local, dir_local)
1053 else {
1054 continue;
1055 };
1056 let depth = t * fwd_dot;
1057 if depth < NEAR_Z {
1058 continue;
1059 }
1060 let dl = shade_ctx.map_or(CpuLights::default(), |s| s.lights);
1065 let lit = if dl.enabled {
1066 let to_world = |v: [f32; 3]| {
1067 [
1068 v[0] * s[0] + v[1] * h[0] + v[2] * f[0],
1069 v[0] * s[1] + v[1] * h[1] + v[2] * f[1],
1070 v[0] * s[2] + v[1] * h[2] + v[2] * f[2],
1071 ]
1072 };
1073 let n_world = to_world(n_local);
1074 let rel = [
1075 cell[0] as f32 + 0.5 - pivot[0],
1076 cell[1] as f32 + 0.5 - pivot[1],
1077 cell[2] as f32 + 0.5 - pivot[2],
1078 ];
1079 let wc = to_world(rel);
1080 let center = [pos[0] + wc[0], pos[1] + wc[1], pos[2] + wc[2]];
1081 let albedo = [
1082 ((color >> 16) & 0xff) as f32 / 255.0,
1083 ((color >> 8) & 0xff) as f32 / 255.0,
1084 (color & 0xff) as f32 / 255.0,
1085 ];
1086 let mut ws = shade_ctx.and_then(|s| s.shadow).map(|occ| WorldShadow {
1090 ctx: WorldShadowCtx::identity(occ),
1091 });
1092 let tester = ws.as_mut().map(|t| t as &mut dyn ShadowTester);
1093 shade_dynamic_mode(light_mode, albedo, n_world, center, &dl, tester)
1094 } else {
1095 shade(color, 0)
1096 };
1097 let lit = tint_packed(lit, shade_ctx.map_or(0x00FF_FFFF, |s| s.tint));
1099 let wrote = unsafe {
1102 if no_z {
1103 target.write_color(idx, lit);
1104 target.write_depth(idx, depth);
1105 true
1106 } else {
1107 target.z_test_write(idx, lit, depth)
1108 }
1109 };
1110 written += u32::from(wrote);
1111 }
1112 }
1113 written
1114 };
1115 let rows = rect.3.saturating_sub(rect.1) as usize;
1120 let cols = rect.2.saturating_sub(rect.0) as usize;
1121 const SPRITE_PAR_MIN_PIXELS: usize = 64 * 64;
1122 if rows >= 2 && rows * cols >= SPRITE_PAR_MIN_PIXELS {
1123 use rayon::prelude::*;
1124 (rect.1..rect.3).into_par_iter().map(draw_row).sum()
1125 } else {
1126 (rect.1..rect.3).map(draw_row).sum()
1127 }
1128}
1129
1130#[allow(
1134 clippy::cast_possible_truncation,
1135 clippy::cast_sign_loss,
1136 clippy::cast_precision_loss
1137)]
1138fn project_screen_rect(
1139 dense: &SpriteDense,
1140 pos: [f32; 3],
1141 s: [f32; 3],
1142 h: [f32; 3],
1143 f: [f32; 3],
1144 cam: &CameraState,
1145 settings: &OpticastSettings,
1146 width: u32,
1147 height: u32,
1148) -> Option<(u32, u32, u32, u32)> {
1149 let (xs, ys, zs) = (
1150 dense.dims[0] as f32,
1151 dense.dims[1] as f32,
1152 dense.dims[2] as f32,
1153 );
1154 let (xp, yp, zp) = (dense.pivot[0], dense.pivot[1], dense.pivot[2]);
1155 let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1156 let mut all_front = true;
1157 for &cx in &[0.0, xs] {
1158 for &cy in &[0.0, ys] {
1159 for &cz in &[0.0, zs] {
1160 let lx = cx - xp;
1162 let ly = cy - yp;
1163 let lz = cz - zp;
1164 let world = [
1165 pos[0] + lx * s[0] + ly * h[0] + lz * f[0],
1166 pos[1] + lx * s[1] + ly * h[1] + lz * f[1],
1167 pos[2] + lx * s[2] + ly * h[2] + lz * f[2],
1168 ];
1169 let rel = [
1170 world[0] - cam.pos[0],
1171 world[1] - cam.pos[1],
1172 world[2] - cam.pos[2],
1173 ];
1174 let cz_cam =
1175 rel[0] * cam.forward[0] + rel[1] * cam.forward[1] + rel[2] * cam.forward[2];
1176 if cz_cam < NEAR_Z {
1177 all_front = false;
1178 continue;
1179 }
1180 let cx_cam = rel[0] * cam.right[0] + rel[1] * cam.right[1] + rel[2] * cam.right[2];
1181 let cy_cam = rel[0] * cam.down[0] + rel[1] * cam.down[1] + rel[2] * cam.down[2];
1182 let sx = settings.hx + cx_cam / cz_cam * settings.hz;
1183 let sy = settings.hy + cy_cam / cz_cam * settings.hz;
1184 x0 = x0.min(sx);
1185 y0 = y0.min(sy);
1186 x1 = x1.max(sx);
1187 y1 = y1.max(sy);
1188 }
1189 }
1190 }
1191 let (w, h) = (width as f32, height as f32);
1192 let (rx0, ry0, rx1, ry1) = if all_front {
1193 (
1194 (x0 - 1.0).max(0.0),
1195 (y0 - 1.0).max(0.0),
1196 (x1 + 1.0).min(w),
1197 (y1 + 1.0).min(h),
1198 )
1199 } else {
1200 (0.0, 0.0, w, h)
1202 };
1203 if rx0 >= rx1 || ry0 >= ry1 {
1204 return None;
1205 }
1206 Some((rx0 as u32, ry0 as u32, rx1.ceil() as u32, ry1.ceil() as u32))
1207}
1208
1209pub struct ClipFlipbook {
1216 frames: Vec<Arc<SpriteDense>>,
1219}
1220
1221impl ClipFlipbook {
1222 #[must_use]
1225 pub fn empty() -> Self {
1226 Self { frames: Vec::new() }
1227 }
1228
1229 #[must_use]
1231 pub fn from_decoded(clip: &DecodedClip) -> Self {
1232 Self::from_decoded_with_materials(clip, &[])
1233 }
1234
1235 #[must_use]
1241 pub fn from_decoded_with_materials(clip: &DecodedClip, material_map: &[(u32, u8)]) -> Self {
1242 let frames = clip
1243 .frames
1244 .iter()
1245 .map(|frame| {
1246 Arc::new(SpriteDense::from_voxel_frame_with_materials(
1247 frame,
1248 clip.dims,
1249 clip.pivot,
1250 material_map,
1251 ))
1252 })
1253 .collect();
1254 Self { frames }
1255 }
1256
1257 #[must_use]
1258 pub fn frame_count(&self) -> usize {
1259 self.frames.len()
1260 }
1261
1262 #[must_use]
1264 pub fn frame(&self, frame: usize) -> Option<&SpriteDense> {
1265 self.frames.get(frame).map(Arc::as_ref)
1266 }
1267
1268 #[must_use]
1271 pub fn frame_arc(&self, frame: usize) -> Option<Arc<SpriteDense>> {
1272 self.frames.get(frame).cloned()
1273 }
1274
1275 pub fn set_frame(&mut self, frame: usize, dense: SpriteDense) -> bool {
1279 match self.frames.get_mut(frame) {
1280 Some(slot) => {
1281 *slot = Arc::new(dense);
1282 true
1283 }
1284 None => false,
1285 }
1286 }
1287
1288 #[allow(clippy::too_many_arguments)]
1292 #[must_use]
1293 pub fn draw_frame(
1294 &self,
1295 fb: &mut [u32],
1296 zb: &mut [f32],
1297 pitch_pixels: usize,
1298 width: u32,
1299 height: u32,
1300 cam: &CameraState,
1301 settings: &OpticastSettings,
1302 frame: usize,
1303 pos: [f32; 3],
1304 s: [f32; 3],
1305 h: [f32; 3],
1306 f: [f32; 3],
1307 flags: u32,
1308 ) -> u32 {
1309 self.draw_frame_shaded(
1310 fb,
1311 zb,
1312 pitch_pixels,
1313 width,
1314 height,
1315 cam,
1316 settings,
1317 frame,
1318 pos,
1319 s,
1320 h,
1321 f,
1322 flags,
1323 None,
1324 )
1325 }
1326
1327 #[allow(clippy::too_many_arguments)]
1332 #[must_use]
1333 pub fn draw_frame_shaded(
1334 &self,
1335 fb: &mut [u32],
1336 zb: &mut [f32],
1337 pitch_pixels: usize,
1338 width: u32,
1339 height: u32,
1340 cam: &CameraState,
1341 settings: &OpticastSettings,
1342 frame: usize,
1343 pos: [f32; 3],
1344 s: [f32; 3],
1345 h: [f32; 3],
1346 f: [f32; 3],
1347 flags: u32,
1348 shade_ctx: Option<SpriteShade>,
1349 ) -> u32 {
1350 let Some(dense) = self.frames.get(frame) else {
1351 return 0;
1352 };
1353 draw_sprite_dense_shaded(
1354 fb,
1355 zb,
1356 pitch_pixels,
1357 width,
1358 height,
1359 cam,
1360 settings,
1361 dense,
1362 pos,
1363 s,
1364 h,
1365 f,
1366 flags,
1367 shade_ctx,
1368 )
1369 }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375 use crate::camera_math;
1376 use crate::Camera;
1377 use roxlap_formats::kv6::Kv6;
1378 use roxlap_formats::material::{Material, MaterialTable};
1379
1380 #[test]
1383 fn sprite_light_mode_world_up_and_ambient_only() {
1384 let lights = CpuLights {
1385 enabled: true,
1386 sun: true,
1387 sun_dir: [0.0, 0.0, -1.0], sun_color: [1.0, 1.0, 1.0],
1389 sun_intensity: 1.0,
1390 sun_casts_shadow: false,
1391 points: &[],
1392 ambient: [0.2, 0.2, 0.2],
1393 bands: 0,
1394 shadow_tint: [0.0; 3],
1395 shadow_strength: 0.0,
1396 shadow_bias: 0.0,
1397 shadow_max_dist: 0.0,
1398 };
1399 let a = [1.0, 1.0, 1.0];
1400 let c = [0.0, 0.0, 0.0];
1401 let g = |packed: u32| (packed >> 8) & 0xff; let up_n = [0.0, 0.0, -1.0];
1403 let side_n = [1.0, 0.0, 0.0];
1404 let face_up = g(shade_dynamic_mode(
1405 SpriteLightMode::FaceNormal,
1406 a,
1407 up_n,
1408 c,
1409 &lights,
1410 None,
1411 ));
1412 let face_side = g(shade_dynamic_mode(
1413 SpriteLightMode::FaceNormal,
1414 a,
1415 side_n,
1416 c,
1417 &lights,
1418 None,
1419 ));
1420 let amb = g(shade_dynamic_mode(
1421 SpriteLightMode::AmbientOnly,
1422 a,
1423 up_n,
1424 c,
1425 &lights,
1426 None,
1427 ));
1428 let world_up = g(shade_dynamic_mode(
1429 SpriteLightMode::WorldUp,
1430 a,
1431 side_n,
1432 c,
1433 &lights,
1434 None,
1435 ));
1436 assert!(
1437 face_up > face_side,
1438 "a sun-facing face is brighter than a side face"
1439 );
1440 assert!(amb < face_up, "ambient-only drops the sun term");
1441 assert_eq!(
1442 world_up, face_up,
1443 "world-up shades a side-facing billboard as if it faced up"
1444 );
1445 let full = g(shade_dynamic_mode(
1446 SpriteLightMode::FullBright,
1447 a,
1448 side_n,
1449 c,
1450 &lights,
1451 None,
1452 ));
1453 assert_eq!(full, 255, "full-bright emits the colour at full intensity");
1456 assert!(full > amb, "full-bright glow is brighter than ambient-only");
1457 }
1458
1459 #[test]
1464 fn cast_local_reports_face_normal() {
1465 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, z| (z >= 4).then_some(0x80_C0_40_20));
1467 let dense = SpriteDense::from_kv6(&kv6);
1468 let (_c, _t, n, cell) =
1470 cast_local(&dense, [4.0, 4.0, -5.0], [0.0, 0.0, 1.0]).expect("ray hits the block");
1471 assert_eq!(cell[2], 4, "first solid voxel is the z=4 surface");
1472 assert!(
1473 n[2] < -0.5 && n[0].abs() < 1e-6 && n[1].abs() < 1e-6,
1474 "z-crossing face normal points back toward the ray (-z): {n:?}",
1475 );
1476 }
1477 use roxlap_formats::sprite::Sprite;
1478 use roxlap_formats::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1479
1480 fn settings(w: u32, h: u32) -> OpticastSettings {
1481 OpticastSettings::for_oracle_framebuffer(w, h)
1482 }
1483
1484 fn cam_looking_y() -> Camera {
1486 Camera {
1487 pos: [0.0, 0.0, 0.0],
1488 right: [1.0, 0.0, 0.0],
1489 down: [0.0, 0.0, 1.0],
1490 forward: [0.0, 1.0, 0.0],
1491 }
1492 }
1493
1494 #[test]
1501 fn scaled_basis_scales_drawn_extent() {
1502 let kv6 = Kv6::from_fn(8, 8, 8, |_, _, _| Some(0x80_C0_40_20));
1503 let (w, h) = (64u32, 64u32);
1504 let n = (w * h) as usize;
1505 let cam = cam_looking_y();
1506 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1507 let cfg = settings(w, h);
1508
1509 let px_at = |k: f32| -> u32 {
1510 let mut sprite = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1511 for a in 0..3 {
1512 sprite.s[a] *= k;
1513 sprite.h[a] *= k;
1514 sprite.f[a] *= k;
1515 }
1516 let mut fb = vec![0u32; n];
1517 let mut zb = vec![f32::INFINITY; n];
1518 draw_sprite_dda(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, &sprite)
1519 };
1520
1521 let (unit, double, half) = (px_at(1.0), px_at(2.0), px_at(0.5));
1522 assert!(unit > 0, "unit-scale cube must draw ({unit} px)");
1523 let r2 = f64::from(double) / f64::from(unit);
1524 let rh = f64::from(half) / f64::from(unit);
1525 assert!(
1526 (3.0..8.0).contains(&r2),
1527 "2× scale should roughly quadruple coverage: {unit} → {double} px (×{r2:.2})"
1528 );
1529 assert!(
1530 (0.08..0.5).contains(&rh),
1531 "0.5× scale should roughly quarter coverage: {unit} → {half} px (×{rh:.2})"
1532 );
1533 }
1534
1535 fn clip_frame(dims: [u32; 3], fill: impl Fn(u32, u32, u32) -> Option<u32>) -> VoxelFrame {
1537 let owpc = dims[2].div_ceil(32).max(1) as usize;
1538 let cols = (dims[0] * dims[1]) as usize;
1539 let mut occupancy = vec![0u32; cols * owpc];
1540 let mut color_offsets = vec![0u32; cols + 1];
1541 let mut colors = Vec::new();
1542 for y in 0..dims[1] {
1543 for x in 0..dims[0] {
1544 let col = (x + y * dims[0]) as usize;
1545 color_offsets[col] = colors.len() as u32;
1546 for z in 0..dims[2] {
1547 if let Some(c) = fill(x, y, z) {
1548 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1549 colors.push(c);
1550 }
1551 }
1552 }
1553 }
1554 color_offsets[cols] = colors.len() as u32;
1555 VoxelFrame {
1556 occupancy,
1557 colors,
1558 color_offsets,
1559 }
1560 }
1561
1562 #[test]
1567 fn clip_flipbook_frames_render_differently() {
1568 let dims = [8u32, 8, 8];
1569 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(
1572 dims,
1573 [4.0, 4.0, 4.0],
1574 1.0,
1575 LoopMode::Loop,
1576 &[f0, f1],
1577 &[],
1578 33,
1579 0,
1580 );
1581 let decoded = clip.decode().expect("decode");
1582 let book = ClipFlipbook::from_decoded(&decoded);
1583 assert_eq!(book.frame_count(), 2);
1584 assert!(book.frame(0).is_some() && book.frame(2).is_none());
1585
1586 let (w, h) = (64u32, 64u32);
1587 let n = (w * h) as usize;
1588 let cam = cam_looking_y();
1589 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1590 let cfg = settings(w, h);
1591 let pose = [0.0, 40.0, 0.0];
1592 let (s, hh, f) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
1593
1594 let render = |frame: usize| -> Vec<u32> {
1595 let mut fb = vec![0u32; n];
1596 let mut zb = vec![f32::INFINITY; n];
1597 let wrote = book.draw_frame(
1598 &mut fb, &mut zb, w as usize, w, h, &cs, &cfg, frame, pose, s, hh, f, 0,
1599 );
1600 assert!(wrote > 0, "frame {frame} should draw some pixels");
1601 fb
1602 };
1603 let fb0 = render(0);
1604 let fb1 = render(1);
1605 assert_ne!(fb0, fb1, "distinct frames must render distinct pixels");
1606 assert!(fb0.iter().any(|&p| (p & 0x00FF_0000) != 0));
1609 assert!(fb1.iter().any(|&p| (p & 0x0000_FF00) != 0));
1610 let mut fb = vec![0u32; n];
1612 let mut zb = vec![f32::INFINITY; n];
1613 assert_eq!(
1614 book.draw_frame(&mut fb, &mut zb, w as usize, w, h, &cs, &cfg, 9, pose, s, hh, f, 0),
1615 0
1616 );
1617 }
1618
1619 #[test]
1620 fn clip_flipbook_set_frame_replaces_one_frame() {
1621 let dims = [8u32, 8, 8];
1624 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 =
1627 VoxelClip::from_frames(dims, [4.0; 3], 1.0, LoopMode::Loop, &[f0, f1], &[], 33, 0);
1628 let decoded = clip.decode().unwrap();
1629 let mut book = ClipFlipbook::from_decoded(&decoded);
1630
1631 let (w, h) = (64u32, 64u32);
1632 let n = (w * h) as usize;
1633 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1634 let cfg = settings(w, h);
1635 let render0 = |b: &ClipFlipbook| -> Vec<u32> {
1636 let mut fb = vec![0u32; n];
1637 let mut zb = vec![f32::INFINITY; n];
1638 let _ = b.draw_frame(
1639 &mut fb,
1640 &mut zb,
1641 w as usize,
1642 w,
1643 h,
1644 &cs,
1645 &cfg,
1646 0,
1647 [0.0, 40.0, 0.0],
1648 [1.0, 0.0, 0.0],
1649 [0.0, 1.0, 0.0],
1650 [0.0, 0.0, 1.0],
1651 0,
1652 );
1653 fb
1654 };
1655
1656 let before = render0(&book);
1657 assert!(
1658 before.iter().any(|&p| (p & 0x00FF_0000) != 0),
1659 "frame 0 is red"
1660 );
1661
1662 let replacement = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1664 assert!(book.set_frame(0, replacement));
1665 let extra = SpriteDense::from_voxel_frame(&decoded.frames[1], dims, decoded.pivot);
1666 assert!(!book.set_frame(9, extra), "out-of-range set_frame is false");
1667
1668 let after = render0(&book);
1669 assert!(
1670 after.iter().any(|&p| (p & 0x0000_FF00) != 0),
1671 "frame 0 now green"
1672 );
1673 assert_ne!(before, after);
1674 }
1675
1676 #[test]
1679 fn cube_sprite_renders() {
1680 let kv6 = Kv6::solid_cube(8, 0x80_C0_40_20);
1681 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1682 let (w, h) = (64u32, 64u32);
1683 let n = (w * h) as usize;
1684 let mut fb = vec![0u32; n];
1685 let mut zb = vec![f32::INFINITY; n];
1686 let cam = cam_looking_y();
1687 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1688 let wrote = draw_sprite_dda(
1689 &mut fb,
1690 &mut zb,
1691 w as usize,
1692 w,
1693 h,
1694 &cs,
1695 &settings(w, h),
1696 &sprite,
1697 );
1698
1699 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1700 let centre = (h / 2 * w + w / 2) as usize;
1701 assert_eq!(
1702 fb[centre] & 0x00ff_ffff,
1703 0x00_C0_40_20,
1704 "got {:08x}",
1705 fb[centre]
1706 );
1707 assert!(
1709 (zb[centre] - 36.0).abs() < 3.0,
1710 "centre depth {} not ≈ 36",
1711 zb[centre]
1712 );
1713 }
1714
1715 #[test]
1720 fn zero_high_byte_sprite_not_black() {
1721 let kv6 = Kv6::solid_cube(8, 0x00_C0_40_20);
1722 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1723 let (w, h) = (64u32, 64u32);
1724 let n = (w * h) as usize;
1725 let mut fb = vec![0u32; n];
1726 let mut zb = vec![f32::INFINITY; n];
1727 let cam = cam_looking_y();
1728 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1729 let wrote = draw_sprite_dda(
1730 &mut fb,
1731 &mut zb,
1732 w as usize,
1733 w,
1734 h,
1735 &cs,
1736 &settings(w, h),
1737 &sprite,
1738 );
1739 assert!(wrote > 20, "cube should cover many pixels (got {wrote})");
1740 let centre = (h / 2 * w + w / 2) as usize;
1741 assert_eq!(
1742 fb[centre] & 0x00ff_ffff,
1743 0x00_C0_40_20,
1744 "zero-high-byte sprite rendered as {:08x} (black bug)",
1745 fb[centre]
1746 );
1747 }
1748
1749 #[test]
1752 fn sprite_respects_zbuffer() {
1753 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1754 let sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1755 let (w, h) = (32u32, 32u32);
1756 let n = (w * h) as usize;
1757 let cam = cam_looking_y();
1758 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1759 let centre = (h / 2 * w + w / 2) as usize;
1760
1761 let mut fb = vec![0u32; n];
1763 let mut zb = vec![f32::INFINITY; n];
1764 fb[centre] = 0x80_11_22_33;
1765 zb[centre] = 10.0;
1766 let _ = draw_sprite_dda(
1767 &mut fb,
1768 &mut zb,
1769 w as usize,
1770 w,
1771 h,
1772 &cs,
1773 &settings(w, h),
1774 &sprite,
1775 );
1776 assert_eq!(
1777 fb[centre], 0x80_11_22_33,
1778 "near terrain must occlude sprite"
1779 );
1780
1781 let mut fb2 = vec![0u32; n];
1783 let mut zb2 = vec![f32::INFINITY; n];
1784 fb2[centre] = 0x80_11_22_33;
1785 zb2[centre] = 100.0;
1786 let _ = draw_sprite_dda(
1787 &mut fb2,
1788 &mut zb2,
1789 w as usize,
1790 w,
1791 h,
1792 &cs,
1793 &settings(w, h),
1794 &sprite,
1795 );
1796 assert_ne!(fb2[centre], 0x80_11_22_33, "sprite must beat far terrain");
1797 assert!(zb2[centre] < 100.0, "sprite depth must replace terrain's");
1798 }
1799
1800 fn covered_rect(fb: &[u32], w: u32, h: u32) -> (u32, u32, u32, u32) {
1803 let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0u32, 0u32);
1804 for py in 0..h {
1805 for px in 0..w {
1806 if fb[(py * w + px) as usize] & 0x00ff_ffff != 0 {
1807 x0 = x0.min(px);
1808 y0 = y0.min(py);
1809 x1 = x1.max(px);
1810 y1 = y1.max(py);
1811 }
1812 }
1813 }
1814 (x0, y0, x1, y1)
1815 }
1816
1817 #[test]
1822 fn posed_basis_reorients_silhouette() {
1823 let kv6 = Kv6::solid_box(16, 4, 4, 0x80_C0_40_20);
1826 let (w, h) = (64u32, 64u32);
1827 let n = (w * h) as usize;
1828 let cam = cam_looking_y();
1829 let cs = camera_math::derive(&cam, w, h, 32.0, 32.0, 32.0);
1830
1831 let aa = Sprite::axis_aligned(kv6.clone(), [0.0, 40.0, 0.0]);
1833 let mut fb = vec![0u32; n];
1834 let mut zb = vec![f32::INFINITY; n];
1835 let _ = draw_sprite_dda(
1836 &mut fb,
1837 &mut zb,
1838 w as usize,
1839 w,
1840 h,
1841 &cs,
1842 &settings(w, h),
1843 &aa,
1844 );
1845 let (ax0, ay0, ax1, ay1) = covered_rect(&fb, w, h);
1846 let aa_wide = (ax1 - ax0) as i32 - (ay1 - ay0) as i32;
1847 assert!(
1848 aa_wide > 4,
1849 "axis-aligned box should be wider than tall (got w-h={aa_wide})"
1850 );
1851
1852 let mut posed = aa.clone();
1855 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];
1859 let mut zb2 = vec![f32::INFINITY; n];
1860 let _ = draw_sprite_dda(
1861 &mut fb2,
1862 &mut zb2,
1863 w as usize,
1864 w,
1865 h,
1866 &cs,
1867 &settings(w, h),
1868 &posed,
1869 );
1870 let (bx0, by0, bx1, by1) = covered_rect(&fb2, w, h);
1871 let posed_tall = (by1 - by0) as i32 - (bx1 - bx0) as i32;
1872 assert!(
1873 posed_tall > 4,
1874 "posed box should be taller than wide (got h-w={posed_tall})"
1875 );
1876 }
1877
1878 #[test]
1881 fn degenerate_basis_draws_nothing() {
1882 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1883 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1884 sprite.f = sprite.s; let (w, h) = (32u32, 32u32);
1886 let n = (w * h) as usize;
1887 let mut fb = vec![0u32; n];
1888 let mut zb = vec![f32::INFINITY; n];
1889 let cam = cam_looking_y();
1890 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1891 let wrote = draw_sprite_dda(
1892 &mut fb,
1893 &mut zb,
1894 w as usize,
1895 w,
1896 h,
1897 &cs,
1898 &settings(w, h),
1899 &sprite,
1900 );
1901 assert_eq!(wrote, 0, "singular basis must skip, not panic");
1902 }
1903
1904 #[test]
1906 fn invisible_sprite_skipped() {
1907 let kv6 = Kv6::solid_cube(8, 0x80_FF_FF_FF);
1908 let mut sprite = Sprite::axis_aligned(kv6, [0.0, 40.0, 0.0]);
1909 sprite.flags |= roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
1910 let (w, h) = (32u32, 32u32);
1911 let n = (w * h) as usize;
1912 let mut fb = vec![0u32; n];
1913 let mut zb = vec![f32::INFINITY; n];
1914 let cam = cam_looking_y();
1915 let cs = camera_math::derive(&cam, w, h, 16.0, 16.0, 16.0);
1916 let wrote = draw_sprite_dda(
1917 &mut fb,
1918 &mut zb,
1919 w as usize,
1920 w,
1921 h,
1922 &cs,
1923 &settings(w, h),
1924 &sprite,
1925 );
1926 assert_eq!(wrote, 0);
1927 }
1928
1929 fn draw_cube_shaded(mat: Material, alpha_mul: u8, bg: u32, zb_v: f32) -> (u32, Vec<u32>) {
1935 let mut table = MaterialTable::new();
1936 table.set(1, mat);
1937 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
1938 let (w, h) = (64u32, 64u32);
1939 let n = (w * h) as usize;
1940 let mut fb = vec![bg; n];
1941 let mut zb = vec![zb_v; n];
1942 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
1943 let sh = SpriteShade {
1944 materials: &table,
1945 lights: CpuLights::default(),
1946 material: 1,
1947 alpha_mul,
1948 tint: 0x00FF_FFFF,
1949 shadow: None,
1950 };
1951 let _ = draw_sprite_dense_shaded(
1952 &mut fb,
1953 &mut zb,
1954 w as usize,
1955 w,
1956 h,
1957 &cs,
1958 &settings(w, h),
1959 &dense,
1960 [0.0, 40.0, 0.0],
1961 [1.0, 0.0, 0.0],
1962 [0.0, 1.0, 0.0],
1963 [0.0, 0.0, 1.0],
1964 0,
1965 Some(sh),
1966 );
1967 (fb[(h / 2 * w + w / 2) as usize], fb)
1968 }
1969
1970 #[test]
1973 fn additive_sprite_brightens_background() {
1974 let bg = 0x80_20_20_20;
1975 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, f32::INFINITY);
1976 let (cr, cg, cb) = ((centre >> 16) & 0xff, (centre >> 8) & 0xff, centre & 0xff);
1977 assert!(
1978 cr > 0x20 && cg > 0x20 && cb >= 0x20,
1979 "centre {centre:08x} should be brighter than bg"
1980 );
1981 assert!(
1983 cr >= cg && cr >= cb,
1984 "additive of a red-dominant cube stays red-dominant"
1985 );
1986 }
1987
1988 #[test]
1991 fn alpha_blend_sprite_between_bg_and_color() {
1992 let bg = 0x80_20_20_20;
1993 let (centre, _) = draw_cube_shaded(Material::alpha_blend(128), 255, bg, f32::INFINITY);
1994 let cr = (centre >> 16) & 0xff;
1995 assert!(
1996 cr > 0x20,
1997 "blended red must rise above bg 0x20 (got {cr:02x})"
1998 );
1999 assert!(
2000 cr < 0xC0,
2001 "blended red must stay below opaque 0xC0 (got {cr:02x})"
2002 );
2003 assert_ne!(centre & 0x00ff_ffff, bg & 0x00ff_ffff);
2005 assert_ne!(centre & 0x00ff_ffff, 0x00_C0_40_20);
2006 }
2007
2008 #[test]
2011 fn alpha_mul_scales_opacity() {
2012 let bg = 0x80_20_20_20;
2013 let (full, _) = draw_cube_shaded(Material::alpha_blend(255), 255, bg, f32::INFINITY);
2014 let (faded, _) = draw_cube_shaded(Material::alpha_blend(255), 64, bg, f32::INFINITY);
2015 let r_full = (full >> 16) & 0xff;
2016 let r_faded = (faded >> 16) & 0xff;
2017 assert!(
2019 r_full > r_faded,
2020 "alpha_mul=255 ({r_full:02x}) more opaque than 64 ({r_faded:02x})"
2021 );
2022 assert!(r_faded > 0x20, "even faded lifts above bg");
2023 }
2024
2025 #[test]
2029 fn opaque_shade_ctx_matches_plain_path() {
2030 let table = MaterialTable::new();
2031 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_C0_40_20));
2032 let (w, h) = (64u32, 64u32);
2033 let n = (w * h) as usize;
2034 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2035 let pose = (
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 );
2041
2042 let mut fb_plain = vec![0u32; n];
2043 let mut zb_plain = vec![f32::INFINITY; n];
2044 let _ = draw_sprite_dense(
2045 &mut fb_plain,
2046 &mut zb_plain,
2047 w as usize,
2048 w,
2049 h,
2050 &cs,
2051 &settings(w, h),
2052 &dense,
2053 pose.0,
2054 pose.1,
2055 pose.2,
2056 pose.3,
2057 0,
2058 );
2059
2060 let mut fb_sh = vec![0u32; n];
2061 let mut zb_sh = vec![f32::INFINITY; n];
2062 let sh = SpriteShade {
2063 materials: &table,
2064 lights: CpuLights::default(),
2065 material: 0, alpha_mul: 255,
2067 tint: 0x00FF_FFFF,
2068 shadow: None,
2069 };
2070 let _ = draw_sprite_dense_shaded(
2071 &mut fb_sh,
2072 &mut zb_sh,
2073 w as usize,
2074 w,
2075 h,
2076 &cs,
2077 &settings(w, h),
2078 &dense,
2079 pose.0,
2080 pose.1,
2081 pose.2,
2082 pose.3,
2083 0,
2084 Some(sh),
2085 );
2086
2087 assert_eq!(
2088 fb_plain, fb_sh,
2089 "opaque shade-ctx must match the plain path bit-for-bit"
2090 );
2091 assert_eq!(zb_plain, zb_sh, "opaque shade-ctx z-buffer must match too");
2092 }
2093
2094 #[test]
2098 fn translucent_sprite_occluded_by_near_terrain() {
2099 let bg = 0x80_20_20_20;
2100 let (centre, _) = draw_cube_shaded(Material::additive(255), 255, bg, 5.0);
2101 assert_eq!(
2102 centre, bg,
2103 "near terrain (z=5) must occlude the sprite at y≈36"
2104 );
2105 }
2106
2107 #[test]
2113 fn per_span_thickness_independent() {
2114 fn centre(ysiz: u32) -> u32 {
2115 let mut table = MaterialTable::new();
2116 table.set(1, Material::alpha_blend(128));
2117 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, ysiz, 8, 0x80_C0_40_20));
2118 let (w, h) = (64u32, 64u32);
2119 let n = (w * h) as usize;
2120 let mut fb = vec![0x80_10_10_10u32; n];
2121 let mut zb = vec![f32::INFINITY; n];
2122 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2123 let sh = SpriteShade {
2124 materials: &table,
2125 lights: CpuLights::default(),
2126 material: 1,
2127 alpha_mul: 255,
2128 tint: 0x00FF_FFFF,
2129 shadow: None,
2130 };
2131 let _ = draw_sprite_dense_shaded(
2132 &mut fb,
2133 &mut zb,
2134 w as usize,
2135 w,
2136 h,
2137 &cs,
2138 &settings(w, h),
2139 &dense,
2140 [0.0, 40.0, 0.0],
2141 [1.0, 0.0, 0.0],
2142 [0.0, 1.0, 0.0],
2143 [0.0, 0.0, 1.0],
2144 0,
2145 Some(sh),
2146 );
2147 fb[(h / 2 * w + w / 2) as usize] & 0x00ff_ffff
2148 }
2149 assert_eq!(
2153 centre(1),
2154 centre(2),
2155 "per-span: a 2-thick slab must match a 1-thick one (no double-count)"
2156 );
2157 }
2158
2159 #[test]
2164 fn volumetric_thickness_deepens_opacity() {
2165 fn red_at(depth: u32) -> u32 {
2168 let mut table = MaterialTable::new();
2169 table.set(1, Material::volumetric(128));
2170 let kv6 =
2175 Kv6::from_fn_keep_interior(8, depth, 8, |_, _, _| Some(0x80_C0_20_20), |_| true);
2176 let dense = SpriteDense::from_kv6(&kv6);
2177 let (w, h) = (64u32, 64u32);
2178 let n = (w * h) as usize;
2179 let mut fb = vec![0x80_10_10_10u32; n];
2180 let mut zb = vec![f32::INFINITY; n];
2181 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2182 let sh = SpriteShade {
2183 materials: &table,
2184 lights: CpuLights::default(),
2185 material: 1,
2186 alpha_mul: 255,
2187 tint: 0x00FF_FFFF,
2188 shadow: None,
2189 };
2190 let _ = draw_sprite_dense_shaded(
2191 &mut fb,
2192 &mut zb,
2193 w as usize,
2194 w,
2195 h,
2196 &cs,
2197 &settings(w, h),
2198 &dense,
2199 [0.0, 40.0, 0.0],
2200 [1.0, 0.0, 0.0],
2201 [0.0, 1.0, 0.0],
2202 [0.0, 0.0, 1.0],
2203 0,
2204 Some(sh),
2205 );
2206 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2207 }
2208 let shallow = red_at(1);
2209 let deep = red_at(12);
2210 assert!(
2213 shallow > 0x10,
2214 "even a 1-deep volume tints (got {shallow:02x})"
2215 );
2216 assert!(
2217 deep > shallow,
2218 "deeper Volumetric volume is more opaque: deep {deep:02x} > shallow {shallow:02x}"
2219 );
2220 }
2221
2222 #[test]
2227 fn sprite_occluder_blocks_ray_through_volume() {
2228 use crate::dda::WorldOccluder;
2229 let dense = Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF)));
2232 let mut occ = SpriteOccluder::new();
2233 occ.push(
2234 dense,
2235 [0.0, 0.0, 0.0],
2236 [1.0, 0.0, 0.0],
2237 [0.0, 1.0, 0.0],
2238 [0.0, 0.0, 1.0],
2239 );
2240 assert!(!occ.is_empty());
2241 assert!(
2243 occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2244 "a ray through the cube must be occluded"
2245 );
2246 assert!(
2248 !occ.occluded_world([50.0, 0.0, -50.0], [0.0, 0.0, 1.0], 100.0),
2249 "a ray missing the cube must not be occluded"
2250 );
2251 assert!(
2253 !occ.occluded_world([0.0, 0.0, -50.0], [0.0, 0.0, 1.0], 10.0),
2254 "max_t shorter than the distance to the cube ⇒ unoccluded"
2255 );
2256 }
2257
2258 #[test]
2263 fn sprite_receives_hard_shadow() {
2264 let target = SpriteDense::from_kv6(&Kv6::from_fn(16, 16, 16, |x, y, z| {
2270 let (dx, dy, dz) = (x as i32 - 8, y as i32 - 8, z as i32 - 8);
2271 (dx * dx + dy * dy + dz * dz <= 49).then_some(0x80_C0_C0_C0)
2272 }));
2273 let mut occ = SpriteOccluder::new();
2274 occ.push(
2275 Arc::new(SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF))),
2276 [0.0, 25.0, 0.0],
2277 [1.0, 0.0, 0.0],
2278 [0.0, 1.0, 0.0],
2279 [0.0, 0.0, 1.0],
2280 );
2281 let table = MaterialTable::new();
2282 let base = CpuLights {
2283 enabled: true,
2284 sun: true,
2285 sun_dir: [0.0, -1.0, 0.0], sun_color: [1.0; 3],
2287 sun_intensity: 1.0,
2288 sun_casts_shadow: true,
2289 ambient: [0.3; 3],
2290 shadow_strength: 0.85,
2291 shadow_bias: 1.5,
2292 shadow_max_dist: 128.0,
2293 ..CpuLights::default()
2294 };
2295 let (w, h) = (64u32, 64u32);
2296 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2297 let sum_lum = |shadow: Option<&dyn crate::dda::WorldOccluder>| -> u64 {
2298 let n = (w * h) as usize;
2299 let mut fb = vec![0u32; n];
2300 let mut zb = vec![f32::INFINITY; n];
2301 let sh = SpriteShade {
2302 materials: &table,
2303 lights: base,
2304 material: 0,
2305 alpha_mul: 255,
2306 tint: 0x00FF_FFFF,
2307 shadow,
2308 };
2309 let _ = draw_sprite_dense_shaded(
2310 &mut fb,
2311 &mut zb,
2312 w as usize,
2313 w,
2314 h,
2315 &cs,
2316 &settings(w, h),
2317 &target,
2318 [0.0, 40.0, 0.0],
2319 [1.0, 0.0, 0.0],
2320 [0.0, 1.0, 0.0],
2321 [0.0, 0.0, 1.0],
2322 0,
2323 Some(sh),
2324 );
2325 fb.iter()
2326 .map(|&p| u64::from((p & 0xff) + ((p >> 8) & 0xff) + ((p >> 16) & 0xff)))
2327 .sum()
2328 };
2329 let lit = sum_lum(None);
2330 let shadowed = sum_lum(Some(&occ));
2331 assert!(
2332 shadowed < lit,
2333 "the blocker must shadow the drawn sprite: shadowed={shadowed} lit={lit}"
2334 );
2335 }
2336
2337 #[test]
2340 fn sprite_rgb_tint_recolours() {
2341 let table = MaterialTable::new();
2342 let dense = SpriteDense::from_kv6(&Kv6::solid_cube(8, 0x80_FF_FF_FF));
2343 let (w, h) = (64u32, 64u32);
2344 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2345 let centre = |tint: u32| -> u32 {
2346 let n = (w * h) as usize;
2347 let mut fb = vec![0u32; n];
2348 let mut zb = vec![f32::INFINITY; n];
2349 let sh = SpriteShade {
2350 materials: &table,
2351 lights: CpuLights::default(),
2352 material: 0,
2353 alpha_mul: 255,
2354 tint,
2355 shadow: None,
2356 };
2357 let _ = draw_sprite_dense_shaded(
2358 &mut fb,
2359 &mut zb,
2360 w as usize,
2361 w,
2362 h,
2363 &cs,
2364 &settings(w, h),
2365 &dense,
2366 [0.0, 40.0, 0.0],
2367 [1.0, 0.0, 0.0],
2368 [0.0, 1.0, 0.0],
2369 [0.0, 0.0, 1.0],
2370 0,
2371 Some(sh),
2372 );
2373 fb[(h / 2 * w + w / 2) as usize]
2374 };
2375 let r = |p: u32| (p >> 16) & 0xff;
2376 let g = |p: u32| (p >> 8) & 0xff;
2377 let b = |p: u32| p & 0xff;
2378 let white = centre(0x00FF_FFFF);
2379 let red = centre(0x00FF_0000);
2380 assert!(
2381 g(white) > 180 && b(white) > 180 && r(white) > 180,
2382 "white tint must be a no-op: {white:#08x}"
2383 );
2384 assert!(
2385 r(red) > 180 && g(red) < 20 && b(red) < 20,
2386 "red tint zeroes green/blue, keeps red: {red:#08x}"
2387 );
2388 }
2389
2390 #[test]
2395 fn translucent_sprite_layers_are_lit() {
2396 fn center_red(lights: CpuLights) -> u32 {
2397 let mut table = MaterialTable::new();
2398 table.set(1, Material::alpha_blend(160));
2399 let dense = SpriteDense::from_kv6(&Kv6::solid_box(8, 8, 8, 0x80_E0_30_30));
2400 let (w, h) = (64u32, 64u32);
2401 let n = (w * h) as usize;
2402 let mut fb = vec![0x80_10_10_10u32; n];
2403 let mut zb = vec![f32::INFINITY; n];
2404 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2405 let sh = SpriteShade {
2406 materials: &table,
2407 lights,
2408 material: 1,
2409 alpha_mul: 255,
2410 tint: 0x00FF_FFFF,
2411 shadow: None,
2412 };
2413 let _ = draw_sprite_dense_shaded(
2414 &mut fb,
2415 &mut zb,
2416 w as usize,
2417 w,
2418 h,
2419 &cs,
2420 &settings(w, h),
2421 &dense,
2422 [0.0, 40.0, 0.0],
2423 [1.0, 0.0, 0.0],
2424 [0.0, 1.0, 0.0],
2425 [0.0, 0.0, 1.0],
2426 0,
2427 Some(sh),
2428 );
2429 (fb[(h / 2 * w + w / 2) as usize] >> 16) & 0xff
2430 }
2431 let baked = center_red(CpuLights::default()); let dim = center_red(CpuLights {
2433 enabled: true,
2434 ambient: [0.3; 3], ..CpuLights::default()
2436 });
2437 assert!(
2438 dim < baked,
2439 "lit translucent layer must respond to the rig (dim ambient darkens): dim={dim:#x} baked={baked:#x}",
2440 );
2441 }
2442
2443 #[test]
2448 fn translucent_sprite_tints_opaque_sprite_behind() {
2449 let mut table = MaterialTable::new();
2450 table.set(1, Material::alpha_blend(128));
2451 let (w, h) = (64u32, 64u32);
2452 let n = (w * h) as usize;
2453 let mut fb = vec![0x80_10_20_40u32; n]; let mut zb = vec![f32::INFINITY; n];
2455 let cs = camera_math::derive(&cam_looking_y(), w, h, 32.0, 32.0, 32.0);
2456 let cfg = settings(w, h);
2457 let id = [1.0, 0.0, 0.0];
2458 let up = [0.0, 1.0, 0.0];
2459 let fw = [0.0, 0.0, 1.0];
2460 let centre = (h / 2 * w + w / 2) as usize;
2461
2462 let backdrop = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_FF_00_00));
2464 let sh_op = SpriteShade {
2465 materials: &table,
2466 lights: CpuLights::default(),
2467 material: 0,
2468 alpha_mul: 255,
2469 tint: 0x00FF_FFFF,
2470 shadow: None,
2471 };
2472 let _ = draw_sprite_dense_shaded(
2473 &mut fb,
2474 &mut zb,
2475 w as usize,
2476 w,
2477 h,
2478 &cs,
2479 &cfg,
2480 &backdrop,
2481 [0.0, 80.0, 0.0],
2482 id,
2483 up,
2484 fw,
2485 0,
2486 Some(sh_op),
2487 );
2488 let after_backdrop = fb[centre];
2489 assert_eq!(
2490 after_backdrop & 0x00ff_ffff,
2491 0x00FF_0000,
2492 "backdrop red must be drawn first"
2493 );
2494
2495 let glass = SpriteDense::from_kv6(&Kv6::solid_cube(12, 0x80_00_FF_FF));
2497 let sh_gl = SpriteShade {
2498 materials: &table,
2499 lights: CpuLights::default(),
2500 material: 1,
2501 alpha_mul: 255,
2502 tint: 0x00FF_FFFF,
2503 shadow: None,
2504 };
2505 let wrote = draw_sprite_dense_shaded(
2506 &mut fb,
2507 &mut zb,
2508 w as usize,
2509 w,
2510 h,
2511 &cs,
2512 &cfg,
2513 &glass,
2514 [0.0, 40.0, 0.0],
2515 id,
2516 up,
2517 fw,
2518 0,
2519 Some(sh_gl),
2520 );
2521 let _ = wrote;
2522 let after_glass = fb[centre];
2523 assert_ne!(
2524 after_glass, after_backdrop,
2525 "glass must tint the backdrop (composite over it)"
2526 );
2527 assert!(
2529 (after_glass >> 16) & 0xff < 0xFF,
2530 "glass should reduce the backdrop's red (got {after_glass:08x})"
2531 );
2532 }
2533
2534 #[test]
2537 fn from_kv6_with_materials_classifies_by_color() {
2538 let col = 0x80_AA_BB_CC;
2539 let kv6 = Kv6::solid_cube(6, col);
2540 let dense = SpriteDense::from_kv6_with_materials(&kv6, &[(0x00AA_BBCC, 2)]);
2541 assert_eq!(
2542 dense.mat.len(),
2543 dense.col.len(),
2544 "per-voxel mat array sized"
2545 );
2546 let mut solids = 0;
2547 for idx in 0..dense.occ.len() {
2548 if dense.occ[idx] {
2549 assert_eq!(dense.mat[idx], 2, "mapped colour → material 2");
2550 solids += 1;
2551 }
2552 }
2553 assert!(solids > 0, "cube has solid voxels");
2554 let dense0 = SpriteDense::from_kv6_with_materials(&kv6, &[(0x0012_3456, 5)]);
2556 assert!(
2557 dense0.mat.iter().all(|&m| m == 0),
2558 "unmapped colour → material 0"
2559 );
2560 }
2561
2562 #[test]
2567 fn per_voxel_material_matches_uniform_when_homogeneous() {
2568 let mut table = MaterialTable::new();
2569 table.set(1, Material::alpha_blend(120));
2570 let col = 0x80_30_A0_F0;
2571 let kv6 = Kv6::solid_cube(10, col);
2572 let (w, h) = (64u32, 64u32);
2573 let n = (w * h) as usize;
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 (pos, s, hh, f) = (
2577 [0.0, 40.0, 0.0],
2578 [1.0, 0.0, 0.0],
2579 [0.0, 1.0, 0.0],
2580 [0.0, 0.0, 1.0],
2581 );
2582 let render = |dense: &SpriteDense, material: u8| -> Vec<u32> {
2583 let mut fb = vec![0x80_10_10_10u32; n];
2584 let mut zb = vec![f32::INFINITY; n];
2585 let sh = SpriteShade {
2586 materials: &table,
2587 lights: CpuLights::default(),
2588 material,
2589 alpha_mul: 255,
2590 tint: 0x00FF_FFFF,
2591 shadow: None,
2592 };
2593 let _ = draw_sprite_dense_shaded(
2594 &mut fb,
2595 &mut zb,
2596 w as usize,
2597 w,
2598 h,
2599 &cs,
2600 &cfg,
2601 dense,
2602 pos,
2603 s,
2604 hh,
2605 f,
2606 0,
2607 Some(sh),
2608 );
2609 fb
2610 };
2611 let pv = render(
2614 &SpriteDense::from_kv6_with_materials(&kv6, &[(col & 0xff_ffff, 1)]),
2615 0,
2616 );
2617 let un = render(&SpriteDense::from_kv6(&kv6), 1);
2619 assert_eq!(pv, un, "homogeneous per-voxel material == uniform material");
2620 let centre = (h / 2 * w + w / 2) as usize;
2622 assert_ne!(pv[centre] & 0x00ff_ffff, 0x0010_1010, "translucent, not bg");
2623 }
2624
2625 #[test]
2630 fn clip_flipbook_with_materials_classifies_every_frame() {
2631 let dims = [6u32, 6, 6];
2632 let glass = 0x00AA_BBCC;
2633 let glass_lit = 0x80AA_BBCC;
2634 let f0 = clip_frame(dims, |_x, _y, z| (z < 3).then_some(glass_lit));
2636 let f1 = clip_frame(dims, |_x, _y, z| (z >= 3).then_some(glass_lit));
2637 let clip = VoxelClip::from_frames(
2638 dims,
2639 [3.0, 3.0, 3.0],
2640 1.0,
2641 LoopMode::Loop,
2642 &[f0, f1],
2643 &[],
2644 33,
2645 0,
2646 );
2647 let decoded = clip.decode().expect("decode");
2648
2649 let book = ClipFlipbook::from_decoded_with_materials(&decoded, &[(glass, 2)]);
2650 assert_eq!(book.frame_count(), 2);
2651 for fr in 0..2 {
2652 let dense = book.frame(fr).expect("frame in range");
2653 assert_eq!(dense.mat.len(), dense.col.len(), "frame {fr} mat sized");
2654 let mut solids = 0;
2655 for idx in 0..dense.occ.len() {
2656 if dense.occ[idx] {
2657 assert_eq!(dense.mat[idx], 2, "frame {fr}: glass → material 2");
2658 solids += 1;
2659 }
2660 }
2661 assert!(solids > 0, "frame {fr} has solid voxels");
2662 }
2663
2664 let plain = ClipFlipbook::from_decoded(&decoded);
2666 let plain_mat = ClipFlipbook::from_decoded_with_materials(&decoded, &[]);
2667 for fr in 0..2 {
2668 assert!(plain.frame(fr).unwrap().mat.is_empty());
2669 assert!(plain_mat.frame(fr).unwrap().mat.is_empty());
2670 }
2671 }
2672}