1use std::collections::HashMap;
36use std::hash::{BuildHasherDefault, Hasher};
37
38use glam::{DVec3, IVec2, IVec3, UVec3, Vec2};
39use roxlap_formats::color::Rgb;
40use roxlap_formats::vxl::Vxl;
41
42use crate::{DirtyExtent, Grid, GridId, GridTransform, LodThresholds, Scene, CHUNK_SIZE_XY};
43
44pub const INTENSITY_MAX: u8 = 63;
46
47const TILE: i32 = CHUNK_SIZE_XY as i32;
50const TILE_CELLS: usize = (TILE * TILE) as usize;
51const TILE_WORDS: usize = TILE_CELLS / 64;
52
53const SETTLE_EPS: f32 = 0.25;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[repr(u8)]
61pub enum CellState {
62 Unseen = 0,
64 Memory = 1,
66 Visible = 2,
68 Heard = 3,
70}
71
72impl CellState {
73 fn from_bits(bits: u8) -> Self {
74 match bits & 3 {
75 1 => Self::Memory,
76 2 => Self::Visible,
77 3 => Self::Heard,
78 _ => Self::Unseen,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub struct DeckBand {
91 pub z_top: i32,
93 pub z_bottom: i32,
96}
97
98impl DeckBand {
99 #[must_use]
101 pub fn contains_z(&self, z: i32) -> bool {
102 (self.z_top..=self.z_bottom).contains(&z)
103 }
104}
105
106const EYE_HALF: i32 = 2;
111
112#[derive(Debug, Clone, PartialEq)]
119pub struct LightGate {
120 pub lit_brightness: u8,
123 pub softness: u8,
127 pub emissive_colors: Vec<Rgb>,
131}
132
133impl LightGate {
134 #[must_use]
137 pub fn lit_factor(&self, b: u8) -> f32 {
138 if b >= self.lit_brightness {
139 return 1.0;
140 }
141 if self.softness == 0 {
142 return 0.0;
143 }
144 let knee = self.lit_brightness.saturating_sub(self.softness);
145 if b <= knee {
146 return 0.0;
147 }
148 f32::from(b - knee) / f32::from(self.softness)
149 }
150}
151
152#[derive(Debug, Clone, PartialEq)]
157pub struct VisionConfig {
158 pub cone_half_angle: f32,
160 pub cone_taper: f32,
163 pub range: f32,
165 pub peripheral_range: f32,
168 pub edge_taper: f32,
171 pub decks: Vec<DeckBand>,
175 pub light_gate: Option<LightGate>,
177 pub fade_in: f32,
179 pub fade_out: f32,
182 pub memory_intensity: u8,
184 pub memory_decay: f32,
187 pub memory_floor: u8,
190 pub heard_radius: f32,
192 pub heard_duration: f32,
194 pub memory_dim: f32,
196 pub memory_desaturate: f32,
198}
199
200impl VisionConfig {
201 #[must_use]
204 pub fn for_decks(decks: Vec<DeckBand>) -> Self {
205 Self {
206 cone_half_angle: 1.05,
207 cone_taper: 0.12,
208 range: 96.0,
209 peripheral_range: 12.0,
210 edge_taper: 4.0,
211 decks,
212 light_gate: None,
213 fade_in: 240.0,
214 fade_out: 120.0,
215 memory_intensity: 26,
216 memory_decay: 0.8,
217 memory_floor: 10,
218 heard_radius: 10.0,
219 heard_duration: 1.5,
220 memory_dim: 0.45,
221 memory_desaturate: 0.6,
222 }
223 }
224
225 #[must_use]
229 pub fn deck_for_z(&self, z: i32) -> Option<usize> {
230 self.decks.iter().position(|d| d.contains_z(z))
231 }
232}
233
234#[derive(Debug, Clone, PartialEq)]
240pub struct GpuFowMask {
241 pub origin_cell: [i32; 2],
243 pub width: u32,
245 pub height: u32,
247 pub decks: Vec<[i32; 2]>,
250 pub active_deck: usize,
255 pub cells: Vec<u8>,
258 pub memory_dim: f32,
260 pub memory_desaturate: f32,
262 pub version: u64,
264}
265
266#[derive(Debug, Clone, Copy)]
270pub struct FowObserver {
271 pub cell: IVec2,
273 pub facing: Vec2,
276 pub deck: usize,
278 pub eye_z: i32,
282}
283
284struct MaskTile {
287 bytes: Box<[u8; TILE_CELLS]>,
288}
289
290impl MaskTile {
291 fn new() -> Self {
292 Self {
293 bytes: vec![0u8; TILE_CELLS].into_boxed_slice().try_into().unwrap(),
294 }
295 }
296}
297
298struct OpacityTile {
301 key: u64,
302 computed: Box<[u64; TILE_WORDS]>,
303 blocked: Box<[u64; TILE_WORDS]>,
304 lit: Box<[u8; TILE_CELLS]>,
307}
308
309impl OpacityTile {
310 fn new(key: u64) -> Self {
311 Self {
312 key,
313 computed: vec![0u64; TILE_WORDS]
314 .into_boxed_slice()
315 .try_into()
316 .unwrap(),
317 blocked: vec![0u64; TILE_WORDS]
318 .into_boxed_slice()
319 .try_into()
320 .unwrap(),
321 lit: vec![0u8; TILE_CELLS].into_boxed_slice().try_into().unwrap(),
322 }
323 }
324}
325
326#[derive(Default)]
336struct FxHasher(u64);
337
338impl Hasher for FxHasher {
339 #[inline]
340 fn finish(&self) -> u64 {
341 self.0
342 }
343 #[inline]
344 fn write(&mut self, bytes: &[u8]) {
345 for &b in bytes {
346 self.write_u64(u64::from(b));
347 }
348 }
349 #[inline]
350 fn write_i32(&mut self, i: i32) {
351 self.write_u64(u64::from(i as u32));
352 }
353 #[inline]
354 fn write_u64(&mut self, n: u64) {
355 const K: u64 = 0x517c_c1b7_2722_0a95;
356 self.0 = (self.0.rotate_left(5) ^ n).wrapping_mul(K);
357 }
358}
359
360type FastMap<K, V> = HashMap<K, V, BuildHasherDefault<FxHasher>>;
361
362#[derive(Default)]
365struct DeckLayer {
366 mask: FastMap<(i32, i32), MaskTile>,
367 opacity: FastMap<(i32, i32), OpacityTile>,
368}
369
370fn split_cell(cell: IVec2) -> ((i32, i32), usize) {
371 let tx = cell.x.div_euclid(TILE);
372 let ty = cell.y.div_euclid(TILE);
373 let ix = cell.x.rem_euclid(TILE);
374 let iy = cell.y.rem_euclid(TILE);
375 ((tx, ty), (iy * TILE + ix) as usize)
376}
377
378impl DeckLayer {
379 fn byte(&self, cell: IVec2) -> u8 {
380 let (tile, idx) = split_cell(cell);
381 self.mask.get(&tile).map_or(0, |t| t.bytes[idx])
382 }
383
384 fn write(&mut self, cell: IVec2, val: u8) -> bool {
386 let (tile, idx) = split_cell(cell);
387 if val == 0 && !self.mask.contains_key(&tile) {
388 return false;
389 }
390 let t = self.mask.entry(tile).or_insert_with(MaskTile::new);
391 let changed = t.bytes[idx] != val;
392 t.bytes[idx] = val;
393 changed
394 }
395}
396
397fn pack(state: CellState, intensity: u8) -> u8 {
398 ((state as u8) << 6) | intensity.min(INTENSITY_MAX)
399}
400
401#[inline]
402fn fnv(h: &mut u64, v: u64) {
403 *h ^= v;
404 *h = h.wrapping_mul(0x0000_0100_0000_01b3);
405}
406
407#[inline]
415fn mix_chunk_sig(h: &mut u64, grid: &Grid, idx: IVec3) {
416 fnv(h, grid.chunk_version(idx));
417 fnv(h, u64::from(grid.chunk(idx).is_some()));
418}
419
420fn band_chz_range(band: DeckBand, eye_lo: i32, eye_hi: i32) -> (i32, i32) {
421 let cs_z = crate::CHUNK_SIZE_Z as i32;
422 (
423 band.z_top.min(eye_lo).div_euclid(cs_z),
424 band.z_bottom.max(eye_hi).div_euclid(cs_z),
425 )
426}
427
428fn deck_chz_range(band: DeckBand) -> (i32, i32) {
432 let cs_z = crate::CHUNK_SIZE_Z as i32;
433 (band.z_top.div_euclid(cs_z), band.z_bottom.div_euclid(cs_z))
434}
435
436fn tile_key(
440 grid: &Grid,
441 band: DeckBand,
442 eye_lo: i32,
443 eye_hi: i32,
444 config_gen: u64,
445 tx: i32,
446 ty: i32,
447) -> u64 {
448 let (chz_lo, chz_hi) = band_chz_range(band, eye_lo, eye_hi);
449 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
450 fnv(&mut h, config_gen);
451 fnv(&mut h, eye_lo as u64);
452 fnv(&mut h, eye_hi as u64);
453 for chz in chz_lo..=chz_hi {
454 mix_chunk_sig(&mut h, grid, IVec3::new(tx, ty, chz));
455 }
456 h
457}
458
459fn local_edit_key(
465 grid: &Grid,
466 band: DeckBand,
467 eye_lo: i32,
468 eye_hi: i32,
469 config_gen: u64,
470 origin: IVec2,
471 radius: i32,
472) -> u64 {
473 let cs_xy = CHUNK_SIZE_XY as i32;
474 let cx_lo = (origin.x - radius).div_euclid(cs_xy);
475 let cx_hi = (origin.x + radius).div_euclid(cs_xy);
476 let cy_lo = (origin.y - radius).div_euclid(cs_xy);
477 let cy_hi = (origin.y + radius).div_euclid(cs_xy);
478 let (chz_lo, chz_hi) = band_chz_range(band, eye_lo, eye_hi);
479 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
480 fnv(&mut h, config_gen);
481 for chz in chz_lo..=chz_hi {
482 for cy in cy_lo..=cy_hi {
483 for cx in cx_lo..=cx_hi {
484 mix_chunk_sig(&mut h, grid, IVec3::new(cx, cy, chz));
485 }
486 }
487 }
488 h
489}
490
491struct HeardBlob {
495 deck: usize,
496 cells: Vec<(IVec2, f32)>,
497 ttl: f32,
498}
499
500pub struct FogOfWar {
506 config: VisionConfig,
507 config_gen: u64,
508 layers: Vec<DeckLayer>,
509 visible: HashMap<(i32, i32), f32>,
511 visible_deck: usize,
512 los_key: Option<(IVec2, i32, usize, i32, u64, u64)>,
513 heard: Vec<HeardBlob>,
514 heard_dirty: bool,
517 heard_cells: HashMap<(usize, i32, i32), f32>,
519 transitions: HashMap<(usize, i32, i32), f32>,
523 mask_version: u64,
524 sprite_epoch: u64,
531}
532
533impl FogOfWar {
534 #[must_use]
536 pub fn new(config: VisionConfig) -> Self {
537 let layers = config.decks.iter().map(|_| DeckLayer::default()).collect();
538 Self {
539 config,
540 config_gen: 0,
541 layers,
542 visible: HashMap::new(),
543 visible_deck: 0,
544 los_key: None,
545 heard: Vec::new(),
546 heard_dirty: false,
547 heard_cells: HashMap::new(),
548 transitions: HashMap::new(),
549 mask_version: 0,
550 sprite_epoch: 0,
551 }
552 }
553
554 #[must_use]
556 pub fn config(&self) -> &VisionConfig {
557 &self.config
558 }
559
560 pub fn set_config(&mut self, config: VisionConfig) {
567 assert_eq!(
568 config.decks.len(),
569 self.config.decks.len(),
570 "FW deck count is fixed per FogOfWar (layers are index-keyed)"
571 );
572 self.config = config;
573 self.config_gen += 1;
574 }
575
576 #[must_use]
579 pub fn mask_version(&self) -> u64 {
580 self.mask_version
581 }
582
583 #[must_use]
589 pub fn sprite_epoch(&self) -> u64 {
590 self.sprite_epoch
591 }
592
593 #[must_use]
595 pub fn visible_deck(&self) -> usize {
596 self.visible_deck
597 }
598
599 #[must_use]
602 pub fn state(&self, deck: usize, cell: IVec2) -> (CellState, u8) {
603 let Some(layer) = self.layers.get(deck) else {
604 return (CellState::Unseen, 0);
605 };
606 let b = layer.byte(cell);
607 (CellState::from_bits(b >> 6), b & INTENSITY_MAX)
608 }
609
610 pub fn hear(&mut self, deck: usize, cell: IVec2, loudness: f32) -> bool {
618 if deck >= self.config.decks.len() || loudness <= 0.0 {
619 return false;
620 }
621 let r = self.config.heard_radius * loudness;
622 let ri = r.ceil() as i32;
623 let taper = self.config.edge_taper.max(1.0);
624 let mut cells = Vec::new();
625 for dy in -ri..=ri {
626 for dx in -ri..=ri {
627 let d = f64::from(dx * dx + dy * dy).sqrt() as f32;
628 let t = ((r - d) / taper).clamp(0.0, 1.0);
629 if t > 0.0 {
630 cells.push((cell + IVec2::new(dx, dy), t * f32::from(INTENSITY_MAX)));
631 }
632 }
633 }
634 if cells.is_empty() {
635 return false;
636 }
637 self.heard.push(HeardBlob {
638 deck,
639 cells,
640 ttl: self.config.heard_duration,
641 });
642 self.heard_dirty = true;
643 true
644 }
645
646 fn resolve_world(
655 &self,
656 transform: &GridTransform,
657 footprint: (IVec2, IVec2),
658 world: DVec3,
659 ) -> Option<(usize, IVec2)> {
660 let glp = crate::addr::world_to_grid_local(world, transform);
661 let v = crate::addr::voxel_global(glp.chunk, glp.voxel);
662 let (lo, hi) = footprint;
663 if v.x < lo.x || v.x >= hi.x || v.y < lo.y || v.y >= hi.y {
664 return None; }
666 let deck = self
667 .config
668 .deck_for_z(v.z)
669 .unwrap_or_else(|| self.visible_deck());
670 Some((deck, IVec2::new(v.x, v.y)))
671 }
672
673 pub fn hear_world(
680 &mut self,
681 transform: &GridTransform,
682 footprint: (IVec2, IVec2),
683 world: DVec3,
684 loudness: f32,
685 ) -> bool {
686 match self.resolve_world(transform, footprint, world) {
687 Some((deck, cell)) => self.hear(deck, cell, loudness),
688 None => false,
689 }
690 }
691
692 #[must_use]
703 pub fn hides_sprite(
704 &self,
705 transform: &GridTransform,
706 footprint: (IVec2, IVec2),
707 world: DVec3,
708 ) -> bool {
709 self.resolve_world(transform, footprint, world)
710 .is_some_and(|(deck, cell)| self.state(deck, cell).0 != CellState::Visible)
711 }
712
713 pub fn for_each_live_cell(&self, mut f: impl FnMut(usize, IVec2, CellState)) {
720 for &(x, y) in self.visible.keys() {
721 f(self.visible_deck, IVec2::new(x, y), CellState::Visible);
722 }
723 for &(deck, x, y) in self.heard_cells.keys() {
724 if deck == self.visible_deck && self.visible.contains_key(&(x, y)) {
727 continue;
728 }
729 f(deck, IVec2::new(x, y), CellState::Heard);
730 }
731 }
732
733 pub fn for_each_known_cell(&self, mut f: impl FnMut(usize, IVec2, CellState)) {
742 for (deck, layer) in self.layers.iter().enumerate() {
743 for (&(tx, ty), tile) in &layer.mask {
744 for (idx, &b) in tile.bytes.iter().enumerate() {
745 let state = CellState::from_bits(b >> 6);
746 if state == CellState::Unseen {
747 continue;
748 }
749 let ix = idx as i32 % TILE;
750 let iy = idx as i32 / TILE;
751 f(deck, IVec2::new(tx * TILE + ix, ty * TILE + iy), state);
752 }
753 }
754 }
755 }
756
757 pub fn deck_tiles(&self, deck: usize) -> impl Iterator<Item = (IVec2, &[u8])> {
760 self.layers.get(deck).into_iter().flat_map(|l| {
761 l.mask
762 .iter()
763 .map(|(&(tx, ty), t)| (IVec2::new(tx, ty), &t.bytes[..]))
764 })
765 }
766
767 #[must_use]
780 pub fn gpu_mask(&self, origin_cell: IVec2, width: u32, height: u32) -> GpuFowMask {
781 let (w, h) = (width as usize, height as usize);
782 let deck_count = self.config.decks.len();
783 let mut cells = vec![0u8; deck_count * w * h];
784 let tile = TILE;
785 for (deck, layer) in self.layers.iter().enumerate() {
786 let base = deck * w * h;
787 for (&(tx, ty), t) in &layer.mask {
788 let tile_x0 = tx * tile - origin_cell.x;
790 let tile_y0 = ty * tile - origin_cell.y;
791 for iy in 0..tile {
792 let by = tile_y0 + iy;
793 if by < 0 || by >= height as i32 {
794 continue;
795 }
796 for ix in 0..tile {
797 let bx = tile_x0 + ix;
798 if bx < 0 || bx >= width as i32 {
799 continue;
800 }
801 let src = (iy * tile + ix) as usize;
802 let dst = base + (by as usize) * w + bx as usize;
803 cells[dst] = t.bytes[src];
804 }
805 }
806 }
807 }
808 GpuFowMask {
809 origin_cell: [origin_cell.x, origin_cell.y],
810 width,
811 height,
812 decks: self
813 .config
814 .decks
815 .iter()
816 .map(|d| [d.z_top, d.z_bottom])
817 .collect(),
818 active_deck: self.visible_deck,
819 cells,
820 memory_dim: self.config.memory_dim,
821 memory_desaturate: self.config.memory_desaturate,
822 version: self.mask_version,
823 }
824 }
825
826 pub fn update(&mut self, grid: &Grid, observer: &FowObserver, dt: f32) {
831 let mut changed = false;
832
833 if observer.deck != self.visible_deck && !self.visible.is_empty() {
835 let old = std::mem::take(&mut self.visible);
836 let deck = self.visible_deck;
837 for (cell, _) in old {
838 changed |= self.demote_to_memory(deck, IVec2::new(cell.0, cell.1));
839 }
840 self.los_key = None;
841 self.sprite_epoch = self.sprite_epoch.wrapping_add(1); }
843 self.visible_deck = observer.deck;
844
845 if observer.deck < self.config.decks.len() {
847 let facing_q = quantize_facing(observer.facing);
848 let band = self.config.decks[observer.deck];
849 let (eye_lo, eye_hi) = (observer.eye_z - EYE_HALF, observer.eye_z + EYE_HALF);
850 let radius = self.config.range.max(self.config.peripheral_range).ceil() as i32;
851 let edit_key = local_edit_key(
852 grid,
853 band,
854 eye_lo,
855 eye_hi,
856 self.config_gen,
857 observer.cell,
858 radius,
859 );
860 let key = (
861 observer.cell,
862 facing_q,
863 observer.deck,
864 observer.eye_z,
865 edit_key,
866 self.config_gen,
867 );
868 if self.los_key != Some(key) {
869 self.los_key = Some(key);
870 changed |= self.recompute_los(grid, observer);
871 }
872 } else if !self.visible.is_empty() {
873 let old = std::mem::take(&mut self.visible);
875 for (cell, _) in old {
876 changed |= self.demote_to_memory(
877 observer.deck.min(self.layers.len()),
878 IVec2::new(cell.0, cell.1),
879 );
880 }
881 self.sprite_epoch = self.sprite_epoch.wrapping_add(1); }
883
884 changed |= self.update_heard(dt);
885 changed |= self.tick_transitions(dt);
886
887 if changed {
888 self.mask_version += 1;
889 }
890 }
891
892 fn recompute_los(&mut self, grid: &Grid, observer: &FowObserver) -> bool {
896 let deck = observer.deck;
897 let band = self.config.decks[deck];
898 let mut new_visible: HashMap<(i32, i32), f32> = HashMap::new();
899 {
900 let layer = &mut self.layers[deck];
901 let mut scan = LosScan {
902 grid,
903 cfg: &self.config,
904 band,
905 eye_lo: observer.eye_z - EYE_HALF,
906 eye_hi: observer.eye_z + EYE_HALF,
907 config_gen: self.config_gen,
908 layer,
909 origin: observer.cell,
910 facing: normalize_facing(observer.facing),
911 out: &mut new_visible,
912 key_memo: None,
913 };
914 scan.run();
915 }
916
917 let mut changed = false;
918 let mut membership_changed = false;
923 let old = std::mem::take(&mut self.visible);
925 for &cell in old.keys() {
926 if !new_visible.contains_key(&cell) {
927 changed |= self.demote_to_memory(deck, IVec2::new(cell.0, cell.1));
928 membership_changed = true;
929 }
930 }
931 for &(x, y) in new_visible.keys() {
940 if !old.contains_key(&(x, y)) {
941 membership_changed = true;
942 }
943 let cell = IVec2::new(x, y);
944 changed |= self.layers[deck].write(cell, pack(CellState::Visible, INTENSITY_MAX));
945 self.transitions.remove(&(deck, x, y));
946 }
947 self.visible = new_visible;
948 if membership_changed {
949 self.sprite_epoch = self.sprite_epoch.wrapping_add(1);
950 }
951 changed
952 }
953
954 fn demote_to_memory(&mut self, deck: usize, cell: IVec2) -> bool {
957 let Some(layer) = self.layers.get_mut(deck) else {
958 return false;
959 };
960 let b = layer.byte(cell);
961 if CellState::from_bits(b >> 6) == CellState::Unseen {
962 return false;
963 }
964 let intensity = b & INTENSITY_MAX;
965 let changed = layer.write(cell, pack(CellState::Memory, intensity));
966 self.transitions
967 .entry((deck, cell.x, cell.y))
968 .or_insert_with(|| f32::from(intensity));
969 changed
970 }
971
972 fn update_heard(&mut self, dt: f32) -> bool {
977 let mut changed = false;
978 let before = self.heard.len();
979 for blob in &mut self.heard {
980 blob.ttl -= dt;
981 }
982 self.heard.retain(|b| b.ttl > 0.0);
983 let expired = self.heard.len() != before;
984
985 if self.heard_dirty || expired {
990 self.heard_dirty = false;
991 let mut now: HashMap<(usize, i32, i32), f32> = HashMap::new();
992 for blob in &self.heard {
993 for &(cell, target) in &blob.cells {
994 let e = now.entry((blob.deck, cell.x, cell.y)).or_insert(0.0);
995 *e = e.max(target);
996 }
997 }
998 let old = std::mem::take(&mut self.heard_cells);
1003 for &key in old.keys() {
1004 if now.contains_key(&key) {
1005 continue;
1006 }
1007 if key.0 == self.visible_deck && self.visible.contains_key(&(key.1, key.2)) {
1008 continue;
1009 }
1010 changed |= self.demote_to_memory(key.0, IVec2::new(key.1, key.2));
1011 }
1012 self.heard_cells = now;
1013 }
1014
1015 for (&(deck, x, y), &target) in &self.heard_cells {
1019 if deck == self.visible_deck && self.visible.contains_key(&(x, y)) {
1020 continue;
1021 }
1022 let cell = IVec2::new(x, y);
1023 let cur = self.layers[deck].byte(cell);
1024 let intensity = cur & INTENSITY_MAX;
1025 changed |= self.layers[deck].write(cell, pack(CellState::Heard, intensity));
1026 if (f32::from(intensity) - target).abs() > SETTLE_EPS {
1027 self.transitions
1028 .entry((deck, x, y))
1029 .or_insert_with(|| f32::from(intensity));
1030 }
1031 }
1032 changed
1033 }
1034
1035 fn tick_transitions(&mut self, dt: f32) -> bool {
1037 if self.transitions.is_empty() {
1038 return false;
1039 }
1040 let cfg = &self.config;
1041 let mut changed = false;
1042 let mut settled: Vec<(usize, i32, i32)> = Vec::new();
1043 let visible_deck = self.visible_deck;
1044 for (&key, cur) in &mut self.transitions {
1045 let (deck, x, y) = key;
1046 let cell = IVec2::new(x, y);
1047 let (target, rate) = if deck == visible_deck && self.visible.contains_key(&(x, y)) {
1049 let t = self.visible[&(x, y)];
1050 (t, if *cur < t { cfg.fade_in } else { cfg.fade_out })
1051 } else if let Some(&t) = self.heard_cells.get(&key) {
1052 (t, if *cur < t { cfg.fade_in } else { cfg.fade_out })
1053 } else {
1054 let sustain = f32::from(cfg.memory_intensity);
1057 if *cur > sustain {
1058 (sustain.max(f32::from(cfg.memory_floor)), cfg.fade_out)
1059 } else if cfg.memory_decay > 0.0 {
1060 (f32::from(cfg.memory_floor).min(*cur), cfg.memory_decay)
1061 } else {
1062 (*cur, f32::INFINITY)
1063 }
1064 };
1065
1066 let step = rate * dt;
1067 if (*cur - target).abs() <= step.max(SETTLE_EPS) {
1068 *cur = target;
1069 } else if *cur < target {
1070 *cur += step;
1071 } else {
1072 *cur -= step;
1073 }
1074
1075 let layer = &mut self.layers[deck];
1076 let b = layer.byte(cell);
1077 let state = CellState::from_bits(b >> 6);
1078 let rounded = (cur.round().clamp(0.0, f32::from(INTENSITY_MAX))) as u8;
1079 changed |= layer.write(cell, pack(state, rounded));
1080 if (*cur - target).abs() <= SETTLE_EPS {
1081 let memory_pending = state == CellState::Memory
1084 && cfg.memory_decay > 0.0
1085 && *cur > f32::from(cfg.memory_floor) + SETTLE_EPS;
1086 if !memory_pending {
1087 settled.push(key);
1088 }
1089 }
1090 }
1091 for key in settled {
1092 self.transitions.remove(&key);
1093 }
1094 changed
1095 }
1096}
1097
1098fn chunk_sig_pair(grid: &Grid, idx: IVec3) -> (u64, bool) {
1101 (grid.chunk_version(idx), grid.chunk(idx).is_some())
1102}
1103
1104type TwinCopy = (IVec3, Option<Vxl>, (u64, bool), bool);
1107
1108fn grid_chunk_bbox(grid: &Grid) -> Option<([i32; 3], [u32; 3])> {
1113 let mut it = grid.chunks.keys();
1114 let first = it.next()?;
1115 let (mut lo, mut hi) = (*first, *first);
1116 for k in it {
1117 lo = lo.min(*k);
1118 hi = hi.max(*k);
1119 }
1120 Some((
1121 [lo.x, lo.y, lo.z],
1122 [
1123 (hi.x - lo.x + 1) as u32,
1124 (hi.y - lo.y + 1) as u32,
1125 (hi.z - lo.z + 1) as u32,
1126 ],
1127 ))
1128}
1129
1130struct TwinMirror {
1136 transform: GridTransform,
1137 render_sky: bool,
1138 mip_levels_override: Option<u32>,
1139 lod_thresholds: LodThresholds,
1140 z_clip: Option<i32>,
1141}
1142
1143impl TwinMirror {
1144 fn read(g: &Grid) -> Self {
1145 Self {
1146 transform: g.transform,
1147 render_sky: g.render_sky,
1148 mip_levels_override: g.mip_levels_override,
1149 lod_thresholds: g.lod_thresholds,
1150 z_clip: g.z_clip,
1151 }
1152 }
1153
1154 fn apply(&self, g: &mut Grid) {
1155 g.transform = self.transform;
1156 g.render_sky = self.render_sky;
1157 g.mip_levels_override = self.mip_levels_override;
1158 let mut lod = self.lod_thresholds;
1167 lod.r_mid = f64::INFINITY;
1168 g.lod_thresholds = lod;
1169 g.z_clip = self.z_clip;
1170 }
1171}
1172
1173pub struct FowTwin {
1189 real: GridId,
1190 twin: GridId,
1191 copied: HashMap<IVec3, (u64, bool)>,
1194 last_synced: Option<(u64, u64)>,
1198}
1199
1200impl FowTwin {
1201 pub fn attach(scene: &mut Scene, real: GridId) -> Self {
1212 let (transform, hint) = {
1213 let g = scene
1214 .grid(real)
1215 .expect("FowTwin::attach: real grid must be registered");
1216 (g.transform, grid_chunk_bbox(g))
1217 };
1218 let twin = scene.add_grid(transform);
1219 scene
1220 .grid_mut(real)
1221 .expect("real grid just checked")
1222 .render_excluded = true;
1223 let t = scene.grid_mut(twin).expect("twin just added");
1224 t.presentation_only = true;
1225 t.gpu_residency_hint = hint;
1226 Self {
1227 real,
1228 twin,
1229 copied: HashMap::new(),
1230 last_synced: None,
1231 }
1232 }
1233
1234 #[must_use]
1236 pub fn real(&self) -> GridId {
1237 self.real
1238 }
1239
1240 #[must_use]
1242 pub fn twin(&self) -> GridId {
1243 self.twin
1244 }
1245
1246 pub fn detach(self, scene: &mut Scene) {
1250 if let Some(r) = scene.grid_mut(self.real) {
1251 r.render_excluded = false;
1252 }
1253 scene.remove_grid(self.twin);
1254 }
1255
1256 #[must_use]
1271 pub fn sync(&mut self, scene: &mut Scene, fow: &FogOfWar) -> bool {
1272 let Some(real_mut) = scene.grid(self.real).map(Grid::mutation_counter) else {
1275 return false;
1276 };
1277 if scene.grid(self.twin).is_none() {
1278 return false;
1279 }
1280
1281 let key = (fow.mask_version(), real_mut);
1285 if self.last_synced == Some(key) {
1286 return true;
1287 }
1288 let first_scan = self.last_synced.is_none();
1293 self.last_synced = Some(key);
1294
1295 let (mirror, hint, copies) = {
1299 let real = scene.grid(self.real).expect("checked above");
1300 let mirror = TwinMirror::read(real);
1301 let hint = grid_chunk_bbox(real);
1302 let mut want: HashMap<IVec3, (u64, bool)> = HashMap::new();
1304 let decks = &fow.config().decks;
1305 let mut collect = |deck: usize, cell: IVec2| {
1306 let Some(band) = decks.get(deck) else {
1307 return;
1308 };
1309 let (chz_lo, chz_hi) = deck_chz_range(*band);
1310 let chx = cell.x.div_euclid(TILE);
1311 let chy = cell.y.div_euclid(TILE);
1312 for chz in chz_lo..=chz_hi {
1313 let idx = IVec3::new(chx, chy, chz);
1314 want.entry(idx).or_insert_with(|| chunk_sig_pair(real, idx));
1315 }
1316 };
1317 if first_scan {
1318 fow.for_each_known_cell(|deck, cell, _| collect(deck, cell));
1319 } else {
1320 fow.for_each_live_cell(|deck, cell, _| collect(deck, cell));
1321 }
1322 let mut copies: Vec<TwinCopy> = Vec::new();
1323 for (idx, sig) in want {
1324 if self.copied.get(&idx) != Some(&sig) {
1325 let first_seen = !self.copied.contains_key(&idx);
1326 copies.push((idx, real.chunk(idx).cloned(), sig, first_seen));
1327 }
1328 }
1329 (mirror, hint, copies)
1330 };
1331
1332 let extents: Vec<Option<DirtyExtent>> = {
1338 let real = scene.grid_mut(self.real).expect("checked above");
1339 copies
1340 .iter()
1341 .map(|(idx, _, _, _)| real.take_chunk_dirty(*idx))
1342 .collect()
1343 };
1344
1345 let twin = scene.grid_mut(self.twin).expect("checked above");
1347 mirror.apply(twin);
1348 twin.gpu_residency_hint = hint;
1349 let mut any_change = false;
1350 for ((idx, chunk, sig, first_seen), extent) in copies.into_iter().zip(extents) {
1351 match chunk {
1352 Some(vxl) => {
1353 twin.chunks.insert(idx, vxl);
1354 match (first_seen, extent) {
1357 (false, Some(DirtyExtent::Bbox(lo, hi))) => {
1358 twin.bump_chunk_version_bbox(idx, lo, hi);
1359 }
1360 _ => twin.bump_chunk_version(idx),
1361 }
1362 self.copied.insert(idx, sig);
1363 any_change = true;
1364 }
1365 None => {
1366 if twin.chunks.contains_key(&idx) {
1374 self.copied.insert(idx, sig);
1375 }
1376 }
1377 }
1378 }
1379 if any_change {
1380 twin.billboards = None;
1384 }
1385 true
1386 }
1387}
1388
1389pub struct FowRender<'a> {
1403 fow: &'a FogOfWar,
1404}
1405
1406const OCCLUDE_BELOW: roxlap_core::dda::FowVerdict = roxlap_core::dda::FowVerdict::Show {
1411 dynamic: false,
1412 dim: 0.0,
1413 desaturate: 0.0,
1414};
1415
1416impl<'a> FowRender<'a> {
1417 #[must_use]
1419 pub fn new(fow: &'a FogOfWar) -> Self {
1420 Self { fow }
1421 }
1422}
1423
1424impl roxlap_core::dda::FowStyler for FowRender<'_> {
1425 fn verdict(&self, x: i32, y: i32, z: i32) -> roxlap_core::dda::FowVerdict {
1426 use roxlap_core::dda::FowVerdict;
1427 let cfg = self.fow.config();
1428 let active = self.fow.visible_deck();
1429 let below = |deck: usize| deck > active;
1435 let Some(deck) = cfg.deck_for_z(z) else {
1436 let active_floor = cfg.decks.get(active).map_or(i32::MAX, |b| b.z_bottom);
1440 return if z > active_floor {
1441 OCCLUDE_BELOW
1442 } else {
1443 FowVerdict::Hide
1444 };
1445 };
1446 let (state, intensity) = self.fow.state(deck, IVec2::new(x, y));
1447 let t = f32::from(intensity) / f32::from(INTENSITY_MAX);
1448 match state {
1449 CellState::Unseen if below(deck) => OCCLUDE_BELOW,
1458 CellState::Unseen => FowVerdict::Hide,
1459 CellState::Visible => FowVerdict::Show {
1464 dynamic: true,
1465 dim: 1.0,
1466 desaturate: 0.0,
1467 },
1468 CellState::Memory | CellState::Heard => FowVerdict::Show {
1469 dynamic: false,
1470 dim: cfg.memory_dim + (1.0 - cfg.memory_dim) * t,
1471 desaturate: cfg.memory_desaturate * (1.0 - t),
1472 },
1473 }
1474 }
1475}
1476
1477fn normalize_facing(f: Vec2) -> Option<Vec2> {
1478 let len = f.length();
1479 if len < 1e-6 {
1480 None
1481 } else {
1482 Some(f / len)
1483 }
1484}
1485
1486fn quantize_facing(f: Vec2) -> i32 {
1494 match normalize_facing(f) {
1495 None => i32::MIN,
1496 Some(n) => {
1497 let ang = f64::from(n.y).atan2(f64::from(n.x));
1498 let raw = (ang / std::f64::consts::TAU * 2048.0).round() as i32;
1499 raw.rem_euclid(2048)
1500 }
1501 }
1502}
1503
1504struct LosScan<'a> {
1509 grid: &'a Grid,
1510 cfg: &'a VisionConfig,
1511 band: DeckBand,
1512 eye_lo: i32,
1515 eye_hi: i32,
1516 config_gen: u64,
1517 layer: &'a mut DeckLayer,
1518 origin: IVec2,
1519 facing: Option<Vec2>,
1520 out: &'a mut HashMap<(i32, i32), f32>,
1521 key_memo: Option<((i32, i32), u64)>,
1525}
1526
1527const OCTANTS: [[i32; 4]; 8] = [
1529 [1, 0, 0, 1],
1530 [0, 1, 1, 0],
1531 [0, -1, 1, 0],
1532 [-1, 0, 0, 1],
1533 [-1, 0, 0, -1],
1534 [0, -1, -1, 0],
1535 [0, 1, -1, 0],
1536 [1, 0, 0, -1],
1537];
1538
1539impl LosScan<'_> {
1540 fn radius(&self) -> i32 {
1541 self.cfg.range.max(self.cfg.peripheral_range).ceil() as i32
1542 }
1543
1544 fn run(&mut self) {
1545 self.out
1548 .insert((self.origin.x, self.origin.y), f32::from(INTENSITY_MAX));
1549 for m in OCTANTS {
1550 self.cast(1, 1.0, 0.0, m);
1551 }
1552 }
1553
1554 fn blocked_at(&mut self, cell: IVec2) -> bool {
1557 self.sample(cell).0
1558 }
1559
1560 fn sample(&mut self, cell: IVec2) -> (bool, f32) {
1563 let ((tx, ty), idx) = split_cell(cell);
1564 let key = match self.key_memo {
1565 Some((t, k)) if t == (tx, ty) => k,
1566 _ => {
1567 let k = tile_key(
1568 self.grid,
1569 self.band,
1570 self.eye_lo,
1571 self.eye_hi,
1572 self.config_gen,
1573 tx,
1574 ty,
1575 );
1576 self.key_memo = Some(((tx, ty), k));
1577 k
1578 }
1579 };
1580 let tile = self
1581 .layer
1582 .opacity
1583 .entry((tx, ty))
1584 .or_insert_with(|| OpacityTile::new(key));
1585 if tile.key != key {
1586 *tile = OpacityTile::new(key);
1587 }
1588 let (w, bit) = (idx / 64, 1u64 << (idx % 64));
1589 if tile.computed[w] & bit == 0 {
1590 let (blocked, lit) = sample_cell(
1591 self.grid,
1592 self.band,
1593 self.eye_lo,
1594 self.eye_hi,
1595 self.cfg.light_gate.as_ref(),
1596 cell,
1597 );
1598 tile.computed[w] |= bit;
1599 if blocked {
1600 tile.blocked[w] |= bit;
1601 }
1602 tile.lit[idx] = (lit * 255.0).round() as u8;
1603 }
1604 (tile.blocked[w] & bit != 0, f32::from(tile.lit[idx]) / 255.0)
1605 }
1606
1607 fn visit(&mut self, cell: IVec2, dx: i32, dy: i32) {
1610 let d = f64::from(dx * dx + dy * dy).sqrt() as f32;
1611 let taper = self.cfg.edge_taper.max(1.0);
1612 let mut vis = 0.0f32;
1613 if d <= self.cfg.peripheral_range {
1614 vis = ((self.cfg.peripheral_range - d) / taper).clamp(0.0, 1.0);
1615 }
1616 if let Some(f) = self.facing {
1617 if d <= self.cfg.range && d > 0.0 {
1618 let dir = Vec2::new(dx as f32, dy as f32) / d;
1619 let ang = dir.dot(f).clamp(-1.0, 1.0).acos();
1620 if ang <= self.cfg.cone_half_angle {
1621 let ang_t = ((self.cfg.cone_half_angle - ang) / self.cfg.cone_taper.max(1e-3))
1622 .clamp(0.0, 1.0);
1623 let rad_t = ((self.cfg.range - d) / taper).clamp(0.0, 1.0);
1624 vis = vis.max(ang_t.min(rad_t));
1625 }
1626 }
1627 }
1628 if vis <= 0.0 {
1629 return;
1630 }
1631 let lit = self.sample(cell).1;
1632 vis *= lit;
1633 if vis <= 0.0 {
1634 return;
1635 }
1636 let target = f32::from(INTENSITY_MAX);
1645 let e = self.out.entry((cell.x, cell.y)).or_insert(0.0);
1646 *e = e.max(target);
1647 }
1648
1649 fn cast(&mut self, row: i32, mut start: f64, end: f64, m: [i32; 4]) {
1651 if start < end {
1652 return;
1653 }
1654 let radius = self.radius();
1655 let r2 = i64::from(radius) * i64::from(radius);
1656 let mut new_start = start;
1657 let mut blocked = false;
1658 let mut j = row;
1659 while j <= radius && !blocked {
1660 let dy = -j;
1661 for dx in -j..=0 {
1662 let l_slope = (f64::from(dx) - 0.5) / (f64::from(dy) + 0.5);
1663 let r_slope = (f64::from(dx) + 0.5) / (f64::from(dy) - 0.5);
1664 if start < r_slope {
1665 continue;
1666 }
1667 if end > l_slope {
1668 break;
1669 }
1670 let sx = dx * m[0] + dy * m[1];
1671 let sy = dx * m[2] + dy * m[3];
1672 let cell = self.origin + IVec2::new(sx, sy);
1673 if i64::from(dx) * i64::from(dx) + i64::from(dy) * i64::from(dy) <= r2 {
1674 self.visit(cell, sx, sy);
1675 }
1676 let blk = self.blocked_at(cell);
1677 if blocked {
1678 if blk {
1679 new_start = r_slope;
1680 } else {
1681 blocked = false;
1682 start = new_start;
1683 }
1684 } else if blk && j < radius {
1685 blocked = true;
1686 self.cast(j + 1, start, l_slope, m);
1687 new_start = r_slope;
1688 }
1689 }
1690 j += 1;
1691 }
1692 }
1693}
1694
1695fn sample_cell(
1711 grid: &Grid,
1712 band: DeckBand,
1713 eye_lo: i32,
1714 eye_hi: i32,
1715 gate: Option<&LightGate>,
1716 cell: IVec2,
1717) -> (bool, f32) {
1718 let cs_xy = CHUNK_SIZE_XY as i32;
1719 let cs_z = crate::CHUNK_SIZE_Z as i32;
1720 let chx = cell.x.div_euclid(cs_xy);
1721 let chy = cell.y.div_euclid(cs_xy);
1722 let ix = cell.x.rem_euclid(cs_xy) as u32;
1723 let iy = cell.y.rem_euclid(cs_xy) as u32;
1724
1725 let z_lo = band.z_top.min(eye_lo);
1726 let z_hi = band.z_bottom.max(eye_hi);
1727
1728 let mut blocked = false;
1729 let mut surface: Option<u8> = None;
1730 let mut lit_override = false;
1731
1732 let mut cur_chz = i32::MIN;
1733 let mut chunk: Option<&Vxl> = None;
1734 for z in z_lo..=z_hi {
1735 let chz = z.div_euclid(cs_z);
1736 if chz != cur_chz {
1737 cur_chz = chz;
1738 chunk = grid.chunk(IVec3::new(chx, chy, chz));
1739 }
1740 let Some(vxl) = chunk else {
1741 continue;
1742 };
1743 let iz = z.rem_euclid(cs_z) as u32;
1744 if !Grid::chunk_voxel_solid(vxl, UVec3::new(ix, iy, iz)) {
1745 continue;
1746 }
1747 if (eye_lo..=eye_hi).contains(&z) {
1748 blocked = true;
1749 }
1750 if let Some(g) = gate {
1751 let color = vxl.voxel_color(ix, iy, iz);
1752 if let Some(c) = color {
1753 if g.emissive_colors.contains(&c.rgb_part()) {
1754 lit_override = true;
1755 }
1756 }
1757 if surface.is_none() && z >= eye_lo {
1760 surface = Some(color.map_or(0x80, |c| (c.0 >> 24) as u8));
1761 }
1762 } else if blocked {
1763 return (true, 1.0);
1766 }
1767 }
1768
1769 let lit = match gate {
1770 None => 1.0,
1771 Some(_) if lit_override => 1.0,
1772 Some(g) => surface.map_or(0.0, |b| g.lit_factor(b)),
1773 };
1774 (blocked, lit)
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779 use super::*;
1780 use crate::GridTransform;
1781 use roxlap_formats::color::VoxColor;
1782
1783 const FLOOR_Z: i32 = 100;
1784 const EYE_Z: i32 = 94;
1787
1788 fn band() -> DeckBand {
1789 DeckBand {
1790 z_top: 80,
1791 z_bottom: FLOOR_Z,
1792 }
1793 }
1794
1795 fn open_room() -> Grid {
1797 let mut g = Grid::new(GridTransform::identity());
1798 g.set_rect(
1799 IVec3::new(-64, -64, FLOOR_Z),
1800 IVec3::new(63, 63, FLOOR_Z),
1801 Some(VoxColor::rgb(120, 120, 120)),
1802 );
1803 g
1804 }
1805
1806 fn cfg() -> VisionConfig {
1807 let mut c = VisionConfig::for_decks(vec![band()]);
1808 c.range = 40.0;
1809 c.peripheral_range = 12.0;
1810 c
1811 }
1812
1813 fn observer(cell: IVec2, facing: Vec2) -> FowObserver {
1814 FowObserver {
1815 cell,
1816 facing,
1817 deck: 0,
1818 eye_z: EYE_Z,
1819 }
1820 }
1821
1822 fn wall(g: &mut Grid, x: i32, y: i32) {
1824 g.set_rect(
1825 IVec3::new(x, y, 88),
1826 IVec3::new(x, y, FLOOR_Z),
1827 Some(VoxColor::rgb(200, 60, 60)),
1828 );
1829 }
1830
1831 const SETTLE: f32 = 1000.0;
1832
1833 #[test]
1834 fn cone_and_peripheral_classification() {
1835 let g = open_room();
1836 let mut fow = FogOfWar::new(cfg());
1837 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1838
1839 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
1841 assert_eq!(fow.state(0, IVec2::new(-6, 0)).0, CellState::Visible);
1843 assert_eq!(fow.state(0, IVec2::new(-25, 0)).0, CellState::Unseen);
1845 assert_eq!(fow.state(0, IVec2::new(60, 0)).0, CellState::Unseen);
1847 assert_eq!(fow.state(0, IVec2::new(20, 0)).1, INTENSITY_MAX);
1849 }
1850
1851 #[test]
1852 fn wall_blocks_line_of_sight() {
1853 let mut g = open_room();
1854 for y in -12..=12 {
1855 wall(&mut g, 15, y);
1856 }
1857 let mut fow = FogOfWar::new(cfg());
1858 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1859
1860 assert_eq!(fow.state(0, IVec2::new(15, 0)).0, CellState::Visible);
1862 assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Unseen);
1864 assert_eq!(fow.state(0, IVec2::new(10, 0)).0, CellState::Visible);
1866 }
1867
1868 #[test]
1873 fn known_cells_include_memory_live_cells_dont() {
1874 use std::collections::HashSet;
1875 let g = open_room();
1876 let mut fow = FogOfWar::new(cfg());
1877 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1879 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.01);
1880 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
1881
1882 let mut live = HashSet::new();
1883 fow.for_each_live_cell(|_d, c, _s| {
1884 live.insert((c.x, c.y));
1885 });
1886 assert!(!live.contains(&(20, 0)), "memory cell is not LIVE");
1887
1888 let mut known = HashSet::new();
1889 fow.for_each_known_cell(|_d, c, _s| {
1890 known.insert((c.x, c.y));
1891 });
1892 assert!(known.contains(&(20, 0)), "memory cell IS known");
1893 }
1894
1895 #[test]
1896 fn edit_invalidates_opacity_cache() {
1897 let mut g = open_room();
1898 let mut fow = FogOfWar::new(cfg());
1899 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1900 assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Visible);
1901
1902 for y in -12..=12 {
1906 wall(&mut g, 15, y);
1907 }
1908 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1909 assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Memory);
1910 assert_eq!(fow.state(0, IVec2::new(15, 0)).0, CellState::Visible);
1911 }
1912
1913 #[test]
1914 fn memory_decays_to_floor_and_reseeing_resets() {
1915 let g = open_room();
1916 let mut c = cfg();
1917 c.memory_decay = 8.0;
1918 let floor = c.memory_floor;
1919 let sustain = c.memory_intensity;
1920 let mut fow = FogOfWar::new(c);
1921 let cell = IVec2::new(20, 0);
1922 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1923 assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
1924
1925 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.05);
1927 assert_eq!(fow.state(0, cell).0, CellState::Memory);
1928 let after_fall = fow.state(0, cell).1;
1929 assert!(after_fall < INTENSITY_MAX);
1930
1931 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 1.0);
1934 let at_sustain = fow.state(0, cell).1;
1935 assert!(at_sustain <= sustain);
1936 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 30.0);
1937 assert_eq!(fow.state(0, cell).1, floor);
1938
1939 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1941 assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
1942 }
1943
1944 #[test]
1945 fn visible_snaps_full_then_memory_fades_gradually() {
1946 let g = open_room();
1951 let mut fow = FogOfWar::new(cfg());
1952 let cell = IVec2::new(20, 0);
1953 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.001);
1955 assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
1956 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.05);
1959 let (state, mid) = fow.state(0, cell);
1960 assert_eq!(state, CellState::Memory);
1961 assert!(mid > 0 && mid < INTENSITY_MAX, "mid fade-out, got {mid}");
1962 }
1963
1964 #[test]
1965 fn light_gate_caps_dark_cells() {
1966 let mut g = Grid::new(GridTransform::identity());
1970 g.set_rect(
1971 IVec3::new(-64, -64, FLOOR_Z),
1972 IVec3::new(9, 63, FLOOR_Z),
1973 Some(VoxColor::rgb(120, 120, 120)),
1974 );
1975 g.set_rect(
1976 IVec3::new(10, -64, FLOOR_Z),
1977 IVec3::new(63, 63, FLOOR_Z),
1978 Some(VoxColor::rgb(120, 120, 120).with_brightness(20)),
1979 );
1980 let emissive = VoxColor::rgb(80, 255, 160);
1982 g.set_voxel(
1983 IVec3::new(30, 5, FLOOR_Z - 1),
1984 Some(emissive.with_brightness(20)),
1985 );
1986
1987 let mut c = cfg();
1988 c.light_gate = Some(LightGate {
1989 lit_brightness: 100,
1990 softness: 0,
1991 emissive_colors: vec![emissive.rgb_part()],
1992 });
1993 let mut fow = FogOfWar::new(c);
1994 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
1995
1996 assert_eq!(fow.state(0, IVec2::new(5, 0)).0, CellState::Visible);
1998 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Unseen);
2000 assert_eq!(fow.state(0, IVec2::new(30, 5)).0, CellState::Visible);
2002 }
2003
2004 #[test]
2005 fn deck_layers_are_independent() {
2006 let mut g = open_room();
2007 g.set_rect(
2009 IVec3::new(-64, -64, 130),
2010 IVec3::new(63, 63, 130),
2011 Some(VoxColor::rgb(90, 90, 140)),
2012 );
2013 let lower = DeckBand {
2014 z_top: 110,
2015 z_bottom: 130,
2016 };
2017 let mut c = cfg();
2018 c.decks = vec![band(), lower];
2019 let mut fow = FogOfWar::new(c);
2020
2021 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2022 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
2023 assert_eq!(fow.state(1, IVec2::new(20, 0)).0, CellState::Unseen);
2024
2025 let mut obs = observer(IVec2::new(0, 0), Vec2::X);
2029 obs.deck = 1;
2030 obs.eye_z = 124;
2031 fow.update(&g, &obs, SETTLE);
2032 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
2033 assert_eq!(fow.state(1, IVec2::new(20, 0)).0, CellState::Visible);
2034 }
2035
2036 #[test]
2037 fn heard_blob_reveals_behind_wall_then_fades() {
2038 let mut g = open_room();
2039 for y in -12..=12 {
2040 wall(&mut g, 15, y);
2041 }
2042 let mut fow = FogOfWar::new(cfg());
2043 let src = IVec2::new(25, 0);
2044 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2045 assert_eq!(fow.state(0, src).0, CellState::Unseen);
2046
2047 fow.hear(0, src, 1.0);
2049 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.5);
2050 assert_eq!(fow.state(0, src).0, CellState::Heard);
2051 assert!(fow.state(0, IVec2::new(27, 2)).1 > 0);
2052
2053 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 2.0);
2055 assert_eq!(fow.state(0, src).0, CellState::Memory);
2056 }
2057
2058 #[test]
2059 fn mask_version_tracks_changes() {
2060 let g = open_room();
2061 let mut fow = FogOfWar::new(cfg());
2062 let v0 = fow.mask_version();
2063 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2064 let v1 = fow.mask_version();
2065 assert!(v1 > v0);
2066 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 1.0);
2068 assert_eq!(fow.mask_version(), v1);
2069 }
2070
2071 #[test]
2072 fn live_cells_cover_visible_and_heard() {
2073 let mut g = open_room();
2074 for y in -12..=12 {
2075 wall(&mut g, 15, y);
2076 }
2077 let mut fow = FogOfWar::new(cfg());
2078 fow.hear(0, IVec2::new(25, 0), 0.5);
2079 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.5);
2080 let mut visible = 0usize;
2081 let mut heard = 0usize;
2082 fow.for_each_live_cell(|deck, _, state| {
2083 assert_eq!(deck, 0);
2084 match state {
2085 CellState::Visible => visible += 1,
2086 CellState::Heard => heard += 1,
2087 _ => panic!("live cells are Visible or Heard"),
2088 }
2089 });
2090 assert!(visible > 0 && heard > 0);
2091 }
2092
2093 #[test]
2099 fn light_gate_reads_floor_not_ceiling() {
2100 let mut g = open_room(); g.set_rect(
2104 IVec3::new(10, -32, 85),
2105 IVec3::new(40, 32, 85),
2106 Some(VoxColor::rgb(120, 120, 120).with_brightness(15)),
2107 );
2108 let mut c = cfg();
2109 c.light_gate = Some(LightGate {
2110 lit_brightness: 100,
2111 softness: 0,
2112 emissive_colors: Vec::new(),
2113 });
2114 let mut fow = FogOfWar::new(c);
2115 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2116 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
2119 }
2120
2121 #[test]
2124 fn full_bake_bumps_chunk_version() {
2125 let g = open_room();
2126 let (chunk_idx, _) = crate::voxel_split(IVec3::new(0, 0, FLOOR_Z));
2127 let v0 = g.chunk_version(chunk_idx);
2128 let mut g = g;
2129 g.bake(crate::BakeMode::Directional);
2130 assert!(
2131 g.chunk_version(chunk_idx) > v0,
2132 "full bake must bump the version (was {v0})"
2133 );
2134 }
2135
2136 #[test]
2141 fn tile_key_changes_on_materialisation_at_version_zero() {
2142 let mut g = Grid::new(GridTransform::identity());
2143 let b = band();
2144 let (eye_lo, eye_hi) = (EYE_Z - EYE_HALF, EYE_Z + EYE_HALF);
2145 let k_absent = tile_key(&g, b, eye_lo, eye_hi, 0, 0, 0);
2146 let _ = g.ensure_chunk(IVec3::new(0, 0, 0));
2149 assert_eq!(g.chunk_version(IVec3::new(0, 0, 0)), 0);
2150 let k_present = tile_key(&g, b, eye_lo, eye_hi, 0, 0, 0);
2151 assert_ne!(
2152 k_absent, k_present,
2153 "presence must change the tile key at version 0"
2154 );
2155 }
2156
2157 #[test]
2161 fn ceiling_emissive_lights_without_blocking() {
2162 let mut g = open_room();
2163 let emissive = VoxColor::rgb(80, 255, 160);
2164 g.set_voxel(IVec3::new(20, 0, 85), Some(emissive.with_brightness(30)));
2166 let mut c = cfg();
2167 c.light_gate = Some(LightGate {
2168 lit_brightness: 100,
2169 softness: 0,
2170 emissive_colors: vec![emissive.rgb_part()],
2171 });
2172 let mut fow = FogOfWar::new(c);
2173 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2174 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
2177 assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Visible);
2178 }
2179
2180 #[test]
2183 fn facing_a_heard_source_stays_visible() {
2184 let g = open_room();
2185 let mut fow = FogOfWar::new(cfg());
2186 let src = IVec2::new(25, 0);
2187 fow.hear(0, src, 1.0);
2189 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.1);
2190 assert_eq!(fow.state(0, src).0, CellState::Heard);
2191 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.1);
2194 assert_eq!(fow.state(0, src).0, CellState::Visible);
2195 }
2196
2197 #[test]
2200 fn near_zero_facing_distinct_from_none() {
2201 let g = open_room();
2202 let mut fow = FogOfWar::new(cfg());
2203 let cell = IVec2::new(30, 0); fow.update(
2207 &g,
2208 &observer(IVec2::new(0, 0), Vec2::new(1.0, -0.003)),
2209 SETTLE,
2210 );
2211 assert_eq!(fow.state(0, cell).0, CellState::Visible);
2212 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::ZERO), SETTLE);
2215 assert_eq!(fow.state(0, cell).0, CellState::Memory);
2216 }
2217
2218 #[test]
2222 fn far_edit_does_not_bump_mask_version() {
2223 let mut g = open_room();
2224 let mut fow = FogOfWar::new(cfg());
2225 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2226 let v = fow.mask_version();
2227 g.set_voxel(IVec3::new(600, 600, FLOOR_Z), Some(VoxColor::rgb(1, 2, 3)));
2229 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.0);
2230 assert_eq!(fow.mask_version(), v, "far edit should not recompute LOS");
2231 }
2232
2233 use crate::Scene;
2236 use glam::DVec3;
2237
2238 fn scene_with_room() -> (Scene, GridId) {
2240 let mut scene = Scene::new();
2241 let id = scene.add_grid(GridTransform::identity());
2242 let g = scene.grid_mut(id).unwrap();
2243 g.set_rect(
2244 IVec3::new(-64, -64, FLOOR_Z),
2245 IVec3::new(63, 63, FLOOR_Z),
2246 Some(VoxColor::rgb(120, 120, 120)),
2247 );
2248 (scene, id)
2249 }
2250
2251 #[test]
2254 fn attach_splits_render_and_query_grids() {
2255 let (mut scene, real) = scene_with_room();
2256 let twin = FowTwin::attach(&mut scene, real);
2257 assert!(scene.grid(real).unwrap().render_excluded);
2258 assert!(scene.grid(twin.twin()).unwrap().presentation_only);
2259
2260 let rendered: Vec<GridId> = scene.render_grids().map(|(id, _)| id).collect();
2262 assert!(rendered.contains(&twin.twin()));
2263 assert!(!rendered.contains(&real));
2264
2265 let queried: Vec<GridId> = scene.query_grids().map(|(id, _)| id).collect();
2267 assert!(queried.contains(&real));
2268 assert!(!queried.contains(&twin.twin()));
2269 }
2270
2271 #[test]
2274 fn excluded_real_grid_still_raycasts() {
2275 let (mut scene, real) = scene_with_room();
2276 let _twin = FowTwin::attach(&mut scene, real);
2277 let hit = scene.raycast(
2278 DVec3::new(0.5, 0.5, FLOOR_Z as f64 - 5.0),
2279 DVec3::new(0.0, 0.0, 1.0),
2280 32.0,
2281 );
2282 assert!(hit.is_some(), "real grid must still answer raycasts");
2283 }
2284
2285 fn fow_deck() -> DeckBand {
2286 band()
2287 }
2288
2289 #[test]
2292 fn sync_copies_only_seen_chunks() {
2293 let (mut scene, real) = scene_with_room();
2294 let mut twin = FowTwin::attach(&mut scene, real);
2295 let mut fow = FogOfWar::new({
2296 let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2297 c.range = 40.0;
2298 c.peripheral_range = 12.0;
2299 c
2300 });
2301 let real_grid = scene.grid(real).unwrap();
2303 fow.update(real_grid, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2304 assert!(twin.sync(&mut scene, &fow));
2305
2306 let twin_grid = scene.grid(twin.twin()).unwrap();
2307 assert!(twin_grid.voxel_solid(IVec3::new(20, 0, FLOOR_Z)));
2309 assert!(twin_grid.chunk(IVec3::new(4, 4, 0)).is_none());
2311 }
2312
2313 #[test]
2316 fn edit_behind_wall_invisible_until_seen() {
2317 let (mut scene, real) = scene_with_room();
2318 scene.grid_mut(real).unwrap().set_rect(
2320 IVec3::new(128, -8, FLOOR_Z),
2321 IVec3::new(200, 8, FLOOR_Z),
2322 Some(VoxColor::rgb(120, 120, 120)),
2323 );
2324 let mut twin = FowTwin::attach(&mut scene, real);
2325 let mut fow = FogOfWar::new({
2326 let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2327 c.range = 40.0;
2328 c
2329 });
2330 fow.update(
2332 scene.grid(real).unwrap(),
2333 &observer(IVec2::new(0, 0), Vec2::X),
2334 SETTLE,
2335 );
2336 assert!(twin.sync(&mut scene, &fow));
2337 assert!(scene
2338 .grid(twin.twin())
2339 .unwrap()
2340 .chunk(IVec3::new(1, 0, 0))
2341 .is_none());
2342
2343 scene.grid_mut(real).unwrap().set_voxel(
2345 IVec3::new(150, 0, FLOOR_Z - 1),
2346 Some(VoxColor::rgb(255, 0, 0)),
2347 );
2348 fow.update(
2349 scene.grid(real).unwrap(),
2350 &observer(IVec2::new(0, 0), Vec2::X),
2351 SETTLE,
2352 );
2353 assert!(twin.sync(&mut scene, &fow));
2354 assert!(
2355 scene
2356 .grid(twin.twin())
2357 .unwrap()
2358 .chunk(IVec3::new(1, 0, 0))
2359 .is_none(),
2360 "unseen edit must not reach the twin"
2361 );
2362
2363 fow.update(
2365 scene.grid(real).unwrap(),
2366 &observer(IVec2::new(150, 0), Vec2::X),
2367 SETTLE,
2368 );
2369 assert!(twin.sync(&mut scene, &fow));
2370 let tg = scene.grid(twin.twin()).unwrap();
2371 assert!(
2372 tg.voxel_solid(IVec3::new(150, 0, FLOOR_Z - 1)),
2373 "walked-over edit now seen"
2374 );
2375 }
2376
2377 #[test]
2380 fn memory_chunk_frozen_after_observer_leaves() {
2381 let (mut scene, real) = scene_with_room();
2382 let mut twin = FowTwin::attach(&mut scene, real);
2383 let mut fow = FogOfWar::new({
2384 let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2385 c.range = 40.0;
2386 c.peripheral_range = 4.0;
2387 c
2388 });
2389 let cell = IVec3::new(20, 0, FLOOR_Z);
2390 fow.update(
2392 scene.grid(real).unwrap(),
2393 &observer(IVec2::new(0, 0), Vec2::X),
2394 SETTLE,
2395 );
2396 assert!(twin.sync(&mut scene, &fow));
2397 let seen_color = scene.grid(twin.twin()).unwrap().voxel_color(cell);
2398 assert!(seen_color.is_some());
2399
2400 fow.update(
2402 scene.grid(real).unwrap(),
2403 &observer(IVec2::new(0, 0), -Vec2::X),
2404 0.05,
2405 );
2406 scene
2407 .grid_mut(real)
2408 .unwrap()
2409 .set_voxel(cell, Some(VoxColor::rgb(9, 9, 9)));
2410 fow.update(
2411 scene.grid(real).unwrap(),
2412 &observer(IVec2::new(0, 0), -Vec2::X),
2413 0.05,
2414 );
2415 assert!(twin.sync(&mut scene, &fow));
2416
2417 assert_eq!(
2419 scene.grid(twin.twin()).unwrap().voxel_color(cell),
2420 seen_color,
2421 "memory geometry must stay frozen"
2422 );
2423 }
2424
2425 #[test]
2429 fn chunk_granular_sync_leaks_within_a_seen_chunk() {
2430 let (mut scene, real) = scene_with_room();
2431 let mut twin = FowTwin::attach(&mut scene, real);
2432 let mut fow = FogOfWar::new({
2433 let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2434 c.range = 20.0; c.peripheral_range = 4.0;
2436 c
2437 });
2438 fow.update(
2439 scene.grid(real).unwrap(),
2440 &observer(IVec2::new(0, 0), Vec2::X),
2441 SETTLE,
2442 );
2443 assert!(twin.sync(&mut scene, &fow));
2444
2445 let unseen = IVec3::new(60, 60, FLOOR_Z - 1);
2447 assert_eq!(fow.state(0, IVec2::new(60, 60)).0, CellState::Unseen);
2448 scene
2449 .grid_mut(real)
2450 .unwrap()
2451 .set_voxel(unseen, Some(VoxColor::rgb(255, 0, 0)));
2452 fow.update(
2454 scene.grid(real).unwrap(),
2455 &observer(IVec2::new(0, 0), Vec2::X),
2456 SETTLE,
2457 );
2458 assert!(twin.sync(&mut scene, &fow));
2459 assert!(
2460 scene.grid(twin.twin()).unwrap().voxel_solid(unseen),
2461 "v1 chunk-granular sync copies the whole chunk (accepted leak)"
2462 );
2463 }
2464
2465 #[test]
2467 fn detach_restores_real_and_removes_twin() {
2468 let (mut scene, real) = scene_with_room();
2469 let twin = FowTwin::attach(&mut scene, real);
2470 let twin_id = twin.twin();
2471 twin.detach(&mut scene);
2472 assert!(!scene.grid(real).unwrap().render_excluded);
2473 assert!(scene.grid(twin_id).is_none());
2474 }
2475
2476 #[test]
2479 fn twin_excluded_from_snapshot() {
2480 let (mut scene, real) = scene_with_room();
2481 let mut twin = FowTwin::attach(&mut scene, real);
2482 let mut fow = FogOfWar::new({
2483 let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2484 c.range = 40.0;
2485 c
2486 });
2487 fow.update(
2488 scene.grid(real).unwrap(),
2489 &observer(IVec2::new(0, 0), Vec2::X),
2490 SETTLE,
2491 );
2492 assert!(twin.sync(&mut scene, &fow));
2493 let snap = scene.to_snapshot();
2494 assert_eq!(snap.grids.len(), 1);
2496 assert_eq!(snap.grids[0].0, real);
2497 }
2498
2499 fn seeing_fow() -> FogOfWar {
2502 let mut c = VisionConfig::for_decks(vec![fow_deck()]);
2503 c.range = 40.0;
2504 c.peripheral_range = 12.0;
2505 FogOfWar::new(c)
2506 }
2507
2508 #[test]
2512 fn attach_sets_gpu_residency_hint_to_real_bbox() {
2513 let (mut scene, real) = scene_with_room();
2514 scene
2516 .grid_mut(real)
2517 .unwrap()
2518 .set_voxel(IVec3::new(10, 10, 260), Some(VoxColor::rgb(1, 2, 3)));
2519 let twin = FowTwin::attach(&mut scene, real);
2520 let hint = scene.grid(twin.twin()).unwrap().gpu_residency_hint;
2521 assert_eq!(hint, Some(([-1, -1, 0], [2, 2, 2])));
2525 }
2526
2527 #[test]
2530 fn real_eviction_keeps_twin_memory() {
2531 let (mut scene, real) = scene_with_room();
2532 let mut twin = FowTwin::attach(&mut scene, real);
2533 let mut fow = seeing_fow();
2534 fow.update(
2535 scene.grid(real).unwrap(),
2536 &observer(IVec2::new(0, 0), Vec2::X),
2537 SETTLE,
2538 );
2539 assert!(twin.sync(&mut scene, &fow));
2540 assert!(scene
2541 .grid(twin.twin())
2542 .unwrap()
2543 .voxel_solid(IVec3::new(20, 0, FLOOR_Z)));
2544
2545 {
2548 let g = scene.grid_mut(real).unwrap();
2549 g.chunks.remove(&IVec3::new(0, 0, 0));
2550 g.note_chunk_set_changed();
2551 }
2552 assert!(twin.sync(&mut scene, &fow));
2553 assert!(
2554 scene
2555 .grid(twin.twin())
2556 .unwrap()
2557 .voxel_solid(IVec3::new(20, 0, FLOOR_Z)),
2558 "twin must keep the last-seen copy after real eviction"
2559 );
2560 }
2561
2562 #[test]
2566 fn sync_signals_lost_twin() {
2567 let (mut scene, real) = scene_with_room();
2568 let mut twin = FowTwin::attach(&mut scene, real);
2569 let fow = seeing_fow();
2570 assert!(twin.sync(&mut scene, &fow));
2571 scene.remove_grid(twin.twin());
2573 assert!(!twin.sync(&mut scene, &fow), "lost twin must signal re-arm");
2574 }
2575
2576 #[test]
2579 fn quiet_frame_skips_resync() {
2580 let (mut scene, real) = scene_with_room();
2581 let mut twin = FowTwin::attach(&mut scene, real);
2582 let mut fow = seeing_fow();
2583 fow.update(
2584 scene.grid(real).unwrap(),
2585 &observer(IVec2::new(0, 0), Vec2::X),
2586 SETTLE,
2587 );
2588 assert!(twin.sync(&mut scene, &fow));
2589 let after_first = scene.grid(twin.twin()).unwrap().mutation_counter();
2590 fow.update(
2592 scene.grid(real).unwrap(),
2593 &observer(IVec2::new(0, 0), Vec2::X),
2594 1.0,
2595 );
2596 assert!(twin.sync(&mut scene, &fow));
2597 assert_eq!(
2598 scene.grid(twin.twin()).unwrap().mutation_counter(),
2599 after_first,
2600 "quiet frame must not re-bump the twin"
2601 );
2602 }
2603
2604 #[test]
2608 fn resync_bumps_twin_with_bbox_not_full() {
2609 let (mut scene, real) = scene_with_room();
2610 let mut twin = FowTwin::attach(&mut scene, real);
2611 let mut fow = seeing_fow();
2612 fow.update(
2613 scene.grid(real).unwrap(),
2614 &observer(IVec2::new(0, 0), Vec2::X),
2615 SETTLE,
2616 );
2617 assert!(twin.sync(&mut scene, &fow));
2618 let _ = scene
2620 .grid_mut(twin.twin())
2621 .unwrap()
2622 .take_chunk_dirty(IVec3::new(0, 0, 0));
2623
2624 scene.grid_mut(real).unwrap().set_voxel(
2626 IVec3::new(15, 3, FLOOR_Z - 1),
2627 Some(VoxColor::rgb(255, 0, 0)),
2628 );
2629 fow.update(
2630 scene.grid(real).unwrap(),
2631 &observer(IVec2::new(0, 0), Vec2::X),
2632 SETTLE,
2633 );
2634 assert!(twin.sync(&mut scene, &fow));
2635 let extent = scene
2636 .grid_mut(twin.twin())
2637 .unwrap()
2638 .take_chunk_dirty(IVec3::new(0, 0, 0));
2639 assert!(
2640 matches!(extent, Some(crate::DirtyExtent::Bbox(_, _))),
2641 "re-sync should bump a bbox extent, got {extent:?}"
2642 );
2643 }
2644
2645 #[test]
2647 fn exclusion_predicates() {
2648 let (mut scene, real) = scene_with_room();
2649 let twin = FowTwin::attach(&mut scene, real);
2650 assert!(!scene.grid(real).unwrap().renderable());
2651 assert!(scene.grid(real).unwrap().queryable());
2652 assert!(scene.grid(twin.twin()).unwrap().renderable());
2653 assert!(!scene.grid(twin.twin()).unwrap().queryable());
2654 }
2655
2656 #[test]
2659 fn fow_render_verdict_maps_states() {
2660 use roxlap_core::dda::{FowStyler, FowVerdict};
2661 let g = open_room();
2662 let mut fow = seeing_fow();
2663 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2664 let styler = FowRender::new(&fow);
2665 let zc = FLOOR_Z - 1; match styler.verdict(20, 0, zc) {
2669 FowVerdict::Show {
2670 dynamic,
2671 dim,
2672 desaturate,
2673 } => {
2674 assert!(dynamic);
2675 assert!((dim - 1.0).abs() < 1e-6, "cone-centre dim {dim}");
2676 assert!(desaturate.abs() < 1e-6);
2677 }
2678 FowVerdict::Hide => panic!("expected Show at the cone centre, got Hide"),
2679 }
2680 assert_eq!(styler.verdict(-30, 0, zc), FowVerdict::Hide);
2682 }
2683
2684 #[test]
2685 fn fow_render_memory_is_baked_and_dim() {
2686 use roxlap_core::dda::{FowStyler, FowVerdict};
2687 let g = open_room();
2688 let mut fow = seeing_fow();
2689 let zc = FLOOR_Z - 1;
2690 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2692 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.5);
2693 let styler = FowRender::new(&fow);
2694 match styler.verdict(20, 0, zc) {
2695 FowVerdict::Show {
2696 dynamic,
2697 dim,
2698 desaturate,
2699 } => {
2700 assert!(!dynamic, "memory must skip the dynamic rig");
2701 assert!(dim < 1.0, "memory must be dimmed, got {dim}");
2702 assert!(desaturate > 0.0, "memory desaturated");
2703 }
2704 FowVerdict::Hide => panic!("expected Show(memory), got Hide"),
2705 }
2706 }
2707
2708 #[test]
2712 fn hides_sprite_by_cell_state() {
2713 use glam::DVec3;
2714 let g = open_room();
2715 let mut fow = seeing_fow();
2716 let t = GridTransform::identity();
2717 let fp = (IVec2::new(-128, -128), IVec2::new(128, 128));
2718 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2719 let at = |x: f64, y: f64| DVec3::new(x, y, (FLOOR_Z - 1) as f64 + 0.5);
2720 assert!(!fow.hides_sprite(&t, fp, at(20.5, 0.5)));
2722 assert!(fow.hides_sprite(&t, fp, at(-30.5, 0.5)));
2724 assert!(!fow.hides_sprite(&t, fp, at(500.5, 0.5)));
2726 assert!(!fow.hides_sprite(&t, fp, DVec3::new(20.5, 0.5, 300.5)));
2729
2730 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.5);
2732 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
2733 assert!(
2734 fow.hides_sprite(&t, fp, at(20.5, 0.5)),
2735 "memory hides sprites"
2736 );
2737 }
2738
2739 #[test]
2743 fn sprite_epoch_tracks_visible_set_only() {
2744 let g = open_room();
2745 let mut fow = seeing_fow();
2746 let e0 = fow.sprite_epoch();
2748 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.01);
2749 let e1 = fow.sprite_epoch();
2750 assert!(e1 > e0, "seeing new cells bumps sprite_epoch");
2751 let mv = fow.mask_version();
2755 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.01);
2756 assert_eq!(fow.sprite_epoch(), e1, "a fade must not bump sprite_epoch");
2757 assert!(
2758 fow.mask_version() >= mv,
2759 "mask_version still tracks the fade"
2760 );
2761 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::Y), SETTLE);
2763 assert!(fow.sprite_epoch() > e1, "a turn bumps sprite_epoch");
2764 }
2765
2766 #[test]
2769 fn hear_world_maps_and_stamps() {
2770 use glam::DVec3;
2771 let g = open_room();
2772 let mut fow = seeing_fow();
2773 let t = GridTransform::identity();
2774 let fp = (IVec2::new(-128, -128), IVec2::new(128, 128));
2775 assert!(fow.hear_world(&t, fp, DVec3::new(20.5, 0.5, 99.5), 1.0));
2777 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.1);
2779 assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Heard);
2780 assert!(!fow.hear_world(&t, fp, DVec3::new(500.5, 500.5, 99.5), 1.0));
2782 }
2783
2784 #[test]
2788 fn gpu_mask_flattens_seen_cells() {
2789 let g = open_room();
2790 let mut fow = seeing_fow();
2791 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2792 let mask = fow.gpu_mask(IVec2::ZERO, 32, 32);
2794 assert_eq!(mask.decks.len(), 1);
2795 assert_eq!(mask.decks[0], [80, FLOOR_Z]); assert_eq!(mask.cells.len(), 32 * 32);
2797 assert_eq!(mask.cells[20] >> 6, 2, "seen cell is Visible");
2800 assert_eq!(mask.cells[20 * 32 + 5], 0, "unseen cell is 0");
2802 }
2803
2804 #[test]
2812 fn fow_render_classifies_by_own_deck() {
2813 use roxlap_core::dda::{FowStyler, FowVerdict};
2814 let g = open_room();
2815 let mut fow = seeing_fow();
2816 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2817 let styler = FowRender::new(&fow);
2818 assert!(matches!(
2821 styler.verdict(20, 0, 90),
2822 FowVerdict::Show { dynamic: true, .. }
2823 ));
2824 assert_eq!(
2827 styler.verdict(20, 0, 200),
2828 FowVerdict::Show {
2829 dynamic: false,
2830 dim: 0.0,
2831 desaturate: 0.0
2832 }
2833 );
2834 assert_eq!(styler.verdict(20, 0, 0), FowVerdict::Hide);
2836 assert_eq!(styler.verdict(5, 20, 90), FowVerdict::Hide);
2838 }
2839
2840 #[test]
2845 fn fow_render_below_occludes_above_transparent() {
2846 use roxlap_core::dda::{FowStyler, FowVerdict};
2847 let cfg = VisionConfig::for_decks(vec![
2848 DeckBand {
2849 z_top: 0,
2850 z_bottom: 20,
2851 },
2852 DeckBand {
2853 z_top: 30,
2854 z_bottom: 50,
2855 },
2856 ]);
2857 let mut fow = FogOfWar::new(cfg);
2858 assert_eq!(
2861 FowRender::new(&fow).verdict(0, 0, 40),
2862 FowVerdict::Show {
2863 dynamic: false,
2864 dim: 0.0,
2865 desaturate: 0.0
2866 }
2867 );
2868 assert_eq!(FowRender::new(&fow).verdict(0, 0, 10), FowVerdict::Hide);
2870
2871 let g = Grid::new(GridTransform::identity());
2874 let mut obs = observer(IVec2::new(0, 0), Vec2::X);
2875 obs.deck = 1;
2876 obs.eye_z = 40;
2877 fow.update(&g, &obs, SETTLE);
2878 assert_eq!(FowRender::new(&fow).verdict(9, 9, 10), FowVerdict::Hide);
2879 }
2880
2881 #[test]
2886 fn fow_render_memory_seam_no_pop() {
2887 use roxlap_core::dda::{FowStyler, FowVerdict};
2888 let g = open_room();
2889 let mut fow = seeing_fow();
2890 let zc = FLOOR_Z - 1;
2891 let cell = (20, 0);
2892 fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
2894 let dim_visible = match FowRender::new(&fow).verdict(cell.0, cell.1, zc) {
2895 FowVerdict::Show { dim, .. } => dim,
2896 FowVerdict::Hide => panic!("seen cell hidden"),
2897 };
2898 assert!((dim_visible - 1.0).abs() < 1e-6);
2899
2900 fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.01);
2904 assert_eq!(
2905 fow.state(0, IVec2::new(cell.0, cell.1)).0,
2906 CellState::Memory
2907 );
2908 let dim_memory = match FowRender::new(&fow).verdict(cell.0, cell.1, zc) {
2909 FowVerdict::Show { dim, dynamic, .. } => {
2910 assert!(!dynamic, "memory skips the rig");
2911 dim
2912 }
2913 FowVerdict::Hide => panic!("just-seen memory hidden"),
2914 };
2915 let memory_dim = fow.config().memory_dim;
2916 assert!(
2917 dim_memory > 0.5 * (1.0 + memory_dim),
2918 "seam pop: dim dropped to {dim_memory} (memory_dim {memory_dim})"
2919 );
2920 }
2921
2922 #[test]
2927 fn composed_render_fow_some_hides_unseen_shows_seen() {
2928 use crate::render::{render_scene_composed_frame, ComposedFrameParams, SceneRenderScratch};
2929 use roxlap_core::{Camera, OpticastSettings};
2930
2931 let (mut scene, real) = scene_with_room();
2933 let mut twin = FowTwin::attach(&mut scene, real);
2934 let mut seen = seeing_fow();
2935 seen.update(
2936 scene.grid(real).unwrap(),
2937 &observer(IVec2::new(0, 0), Vec2::X),
2938 SETTLE,
2939 );
2940 assert!(twin.sync(&mut scene, &seen));
2941 let twin_id = twin.twin();
2942
2943 let camera = Camera {
2945 pos: [18.0, 0.0, 60.0],
2946 right: [1.0, 0.0, 0.0],
2947 down: [0.0, 1.0, 0.0],
2948 forward: [0.0, 0.0, 1.0],
2949 };
2950 let (w, h) = (48u32, 48u32);
2951 let settings = OpticastSettings::for_oracle_framebuffer(w, h);
2952 let render = |fow: Option<(GridId, &FogOfWar)>, scene: &mut Scene| {
2953 let mut fb = vec![0u32; (w * h) as usize];
2954 let mut zb = vec![f32::INFINITY; (w * h) as usize];
2955 let mut scratch = SceneRenderScratch::default();
2956 let mut params = ComposedFrameParams::new(&camera, &settings);
2957 params.fow = fow;
2958 let _ = render_scene_composed_frame(
2959 &mut fb,
2960 &mut zb,
2961 w as usize,
2962 w,
2963 h,
2964 scene,
2965 ¶ms,
2966 &mut scratch,
2967 );
2968 fb
2969 };
2970 let non_sky = |fb: &[u32]| fb.iter().filter(|&&p| p != 0).count();
2971
2972 let base = render(None, &mut scene);
2974 assert!(non_sky(&base) > 0, "the twin floor must render without fog");
2975
2976 let shown = render(Some((twin_id, &seen)), &mut scene);
2978 assert!(non_sky(&shown) > 0, "seen cells must stay visible");
2979
2980 let blank = FogOfWar::new(seeing_fow().config().clone());
2982 let hidden = render(Some((twin_id, &blank)), &mut scene);
2983 assert_eq!(non_sky(&hidden), 0, "unseen cells must be hidden (sky)");
2984 }
2985}