use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
use glam::{DVec3, IVec2, IVec3, UVec3, Vec2};
use roxlap_formats::color::Rgb;
use roxlap_formats::vxl::Vxl;
use crate::{DirtyExtent, Grid, GridId, GridTransform, LodThresholds, Scene, CHUNK_SIZE_XY};
pub const INTENSITY_MAX: u8 = 63;
const TILE: i32 = CHUNK_SIZE_XY as i32;
const TILE_CELLS: usize = (TILE * TILE) as usize;
const TILE_WORDS: usize = TILE_CELLS / 64;
const SETTLE_EPS: f32 = 0.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum CellState {
Unseen = 0,
Memory = 1,
Visible = 2,
Heard = 3,
}
impl CellState {
fn from_bits(bits: u8) -> Self {
match bits & 3 {
1 => Self::Memory,
2 => Self::Visible,
3 => Self::Heard,
_ => Self::Unseen,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeckBand {
pub z_top: i32,
pub z_bottom: i32,
}
impl DeckBand {
#[must_use]
pub fn contains_z(&self, z: i32) -> bool {
(self.z_top..=self.z_bottom).contains(&z)
}
}
const EYE_HALF: i32 = 2;
#[derive(Debug, Clone, PartialEq)]
pub struct LightGate {
pub lit_brightness: u8,
pub softness: u8,
pub emissive_colors: Vec<Rgb>,
}
impl LightGate {
#[must_use]
pub fn lit_factor(&self, b: u8) -> f32 {
if b >= self.lit_brightness {
return 1.0;
}
if self.softness == 0 {
return 0.0;
}
let knee = self.lit_brightness.saturating_sub(self.softness);
if b <= knee {
return 0.0;
}
f32::from(b - knee) / f32::from(self.softness)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisionConfig {
pub cone_half_angle: f32,
pub cone_taper: f32,
pub range: f32,
pub peripheral_range: f32,
pub edge_taper: f32,
pub decks: Vec<DeckBand>,
pub light_gate: Option<LightGate>,
pub fade_in: f32,
pub fade_out: f32,
pub memory_intensity: u8,
pub memory_decay: f32,
pub memory_floor: u8,
pub heard_radius: f32,
pub heard_duration: f32,
pub memory_dim: f32,
pub memory_desaturate: f32,
}
impl VisionConfig {
#[must_use]
pub fn for_decks(decks: Vec<DeckBand>) -> Self {
Self {
cone_half_angle: 1.05,
cone_taper: 0.12,
range: 96.0,
peripheral_range: 12.0,
edge_taper: 4.0,
decks,
light_gate: None,
fade_in: 240.0,
fade_out: 120.0,
memory_intensity: 26,
memory_decay: 0.8,
memory_floor: 10,
heard_radius: 10.0,
heard_duration: 1.5,
memory_dim: 0.45,
memory_desaturate: 0.6,
}
}
#[must_use]
pub fn deck_for_z(&self, z: i32) -> Option<usize> {
self.decks.iter().position(|d| d.contains_z(z))
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GpuFowMask {
pub origin_cell: [i32; 2],
pub width: u32,
pub height: u32,
pub decks: Vec<[i32; 2]>,
pub active_deck: usize,
pub cells: Vec<u8>,
pub memory_dim: f32,
pub memory_desaturate: f32,
pub version: u64,
}
#[derive(Debug, Clone, Copy)]
pub struct FowObserver {
pub cell: IVec2,
pub facing: Vec2,
pub deck: usize,
pub eye_z: i32,
}
struct MaskTile {
bytes: Box<[u8; TILE_CELLS]>,
}
impl MaskTile {
fn new() -> Self {
Self {
bytes: vec![0u8; TILE_CELLS].into_boxed_slice().try_into().unwrap(),
}
}
}
struct OpacityTile {
key: u64,
computed: Box<[u64; TILE_WORDS]>,
blocked: Box<[u64; TILE_WORDS]>,
lit: Box<[u8; TILE_CELLS]>,
}
impl OpacityTile {
fn new(key: u64) -> Self {
Self {
key,
computed: vec![0u64; TILE_WORDS]
.into_boxed_slice()
.try_into()
.unwrap(),
blocked: vec![0u64; TILE_WORDS]
.into_boxed_slice()
.try_into()
.unwrap(),
lit: vec![0u8; TILE_CELLS].into_boxed_slice().try_into().unwrap(),
}
}
}
#[derive(Default)]
struct FxHasher(u64);
impl Hasher for FxHasher {
#[inline]
fn finish(&self) -> u64 {
self.0
}
#[inline]
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(u64::from(b));
}
}
#[inline]
fn write_i32(&mut self, i: i32) {
self.write_u64(u64::from(i as u32));
}
#[inline]
fn write_u64(&mut self, n: u64) {
const K: u64 = 0x517c_c1b7_2722_0a95;
self.0 = (self.0.rotate_left(5) ^ n).wrapping_mul(K);
}
}
type FastMap<K, V> = HashMap<K, V, BuildHasherDefault<FxHasher>>;
#[derive(Default)]
struct DeckLayer {
mask: FastMap<(i32, i32), MaskTile>,
opacity: FastMap<(i32, i32), OpacityTile>,
}
fn split_cell(cell: IVec2) -> ((i32, i32), usize) {
let tx = cell.x.div_euclid(TILE);
let ty = cell.y.div_euclid(TILE);
let ix = cell.x.rem_euclid(TILE);
let iy = cell.y.rem_euclid(TILE);
((tx, ty), (iy * TILE + ix) as usize)
}
impl DeckLayer {
fn byte(&self, cell: IVec2) -> u8 {
let (tile, idx) = split_cell(cell);
self.mask.get(&tile).map_or(0, |t| t.bytes[idx])
}
fn write(&mut self, cell: IVec2, val: u8) -> bool {
let (tile, idx) = split_cell(cell);
if val == 0 && !self.mask.contains_key(&tile) {
return false;
}
let t = self.mask.entry(tile).or_insert_with(MaskTile::new);
let changed = t.bytes[idx] != val;
t.bytes[idx] = val;
changed
}
}
fn pack(state: CellState, intensity: u8) -> u8 {
((state as u8) << 6) | intensity.min(INTENSITY_MAX)
}
#[inline]
fn fnv(h: &mut u64, v: u64) {
*h ^= v;
*h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
#[inline]
fn mix_chunk_sig(h: &mut u64, grid: &Grid, idx: IVec3) {
fnv(h, grid.chunk_version(idx));
fnv(h, u64::from(grid.chunk(idx).is_some()));
}
fn band_chz_range(band: DeckBand, eye_lo: i32, eye_hi: i32) -> (i32, i32) {
let cs_z = crate::CHUNK_SIZE_Z as i32;
(
band.z_top.min(eye_lo).div_euclid(cs_z),
band.z_bottom.max(eye_hi).div_euclid(cs_z),
)
}
fn deck_chz_range(band: DeckBand) -> (i32, i32) {
let cs_z = crate::CHUNK_SIZE_Z as i32;
(band.z_top.div_euclid(cs_z), band.z_bottom.div_euclid(cs_z))
}
fn tile_key(
grid: &Grid,
band: DeckBand,
eye_lo: i32,
eye_hi: i32,
config_gen: u64,
tx: i32,
ty: i32,
) -> u64 {
let (chz_lo, chz_hi) = band_chz_range(band, eye_lo, eye_hi);
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, config_gen);
fnv(&mut h, eye_lo as u64);
fnv(&mut h, eye_hi as u64);
for chz in chz_lo..=chz_hi {
mix_chunk_sig(&mut h, grid, IVec3::new(tx, ty, chz));
}
h
}
fn local_edit_key(
grid: &Grid,
band: DeckBand,
eye_lo: i32,
eye_hi: i32,
config_gen: u64,
origin: IVec2,
radius: i32,
) -> u64 {
let cs_xy = CHUNK_SIZE_XY as i32;
let cx_lo = (origin.x - radius).div_euclid(cs_xy);
let cx_hi = (origin.x + radius).div_euclid(cs_xy);
let cy_lo = (origin.y - radius).div_euclid(cs_xy);
let cy_hi = (origin.y + radius).div_euclid(cs_xy);
let (chz_lo, chz_hi) = band_chz_range(band, eye_lo, eye_hi);
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, config_gen);
for chz in chz_lo..=chz_hi {
for cy in cy_lo..=cy_hi {
for cx in cx_lo..=cx_hi {
mix_chunk_sig(&mut h, grid, IVec3::new(cx, cy, chz));
}
}
}
h
}
struct HeardBlob {
deck: usize,
cells: Vec<(IVec2, f32)>,
ttl: f32,
}
pub struct FogOfWar {
config: VisionConfig,
config_gen: u64,
layers: Vec<DeckLayer>,
visible: HashMap<(i32, i32), f32>,
visible_deck: usize,
los_key: Option<(IVec2, i32, usize, i32, u64, u64)>,
heard: Vec<HeardBlob>,
heard_dirty: bool,
heard_cells: HashMap<(usize, i32, i32), f32>,
transitions: HashMap<(usize, i32, i32), f32>,
mask_version: u64,
sprite_epoch: u64,
}
impl FogOfWar {
#[must_use]
pub fn new(config: VisionConfig) -> Self {
let layers = config.decks.iter().map(|_| DeckLayer::default()).collect();
Self {
config,
config_gen: 0,
layers,
visible: HashMap::new(),
visible_deck: 0,
los_key: None,
heard: Vec::new(),
heard_dirty: false,
heard_cells: HashMap::new(),
transitions: HashMap::new(),
mask_version: 0,
sprite_epoch: 0,
}
}
#[must_use]
pub fn config(&self) -> &VisionConfig {
&self.config
}
pub fn set_config(&mut self, config: VisionConfig) {
assert_eq!(
config.decks.len(),
self.config.decks.len(),
"FW deck count is fixed per FogOfWar (layers are index-keyed)"
);
self.config = config;
self.config_gen += 1;
}
#[must_use]
pub fn mask_version(&self) -> u64 {
self.mask_version
}
#[must_use]
pub fn sprite_epoch(&self) -> u64 {
self.sprite_epoch
}
#[must_use]
pub fn visible_deck(&self) -> usize {
self.visible_deck
}
#[must_use]
pub fn state(&self, deck: usize, cell: IVec2) -> (CellState, u8) {
let Some(layer) = self.layers.get(deck) else {
return (CellState::Unseen, 0);
};
let b = layer.byte(cell);
(CellState::from_bits(b >> 6), b & INTENSITY_MAX)
}
pub fn hear(&mut self, deck: usize, cell: IVec2, loudness: f32) -> bool {
if deck >= self.config.decks.len() || loudness <= 0.0 {
return false;
}
let r = self.config.heard_radius * loudness;
let ri = r.ceil() as i32;
let taper = self.config.edge_taper.max(1.0);
let mut cells = Vec::new();
for dy in -ri..=ri {
for dx in -ri..=ri {
let d = f64::from(dx * dx + dy * dy).sqrt() as f32;
let t = ((r - d) / taper).clamp(0.0, 1.0);
if t > 0.0 {
cells.push((cell + IVec2::new(dx, dy), t * f32::from(INTENSITY_MAX)));
}
}
}
if cells.is_empty() {
return false;
}
self.heard.push(HeardBlob {
deck,
cells,
ttl: self.config.heard_duration,
});
self.heard_dirty = true;
true
}
fn resolve_world(
&self,
transform: &GridTransform,
footprint: (IVec2, IVec2),
world: DVec3,
) -> Option<(usize, IVec2)> {
let glp = crate::addr::world_to_grid_local(world, transform);
let v = crate::addr::voxel_global(glp.chunk, glp.voxel);
let (lo, hi) = footprint;
if v.x < lo.x || v.x >= hi.x || v.y < lo.y || v.y >= hi.y {
return None; }
let deck = self
.config
.deck_for_z(v.z)
.unwrap_or_else(|| self.visible_deck());
Some((deck, IVec2::new(v.x, v.y)))
}
pub fn hear_world(
&mut self,
transform: &GridTransform,
footprint: (IVec2, IVec2),
world: DVec3,
loudness: f32,
) -> bool {
match self.resolve_world(transform, footprint, world) {
Some((deck, cell)) => self.hear(deck, cell, loudness),
None => false,
}
}
#[must_use]
pub fn hides_sprite(
&self,
transform: &GridTransform,
footprint: (IVec2, IVec2),
world: DVec3,
) -> bool {
self.resolve_world(transform, footprint, world)
.is_some_and(|(deck, cell)| self.state(deck, cell).0 != CellState::Visible)
}
pub fn for_each_live_cell(&self, mut f: impl FnMut(usize, IVec2, CellState)) {
for &(x, y) in self.visible.keys() {
f(self.visible_deck, IVec2::new(x, y), CellState::Visible);
}
for &(deck, x, y) in self.heard_cells.keys() {
if deck == self.visible_deck && self.visible.contains_key(&(x, y)) {
continue;
}
f(deck, IVec2::new(x, y), CellState::Heard);
}
}
pub fn for_each_known_cell(&self, mut f: impl FnMut(usize, IVec2, CellState)) {
for (deck, layer) in self.layers.iter().enumerate() {
for (&(tx, ty), tile) in &layer.mask {
for (idx, &b) in tile.bytes.iter().enumerate() {
let state = CellState::from_bits(b >> 6);
if state == CellState::Unseen {
continue;
}
let ix = idx as i32 % TILE;
let iy = idx as i32 / TILE;
f(deck, IVec2::new(tx * TILE + ix, ty * TILE + iy), state);
}
}
}
}
pub fn deck_tiles(&self, deck: usize) -> impl Iterator<Item = (IVec2, &[u8])> {
self.layers.get(deck).into_iter().flat_map(|l| {
l.mask
.iter()
.map(|(&(tx, ty), t)| (IVec2::new(tx, ty), &t.bytes[..]))
})
}
#[must_use]
pub fn gpu_mask(&self, origin_cell: IVec2, width: u32, height: u32) -> GpuFowMask {
let (w, h) = (width as usize, height as usize);
let deck_count = self.config.decks.len();
let mut cells = vec![0u8; deck_count * w * h];
let tile = TILE;
for (deck, layer) in self.layers.iter().enumerate() {
let base = deck * w * h;
for (&(tx, ty), t) in &layer.mask {
let tile_x0 = tx * tile - origin_cell.x;
let tile_y0 = ty * tile - origin_cell.y;
for iy in 0..tile {
let by = tile_y0 + iy;
if by < 0 || by >= height as i32 {
continue;
}
for ix in 0..tile {
let bx = tile_x0 + ix;
if bx < 0 || bx >= width as i32 {
continue;
}
let src = (iy * tile + ix) as usize;
let dst = base + (by as usize) * w + bx as usize;
cells[dst] = t.bytes[src];
}
}
}
}
GpuFowMask {
origin_cell: [origin_cell.x, origin_cell.y],
width,
height,
decks: self
.config
.decks
.iter()
.map(|d| [d.z_top, d.z_bottom])
.collect(),
active_deck: self.visible_deck,
cells,
memory_dim: self.config.memory_dim,
memory_desaturate: self.config.memory_desaturate,
version: self.mask_version,
}
}
pub fn update(&mut self, grid: &Grid, observer: &FowObserver, dt: f32) {
let mut changed = false;
if observer.deck != self.visible_deck && !self.visible.is_empty() {
let old = std::mem::take(&mut self.visible);
let deck = self.visible_deck;
for (cell, _) in old {
changed |= self.demote_to_memory(deck, IVec2::new(cell.0, cell.1));
}
self.los_key = None;
self.sprite_epoch = self.sprite_epoch.wrapping_add(1); }
self.visible_deck = observer.deck;
if observer.deck < self.config.decks.len() {
let facing_q = quantize_facing(observer.facing);
let band = self.config.decks[observer.deck];
let (eye_lo, eye_hi) = (observer.eye_z - EYE_HALF, observer.eye_z + EYE_HALF);
let radius = self.config.range.max(self.config.peripheral_range).ceil() as i32;
let edit_key = local_edit_key(
grid,
band,
eye_lo,
eye_hi,
self.config_gen,
observer.cell,
radius,
);
let key = (
observer.cell,
facing_q,
observer.deck,
observer.eye_z,
edit_key,
self.config_gen,
);
if self.los_key != Some(key) {
self.los_key = Some(key);
changed |= self.recompute_los(grid, observer);
}
} else if !self.visible.is_empty() {
let old = std::mem::take(&mut self.visible);
for (cell, _) in old {
changed |= self.demote_to_memory(
observer.deck.min(self.layers.len()),
IVec2::new(cell.0, cell.1),
);
}
self.sprite_epoch = self.sprite_epoch.wrapping_add(1); }
changed |= self.update_heard(dt);
changed |= self.tick_transitions(dt);
if changed {
self.mask_version += 1;
}
}
fn recompute_los(&mut self, grid: &Grid, observer: &FowObserver) -> bool {
let deck = observer.deck;
let band = self.config.decks[deck];
let mut new_visible: HashMap<(i32, i32), f32> = HashMap::new();
{
let layer = &mut self.layers[deck];
let mut scan = LosScan {
grid,
cfg: &self.config,
band,
eye_lo: observer.eye_z - EYE_HALF,
eye_hi: observer.eye_z + EYE_HALF,
config_gen: self.config_gen,
layer,
origin: observer.cell,
facing: normalize_facing(observer.facing),
out: &mut new_visible,
key_memo: None,
};
scan.run();
}
let mut changed = false;
let mut membership_changed = false;
let old = std::mem::take(&mut self.visible);
for &cell in old.keys() {
if !new_visible.contains_key(&cell) {
changed |= self.demote_to_memory(deck, IVec2::new(cell.0, cell.1));
membership_changed = true;
}
}
for &(x, y) in new_visible.keys() {
if !old.contains_key(&(x, y)) {
membership_changed = true;
}
let cell = IVec2::new(x, y);
changed |= self.layers[deck].write(cell, pack(CellState::Visible, INTENSITY_MAX));
self.transitions.remove(&(deck, x, y));
}
self.visible = new_visible;
if membership_changed {
self.sprite_epoch = self.sprite_epoch.wrapping_add(1);
}
changed
}
fn demote_to_memory(&mut self, deck: usize, cell: IVec2) -> bool {
let Some(layer) = self.layers.get_mut(deck) else {
return false;
};
let b = layer.byte(cell);
if CellState::from_bits(b >> 6) == CellState::Unseen {
return false;
}
let intensity = b & INTENSITY_MAX;
let changed = layer.write(cell, pack(CellState::Memory, intensity));
self.transitions
.entry((deck, cell.x, cell.y))
.or_insert_with(|| f32::from(intensity));
changed
}
fn update_heard(&mut self, dt: f32) -> bool {
let mut changed = false;
let before = self.heard.len();
for blob in &mut self.heard {
blob.ttl -= dt;
}
self.heard.retain(|b| b.ttl > 0.0);
let expired = self.heard.len() != before;
if self.heard_dirty || expired {
self.heard_dirty = false;
let mut now: HashMap<(usize, i32, i32), f32> = HashMap::new();
for blob in &self.heard {
for &(cell, target) in &blob.cells {
let e = now.entry((blob.deck, cell.x, cell.y)).or_insert(0.0);
*e = e.max(target);
}
}
let old = std::mem::take(&mut self.heard_cells);
for &key in old.keys() {
if now.contains_key(&key) {
continue;
}
if key.0 == self.visible_deck && self.visible.contains_key(&(key.1, key.2)) {
continue;
}
changed |= self.demote_to_memory(key.0, IVec2::new(key.1, key.2));
}
self.heard_cells = now;
}
for (&(deck, x, y), &target) in &self.heard_cells {
if deck == self.visible_deck && self.visible.contains_key(&(x, y)) {
continue;
}
let cell = IVec2::new(x, y);
let cur = self.layers[deck].byte(cell);
let intensity = cur & INTENSITY_MAX;
changed |= self.layers[deck].write(cell, pack(CellState::Heard, intensity));
if (f32::from(intensity) - target).abs() > SETTLE_EPS {
self.transitions
.entry((deck, x, y))
.or_insert_with(|| f32::from(intensity));
}
}
changed
}
fn tick_transitions(&mut self, dt: f32) -> bool {
if self.transitions.is_empty() {
return false;
}
let cfg = &self.config;
let mut changed = false;
let mut settled: Vec<(usize, i32, i32)> = Vec::new();
let visible_deck = self.visible_deck;
for (&key, cur) in &mut self.transitions {
let (deck, x, y) = key;
let cell = IVec2::new(x, y);
let (target, rate) = if deck == visible_deck && self.visible.contains_key(&(x, y)) {
let t = self.visible[&(x, y)];
(t, if *cur < t { cfg.fade_in } else { cfg.fade_out })
} else if let Some(&t) = self.heard_cells.get(&key) {
(t, if *cur < t { cfg.fade_in } else { cfg.fade_out })
} else {
let sustain = f32::from(cfg.memory_intensity);
if *cur > sustain {
(sustain.max(f32::from(cfg.memory_floor)), cfg.fade_out)
} else if cfg.memory_decay > 0.0 {
(f32::from(cfg.memory_floor).min(*cur), cfg.memory_decay)
} else {
(*cur, f32::INFINITY)
}
};
let step = rate * dt;
if (*cur - target).abs() <= step.max(SETTLE_EPS) {
*cur = target;
} else if *cur < target {
*cur += step;
} else {
*cur -= step;
}
let layer = &mut self.layers[deck];
let b = layer.byte(cell);
let state = CellState::from_bits(b >> 6);
let rounded = (cur.round().clamp(0.0, f32::from(INTENSITY_MAX))) as u8;
changed |= layer.write(cell, pack(state, rounded));
if (*cur - target).abs() <= SETTLE_EPS {
let memory_pending = state == CellState::Memory
&& cfg.memory_decay > 0.0
&& *cur > f32::from(cfg.memory_floor) + SETTLE_EPS;
if !memory_pending {
settled.push(key);
}
}
}
for key in settled {
self.transitions.remove(&key);
}
changed
}
}
fn chunk_sig_pair(grid: &Grid, idx: IVec3) -> (u64, bool) {
(grid.chunk_version(idx), grid.chunk(idx).is_some())
}
type TwinCopy = (IVec3, Option<Vxl>, (u64, bool), bool);
fn grid_chunk_bbox(grid: &Grid) -> Option<([i32; 3], [u32; 3])> {
let mut it = grid.chunks.keys();
let first = it.next()?;
let (mut lo, mut hi) = (*first, *first);
for k in it {
lo = lo.min(*k);
hi = hi.max(*k);
}
Some((
[lo.x, lo.y, lo.z],
[
(hi.x - lo.x + 1) as u32,
(hi.y - lo.y + 1) as u32,
(hi.z - lo.z + 1) as u32,
],
))
}
struct TwinMirror {
transform: GridTransform,
render_sky: bool,
mip_levels_override: Option<u32>,
lod_thresholds: LodThresholds,
z_clip: Option<i32>,
}
impl TwinMirror {
fn read(g: &Grid) -> Self {
Self {
transform: g.transform,
render_sky: g.render_sky,
mip_levels_override: g.mip_levels_override,
lod_thresholds: g.lod_thresholds,
z_clip: g.z_clip,
}
}
fn apply(&self, g: &mut Grid) {
g.transform = self.transform;
g.render_sky = self.render_sky;
g.mip_levels_override = self.mip_levels_override;
let mut lod = self.lod_thresholds;
lod.r_mid = f64::INFINITY;
g.lod_thresholds = lod;
g.z_clip = self.z_clip;
}
}
pub struct FowTwin {
real: GridId,
twin: GridId,
copied: HashMap<IVec3, (u64, bool)>,
last_synced: Option<(u64, u64)>,
}
impl FowTwin {
pub fn attach(scene: &mut Scene, real: GridId) -> Self {
let (transform, hint) = {
let g = scene
.grid(real)
.expect("FowTwin::attach: real grid must be registered");
(g.transform, grid_chunk_bbox(g))
};
let twin = scene.add_grid(transform);
scene
.grid_mut(real)
.expect("real grid just checked")
.render_excluded = true;
let t = scene.grid_mut(twin).expect("twin just added");
t.presentation_only = true;
t.gpu_residency_hint = hint;
Self {
real,
twin,
copied: HashMap::new(),
last_synced: None,
}
}
#[must_use]
pub fn real(&self) -> GridId {
self.real
}
#[must_use]
pub fn twin(&self) -> GridId {
self.twin
}
pub fn detach(self, scene: &mut Scene) {
if let Some(r) = scene.grid_mut(self.real) {
r.render_excluded = false;
}
scene.remove_grid(self.twin);
}
#[must_use]
pub fn sync(&mut self, scene: &mut Scene, fow: &FogOfWar) -> bool {
let Some(real_mut) = scene.grid(self.real).map(Grid::mutation_counter) else {
return false;
};
if scene.grid(self.twin).is_none() {
return false;
}
let key = (fow.mask_version(), real_mut);
if self.last_synced == Some(key) {
return true;
}
let first_scan = self.last_synced.is_none();
self.last_synced = Some(key);
let (mirror, hint, copies) = {
let real = scene.grid(self.real).expect("checked above");
let mirror = TwinMirror::read(real);
let hint = grid_chunk_bbox(real);
let mut want: HashMap<IVec3, (u64, bool)> = HashMap::new();
let decks = &fow.config().decks;
let mut collect = |deck: usize, cell: IVec2| {
let Some(band) = decks.get(deck) else {
return;
};
let (chz_lo, chz_hi) = deck_chz_range(*band);
let chx = cell.x.div_euclid(TILE);
let chy = cell.y.div_euclid(TILE);
for chz in chz_lo..=chz_hi {
let idx = IVec3::new(chx, chy, chz);
want.entry(idx).or_insert_with(|| chunk_sig_pair(real, idx));
}
};
if first_scan {
fow.for_each_known_cell(|deck, cell, _| collect(deck, cell));
} else {
fow.for_each_live_cell(|deck, cell, _| collect(deck, cell));
}
let mut copies: Vec<TwinCopy> = Vec::new();
for (idx, sig) in want {
if self.copied.get(&idx) != Some(&sig) {
let first_seen = !self.copied.contains_key(&idx);
copies.push((idx, real.chunk(idx).cloned(), sig, first_seen));
}
}
(mirror, hint, copies)
};
let extents: Vec<Option<DirtyExtent>> = {
let real = scene.grid_mut(self.real).expect("checked above");
copies
.iter()
.map(|(idx, _, _, _)| real.take_chunk_dirty(*idx))
.collect()
};
let twin = scene.grid_mut(self.twin).expect("checked above");
mirror.apply(twin);
twin.gpu_residency_hint = hint;
let mut any_change = false;
for ((idx, chunk, sig, first_seen), extent) in copies.into_iter().zip(extents) {
match chunk {
Some(vxl) => {
twin.chunks.insert(idx, vxl);
match (first_seen, extent) {
(false, Some(DirtyExtent::Bbox(lo, hi))) => {
twin.bump_chunk_version_bbox(idx, lo, hi);
}
_ => twin.bump_chunk_version(idx),
}
self.copied.insert(idx, sig);
any_change = true;
}
None => {
if twin.chunks.contains_key(&idx) {
self.copied.insert(idx, sig);
}
}
}
}
if any_change {
twin.billboards = None;
}
true
}
}
pub struct FowRender<'a> {
fow: &'a FogOfWar,
}
const OCCLUDE_BELOW: roxlap_core::dda::FowVerdict = roxlap_core::dda::FowVerdict::Show {
dynamic: false,
dim: 0.0,
desaturate: 0.0,
};
impl<'a> FowRender<'a> {
#[must_use]
pub fn new(fow: &'a FogOfWar) -> Self {
Self { fow }
}
}
impl roxlap_core::dda::FowStyler for FowRender<'_> {
fn verdict(&self, x: i32, y: i32, z: i32) -> roxlap_core::dda::FowVerdict {
use roxlap_core::dda::FowVerdict;
let cfg = self.fow.config();
let active = self.fow.visible_deck();
let below = |deck: usize| deck > active;
let Some(deck) = cfg.deck_for_z(z) else {
let active_floor = cfg.decks.get(active).map_or(i32::MAX, |b| b.z_bottom);
return if z > active_floor {
OCCLUDE_BELOW
} else {
FowVerdict::Hide
};
};
let (state, intensity) = self.fow.state(deck, IVec2::new(x, y));
let t = f32::from(intensity) / f32::from(INTENSITY_MAX);
match state {
CellState::Unseen if below(deck) => OCCLUDE_BELOW,
CellState::Unseen => FowVerdict::Hide,
CellState::Visible => FowVerdict::Show {
dynamic: true,
dim: 1.0,
desaturate: 0.0,
},
CellState::Memory | CellState::Heard => FowVerdict::Show {
dynamic: false,
dim: cfg.memory_dim + (1.0 - cfg.memory_dim) * t,
desaturate: cfg.memory_desaturate * (1.0 - t),
},
}
}
}
fn normalize_facing(f: Vec2) -> Option<Vec2> {
let len = f.length();
if len < 1e-6 {
None
} else {
Some(f / len)
}
}
fn quantize_facing(f: Vec2) -> i32 {
match normalize_facing(f) {
None => i32::MIN,
Some(n) => {
let ang = f64::from(n.y).atan2(f64::from(n.x));
let raw = (ang / std::f64::consts::TAU * 2048.0).round() as i32;
raw.rem_euclid(2048)
}
}
}
struct LosScan<'a> {
grid: &'a Grid,
cfg: &'a VisionConfig,
band: DeckBand,
eye_lo: i32,
eye_hi: i32,
config_gen: u64,
layer: &'a mut DeckLayer,
origin: IVec2,
facing: Option<Vec2>,
out: &'a mut HashMap<(i32, i32), f32>,
key_memo: Option<((i32, i32), u64)>,
}
const OCTANTS: [[i32; 4]; 8] = [
[1, 0, 0, 1],
[0, 1, 1, 0],
[0, -1, 1, 0],
[-1, 0, 0, 1],
[-1, 0, 0, -1],
[0, -1, -1, 0],
[0, 1, -1, 0],
[1, 0, 0, -1],
];
impl LosScan<'_> {
fn radius(&self) -> i32 {
self.cfg.range.max(self.cfg.peripheral_range).ceil() as i32
}
fn run(&mut self) {
self.out
.insert((self.origin.x, self.origin.y), f32::from(INTENSITY_MAX));
for m in OCTANTS {
self.cast(1, 1.0, 0.0, m);
}
}
fn blocked_at(&mut self, cell: IVec2) -> bool {
self.sample(cell).0
}
fn sample(&mut self, cell: IVec2) -> (bool, f32) {
let ((tx, ty), idx) = split_cell(cell);
let key = match self.key_memo {
Some((t, k)) if t == (tx, ty) => k,
_ => {
let k = tile_key(
self.grid,
self.band,
self.eye_lo,
self.eye_hi,
self.config_gen,
tx,
ty,
);
self.key_memo = Some(((tx, ty), k));
k
}
};
let tile = self
.layer
.opacity
.entry((tx, ty))
.or_insert_with(|| OpacityTile::new(key));
if tile.key != key {
*tile = OpacityTile::new(key);
}
let (w, bit) = (idx / 64, 1u64 << (idx % 64));
if tile.computed[w] & bit == 0 {
let (blocked, lit) = sample_cell(
self.grid,
self.band,
self.eye_lo,
self.eye_hi,
self.cfg.light_gate.as_ref(),
cell,
);
tile.computed[w] |= bit;
if blocked {
tile.blocked[w] |= bit;
}
tile.lit[idx] = (lit * 255.0).round() as u8;
}
(tile.blocked[w] & bit != 0, f32::from(tile.lit[idx]) / 255.0)
}
fn visit(&mut self, cell: IVec2, dx: i32, dy: i32) {
let d = f64::from(dx * dx + dy * dy).sqrt() as f32;
let taper = self.cfg.edge_taper.max(1.0);
let mut vis = 0.0f32;
if d <= self.cfg.peripheral_range {
vis = ((self.cfg.peripheral_range - d) / taper).clamp(0.0, 1.0);
}
if let Some(f) = self.facing {
if d <= self.cfg.range && d > 0.0 {
let dir = Vec2::new(dx as f32, dy as f32) / d;
let ang = dir.dot(f).clamp(-1.0, 1.0).acos();
if ang <= self.cfg.cone_half_angle {
let ang_t = ((self.cfg.cone_half_angle - ang) / self.cfg.cone_taper.max(1e-3))
.clamp(0.0, 1.0);
let rad_t = ((self.cfg.range - d) / taper).clamp(0.0, 1.0);
vis = vis.max(ang_t.min(rad_t));
}
}
}
if vis <= 0.0 {
return;
}
let lit = self.sample(cell).1;
vis *= lit;
if vis <= 0.0 {
return;
}
let target = f32::from(INTENSITY_MAX);
let e = self.out.entry((cell.x, cell.y)).or_insert(0.0);
*e = e.max(target);
}
fn cast(&mut self, row: i32, mut start: f64, end: f64, m: [i32; 4]) {
if start < end {
return;
}
let radius = self.radius();
let r2 = i64::from(radius) * i64::from(radius);
let mut new_start = start;
let mut blocked = false;
let mut j = row;
while j <= radius && !blocked {
let dy = -j;
for dx in -j..=0 {
let l_slope = (f64::from(dx) - 0.5) / (f64::from(dy) + 0.5);
let r_slope = (f64::from(dx) + 0.5) / (f64::from(dy) - 0.5);
if start < r_slope {
continue;
}
if end > l_slope {
break;
}
let sx = dx * m[0] + dy * m[1];
let sy = dx * m[2] + dy * m[3];
let cell = self.origin + IVec2::new(sx, sy);
if i64::from(dx) * i64::from(dx) + i64::from(dy) * i64::from(dy) <= r2 {
self.visit(cell, sx, sy);
}
let blk = self.blocked_at(cell);
if blocked {
if blk {
new_start = r_slope;
} else {
blocked = false;
start = new_start;
}
} else if blk && j < radius {
blocked = true;
self.cast(j + 1, start, l_slope, m);
new_start = r_slope;
}
}
j += 1;
}
}
}
fn sample_cell(
grid: &Grid,
band: DeckBand,
eye_lo: i32,
eye_hi: i32,
gate: Option<&LightGate>,
cell: IVec2,
) -> (bool, f32) {
let cs_xy = CHUNK_SIZE_XY as i32;
let cs_z = crate::CHUNK_SIZE_Z as i32;
let chx = cell.x.div_euclid(cs_xy);
let chy = cell.y.div_euclid(cs_xy);
let ix = cell.x.rem_euclid(cs_xy) as u32;
let iy = cell.y.rem_euclid(cs_xy) as u32;
let z_lo = band.z_top.min(eye_lo);
let z_hi = band.z_bottom.max(eye_hi);
let mut blocked = false;
let mut surface: Option<u8> = None;
let mut lit_override = false;
let mut cur_chz = i32::MIN;
let mut chunk: Option<&Vxl> = None;
for z in z_lo..=z_hi {
let chz = z.div_euclid(cs_z);
if chz != cur_chz {
cur_chz = chz;
chunk = grid.chunk(IVec3::new(chx, chy, chz));
}
let Some(vxl) = chunk else {
continue;
};
let iz = z.rem_euclid(cs_z) as u32;
if !Grid::chunk_voxel_solid(vxl, UVec3::new(ix, iy, iz)) {
continue;
}
if (eye_lo..=eye_hi).contains(&z) {
blocked = true;
}
if let Some(g) = gate {
let color = vxl.voxel_color(ix, iy, iz);
if let Some(c) = color {
if g.emissive_colors.contains(&c.rgb_part()) {
lit_override = true;
}
}
if surface.is_none() && z >= eye_lo {
surface = Some(color.map_or(0x80, |c| (c.0 >> 24) as u8));
}
} else if blocked {
return (true, 1.0);
}
}
let lit = match gate {
None => 1.0,
Some(_) if lit_override => 1.0,
Some(g) => surface.map_or(0.0, |b| g.lit_factor(b)),
};
(blocked, lit)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GridTransform;
use roxlap_formats::color::VoxColor;
const FLOOR_Z: i32 = 100;
const EYE_Z: i32 = 94;
fn band() -> DeckBand {
DeckBand {
z_top: 80,
z_bottom: FLOOR_Z,
}
}
fn open_room() -> Grid {
let mut g = Grid::new(GridTransform::identity());
g.set_rect(
IVec3::new(-64, -64, FLOOR_Z),
IVec3::new(63, 63, FLOOR_Z),
Some(VoxColor::rgb(120, 120, 120)),
);
g
}
fn cfg() -> VisionConfig {
let mut c = VisionConfig::for_decks(vec![band()]);
c.range = 40.0;
c.peripheral_range = 12.0;
c
}
fn observer(cell: IVec2, facing: Vec2) -> FowObserver {
FowObserver {
cell,
facing,
deck: 0,
eye_z: EYE_Z,
}
}
fn wall(g: &mut Grid, x: i32, y: i32) {
g.set_rect(
IVec3::new(x, y, 88),
IVec3::new(x, y, FLOOR_Z),
Some(VoxColor::rgb(200, 60, 60)),
);
}
const SETTLE: f32 = 1000.0;
#[test]
fn cone_and_peripheral_classification() {
let g = open_room();
let mut fow = FogOfWar::new(cfg());
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
assert_eq!(fow.state(0, IVec2::new(-6, 0)).0, CellState::Visible);
assert_eq!(fow.state(0, IVec2::new(-25, 0)).0, CellState::Unseen);
assert_eq!(fow.state(0, IVec2::new(60, 0)).0, CellState::Unseen);
assert_eq!(fow.state(0, IVec2::new(20, 0)).1, INTENSITY_MAX);
}
#[test]
fn wall_blocks_line_of_sight() {
let mut g = open_room();
for y in -12..=12 {
wall(&mut g, 15, y);
}
let mut fow = FogOfWar::new(cfg());
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(15, 0)).0, CellState::Visible);
assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Unseen);
assert_eq!(fow.state(0, IVec2::new(10, 0)).0, CellState::Visible);
}
#[test]
fn known_cells_include_memory_live_cells_dont() {
use std::collections::HashSet;
let g = open_room();
let mut fow = FogOfWar::new(cfg());
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.01);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
let mut live = HashSet::new();
fow.for_each_live_cell(|_d, c, _s| {
live.insert((c.x, c.y));
});
assert!(!live.contains(&(20, 0)), "memory cell is not LIVE");
let mut known = HashSet::new();
fow.for_each_known_cell(|_d, c, _s| {
known.insert((c.x, c.y));
});
assert!(known.contains(&(20, 0)), "memory cell IS known");
}
#[test]
fn edit_invalidates_opacity_cache() {
let mut g = open_room();
let mut fow = FogOfWar::new(cfg());
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Visible);
for y in -12..=12 {
wall(&mut g, 15, y);
}
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Memory);
assert_eq!(fow.state(0, IVec2::new(15, 0)).0, CellState::Visible);
}
#[test]
fn memory_decays_to_floor_and_reseeing_resets() {
let g = open_room();
let mut c = cfg();
c.memory_decay = 8.0;
let floor = c.memory_floor;
let sustain = c.memory_intensity;
let mut fow = FogOfWar::new(c);
let cell = IVec2::new(20, 0);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.05);
assert_eq!(fow.state(0, cell).0, CellState::Memory);
let after_fall = fow.state(0, cell).1;
assert!(after_fall < INTENSITY_MAX);
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 1.0);
let at_sustain = fow.state(0, cell).1;
assert!(at_sustain <= sustain);
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 30.0);
assert_eq!(fow.state(0, cell).1, floor);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
}
#[test]
fn visible_snaps_full_then_memory_fades_gradually() {
let g = open_room();
let mut fow = FogOfWar::new(cfg());
let cell = IVec2::new(20, 0);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.001);
assert_eq!(fow.state(0, cell), (CellState::Visible, INTENSITY_MAX));
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.05);
let (state, mid) = fow.state(0, cell);
assert_eq!(state, CellState::Memory);
assert!(mid > 0 && mid < INTENSITY_MAX, "mid fade-out, got {mid}");
}
#[test]
fn light_gate_caps_dark_cells() {
let mut g = Grid::new(GridTransform::identity());
g.set_rect(
IVec3::new(-64, -64, FLOOR_Z),
IVec3::new(9, 63, FLOOR_Z),
Some(VoxColor::rgb(120, 120, 120)),
);
g.set_rect(
IVec3::new(10, -64, FLOOR_Z),
IVec3::new(63, 63, FLOOR_Z),
Some(VoxColor::rgb(120, 120, 120).with_brightness(20)),
);
let emissive = VoxColor::rgb(80, 255, 160);
g.set_voxel(
IVec3::new(30, 5, FLOOR_Z - 1),
Some(emissive.with_brightness(20)),
);
let mut c = cfg();
c.light_gate = Some(LightGate {
lit_brightness: 100,
softness: 0,
emissive_colors: vec![emissive.rgb_part()],
});
let mut fow = FogOfWar::new(c);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(5, 0)).0, CellState::Visible);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Unseen);
assert_eq!(fow.state(0, IVec2::new(30, 5)).0, CellState::Visible);
}
#[test]
fn deck_layers_are_independent() {
let mut g = open_room();
g.set_rect(
IVec3::new(-64, -64, 130),
IVec3::new(63, 63, 130),
Some(VoxColor::rgb(90, 90, 140)),
);
let lower = DeckBand {
z_top: 110,
z_bottom: 130,
};
let mut c = cfg();
c.decks = vec![band(), lower];
let mut fow = FogOfWar::new(c);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
assert_eq!(fow.state(1, IVec2::new(20, 0)).0, CellState::Unseen);
let mut obs = observer(IVec2::new(0, 0), Vec2::X);
obs.deck = 1;
obs.eye_z = 124;
fow.update(&g, &obs, SETTLE);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
assert_eq!(fow.state(1, IVec2::new(20, 0)).0, CellState::Visible);
}
#[test]
fn heard_blob_reveals_behind_wall_then_fades() {
let mut g = open_room();
for y in -12..=12 {
wall(&mut g, 15, y);
}
let mut fow = FogOfWar::new(cfg());
let src = IVec2::new(25, 0);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, src).0, CellState::Unseen);
fow.hear(0, src, 1.0);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.5);
assert_eq!(fow.state(0, src).0, CellState::Heard);
assert!(fow.state(0, IVec2::new(27, 2)).1 > 0);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 2.0);
assert_eq!(fow.state(0, src).0, CellState::Memory);
}
#[test]
fn mask_version_tracks_changes() {
let g = open_room();
let mut fow = FogOfWar::new(cfg());
let v0 = fow.mask_version();
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
let v1 = fow.mask_version();
assert!(v1 > v0);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 1.0);
assert_eq!(fow.mask_version(), v1);
}
#[test]
fn live_cells_cover_visible_and_heard() {
let mut g = open_room();
for y in -12..=12 {
wall(&mut g, 15, y);
}
let mut fow = FogOfWar::new(cfg());
fow.hear(0, IVec2::new(25, 0), 0.5);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.5);
let mut visible = 0usize;
let mut heard = 0usize;
fow.for_each_live_cell(|deck, _, state| {
assert_eq!(deck, 0);
match state {
CellState::Visible => visible += 1,
CellState::Heard => heard += 1,
_ => panic!("live cells are Visible or Heard"),
}
});
assert!(visible > 0 && heard > 0);
}
#[test]
fn light_gate_reads_floor_not_ceiling() {
let mut g = open_room(); g.set_rect(
IVec3::new(10, -32, 85),
IVec3::new(40, 32, 85),
Some(VoxColor::rgb(120, 120, 120).with_brightness(15)),
);
let mut c = cfg();
c.light_gate = Some(LightGate {
lit_brightness: 100,
softness: 0,
emissive_colors: Vec::new(),
});
let mut fow = FogOfWar::new(c);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
}
#[test]
fn full_bake_bumps_chunk_version() {
let g = open_room();
let (chunk_idx, _) = crate::voxel_split(IVec3::new(0, 0, FLOOR_Z));
let v0 = g.chunk_version(chunk_idx);
let mut g = g;
g.bake(crate::BakeMode::Directional);
assert!(
g.chunk_version(chunk_idx) > v0,
"full bake must bump the version (was {v0})"
);
}
#[test]
fn tile_key_changes_on_materialisation_at_version_zero() {
let mut g = Grid::new(GridTransform::identity());
let b = band();
let (eye_lo, eye_hi) = (EYE_Z - EYE_HALF, EYE_Z + EYE_HALF);
let k_absent = tile_key(&g, b, eye_lo, eye_hi, 0, 0, 0);
let _ = g.ensure_chunk(IVec3::new(0, 0, 0));
assert_eq!(g.chunk_version(IVec3::new(0, 0, 0)), 0);
let k_present = tile_key(&g, b, eye_lo, eye_hi, 0, 0, 0);
assert_ne!(
k_absent, k_present,
"presence must change the tile key at version 0"
);
}
#[test]
fn ceiling_emissive_lights_without_blocking() {
let mut g = open_room();
let emissive = VoxColor::rgb(80, 255, 160);
g.set_voxel(IVec3::new(20, 0, 85), Some(emissive.with_brightness(30)));
let mut c = cfg();
c.light_gate = Some(LightGate {
lit_brightness: 100,
softness: 0,
emissive_colors: vec![emissive.rgb_part()],
});
let mut fow = FogOfWar::new(c);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Visible);
assert_eq!(fow.state(0, IVec2::new(25, 0)).0, CellState::Visible);
}
#[test]
fn facing_a_heard_source_stays_visible() {
let g = open_room();
let mut fow = FogOfWar::new(cfg());
let src = IVec2::new(25, 0);
fow.hear(0, src, 1.0);
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.1);
assert_eq!(fow.state(0, src).0, CellState::Heard);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.1);
assert_eq!(fow.state(0, src).0, CellState::Visible);
}
#[test]
fn near_zero_facing_distinct_from_none() {
let g = open_room();
let mut fow = FogOfWar::new(cfg());
let cell = IVec2::new(30, 0); fow.update(
&g,
&observer(IVec2::new(0, 0), Vec2::new(1.0, -0.003)),
SETTLE,
);
assert_eq!(fow.state(0, cell).0, CellState::Visible);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::ZERO), SETTLE);
assert_eq!(fow.state(0, cell).0, CellState::Memory);
}
#[test]
fn far_edit_does_not_bump_mask_version() {
let mut g = open_room();
let mut fow = FogOfWar::new(cfg());
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
let v = fow.mask_version();
g.set_voxel(IVec3::new(600, 600, FLOOR_Z), Some(VoxColor::rgb(1, 2, 3)));
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.0);
assert_eq!(fow.mask_version(), v, "far edit should not recompute LOS");
}
use crate::Scene;
use glam::DVec3;
fn scene_with_room() -> (Scene, GridId) {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).unwrap();
g.set_rect(
IVec3::new(-64, -64, FLOOR_Z),
IVec3::new(63, 63, FLOOR_Z),
Some(VoxColor::rgb(120, 120, 120)),
);
(scene, id)
}
#[test]
fn attach_splits_render_and_query_grids() {
let (mut scene, real) = scene_with_room();
let twin = FowTwin::attach(&mut scene, real);
assert!(scene.grid(real).unwrap().render_excluded);
assert!(scene.grid(twin.twin()).unwrap().presentation_only);
let rendered: Vec<GridId> = scene.render_grids().map(|(id, _)| id).collect();
assert!(rendered.contains(&twin.twin()));
assert!(!rendered.contains(&real));
let queried: Vec<GridId> = scene.query_grids().map(|(id, _)| id).collect();
assert!(queried.contains(&real));
assert!(!queried.contains(&twin.twin()));
}
#[test]
fn excluded_real_grid_still_raycasts() {
let (mut scene, real) = scene_with_room();
let _twin = FowTwin::attach(&mut scene, real);
let hit = scene.raycast(
DVec3::new(0.5, 0.5, FLOOR_Z as f64 - 5.0),
DVec3::new(0.0, 0.0, 1.0),
32.0,
);
assert!(hit.is_some(), "real grid must still answer raycasts");
}
fn fow_deck() -> DeckBand {
band()
}
#[test]
fn sync_copies_only_seen_chunks() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = FogOfWar::new({
let mut c = VisionConfig::for_decks(vec![fow_deck()]);
c.range = 40.0;
c.peripheral_range = 12.0;
c
});
let real_grid = scene.grid(real).unwrap();
fow.update(real_grid, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
assert!(twin.sync(&mut scene, &fow));
let twin_grid = scene.grid(twin.twin()).unwrap();
assert!(twin_grid.voxel_solid(IVec3::new(20, 0, FLOOR_Z)));
assert!(twin_grid.chunk(IVec3::new(4, 4, 0)).is_none());
}
#[test]
fn edit_behind_wall_invisible_until_seen() {
let (mut scene, real) = scene_with_room();
scene.grid_mut(real).unwrap().set_rect(
IVec3::new(128, -8, FLOOR_Z),
IVec3::new(200, 8, FLOOR_Z),
Some(VoxColor::rgb(120, 120, 120)),
);
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = FogOfWar::new({
let mut c = VisionConfig::for_decks(vec![fow_deck()]);
c.range = 40.0;
c
});
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
assert!(scene
.grid(twin.twin())
.unwrap()
.chunk(IVec3::new(1, 0, 0))
.is_none());
scene.grid_mut(real).unwrap().set_voxel(
IVec3::new(150, 0, FLOOR_Z - 1),
Some(VoxColor::rgb(255, 0, 0)),
);
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
assert!(
scene
.grid(twin.twin())
.unwrap()
.chunk(IVec3::new(1, 0, 0))
.is_none(),
"unseen edit must not reach the twin"
);
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(150, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
let tg = scene.grid(twin.twin()).unwrap();
assert!(
tg.voxel_solid(IVec3::new(150, 0, FLOOR_Z - 1)),
"walked-over edit now seen"
);
}
#[test]
fn memory_chunk_frozen_after_observer_leaves() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = FogOfWar::new({
let mut c = VisionConfig::for_decks(vec![fow_deck()]);
c.range = 40.0;
c.peripheral_range = 4.0;
c
});
let cell = IVec3::new(20, 0, FLOOR_Z);
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
let seen_color = scene.grid(twin.twin()).unwrap().voxel_color(cell);
assert!(seen_color.is_some());
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), -Vec2::X),
0.05,
);
scene
.grid_mut(real)
.unwrap()
.set_voxel(cell, Some(VoxColor::rgb(9, 9, 9)));
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), -Vec2::X),
0.05,
);
assert!(twin.sync(&mut scene, &fow));
assert_eq!(
scene.grid(twin.twin()).unwrap().voxel_color(cell),
seen_color,
"memory geometry must stay frozen"
);
}
#[test]
fn chunk_granular_sync_leaks_within_a_seen_chunk() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = FogOfWar::new({
let mut c = VisionConfig::for_decks(vec![fow_deck()]);
c.range = 20.0; c.peripheral_range = 4.0;
c
});
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
let unseen = IVec3::new(60, 60, FLOOR_Z - 1);
assert_eq!(fow.state(0, IVec2::new(60, 60)).0, CellState::Unseen);
scene
.grid_mut(real)
.unwrap()
.set_voxel(unseen, Some(VoxColor::rgb(255, 0, 0)));
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
assert!(
scene.grid(twin.twin()).unwrap().voxel_solid(unseen),
"v1 chunk-granular sync copies the whole chunk (accepted leak)"
);
}
#[test]
fn detach_restores_real_and_removes_twin() {
let (mut scene, real) = scene_with_room();
let twin = FowTwin::attach(&mut scene, real);
let twin_id = twin.twin();
twin.detach(&mut scene);
assert!(!scene.grid(real).unwrap().render_excluded);
assert!(scene.grid(twin_id).is_none());
}
#[test]
fn twin_excluded_from_snapshot() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = FogOfWar::new({
let mut c = VisionConfig::for_decks(vec![fow_deck()]);
c.range = 40.0;
c
});
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
let snap = scene.to_snapshot();
assert_eq!(snap.grids.len(), 1);
assert_eq!(snap.grids[0].0, real);
}
fn seeing_fow() -> FogOfWar {
let mut c = VisionConfig::for_decks(vec![fow_deck()]);
c.range = 40.0;
c.peripheral_range = 12.0;
FogOfWar::new(c)
}
#[test]
fn attach_sets_gpu_residency_hint_to_real_bbox() {
let (mut scene, real) = scene_with_room();
scene
.grid_mut(real)
.unwrap()
.set_voxel(IVec3::new(10, 10, 260), Some(VoxColor::rgb(1, 2, 3)));
let twin = FowTwin::attach(&mut scene, real);
let hint = scene.grid(twin.twin()).unwrap().gpu_residency_hint;
assert_eq!(hint, Some(([-1, -1, 0], [2, 2, 2])));
}
#[test]
fn real_eviction_keeps_twin_memory() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = seeing_fow();
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
assert!(scene
.grid(twin.twin())
.unwrap()
.voxel_solid(IVec3::new(20, 0, FLOOR_Z)));
{
let g = scene.grid_mut(real).unwrap();
g.chunks.remove(&IVec3::new(0, 0, 0));
g.note_chunk_set_changed();
}
assert!(twin.sync(&mut scene, &fow));
assert!(
scene
.grid(twin.twin())
.unwrap()
.voxel_solid(IVec3::new(20, 0, FLOOR_Z)),
"twin must keep the last-seen copy after real eviction"
);
}
#[test]
fn sync_signals_lost_twin() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let fow = seeing_fow();
assert!(twin.sync(&mut scene, &fow));
scene.remove_grid(twin.twin());
assert!(!twin.sync(&mut scene, &fow), "lost twin must signal re-arm");
}
#[test]
fn quiet_frame_skips_resync() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = seeing_fow();
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
let after_first = scene.grid(twin.twin()).unwrap().mutation_counter();
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
1.0,
);
assert!(twin.sync(&mut scene, &fow));
assert_eq!(
scene.grid(twin.twin()).unwrap().mutation_counter(),
after_first,
"quiet frame must not re-bump the twin"
);
}
#[test]
fn resync_bumps_twin_with_bbox_not_full() {
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut fow = seeing_fow();
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
let _ = scene
.grid_mut(twin.twin())
.unwrap()
.take_chunk_dirty(IVec3::new(0, 0, 0));
scene.grid_mut(real).unwrap().set_voxel(
IVec3::new(15, 3, FLOOR_Z - 1),
Some(VoxColor::rgb(255, 0, 0)),
);
fow.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &fow));
let extent = scene
.grid_mut(twin.twin())
.unwrap()
.take_chunk_dirty(IVec3::new(0, 0, 0));
assert!(
matches!(extent, Some(crate::DirtyExtent::Bbox(_, _))),
"re-sync should bump a bbox extent, got {extent:?}"
);
}
#[test]
fn exclusion_predicates() {
let (mut scene, real) = scene_with_room();
let twin = FowTwin::attach(&mut scene, real);
assert!(!scene.grid(real).unwrap().renderable());
assert!(scene.grid(real).unwrap().queryable());
assert!(scene.grid(twin.twin()).unwrap().renderable());
assert!(!scene.grid(twin.twin()).unwrap().queryable());
}
#[test]
fn fow_render_verdict_maps_states() {
use roxlap_core::dda::{FowStyler, FowVerdict};
let g = open_room();
let mut fow = seeing_fow();
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
let styler = FowRender::new(&fow);
let zc = FLOOR_Z - 1;
match styler.verdict(20, 0, zc) {
FowVerdict::Show {
dynamic,
dim,
desaturate,
} => {
assert!(dynamic);
assert!((dim - 1.0).abs() < 1e-6, "cone-centre dim {dim}");
assert!(desaturate.abs() < 1e-6);
}
FowVerdict::Hide => panic!("expected Show at the cone centre, got Hide"),
}
assert_eq!(styler.verdict(-30, 0, zc), FowVerdict::Hide);
}
#[test]
fn fow_render_memory_is_baked_and_dim() {
use roxlap_core::dda::{FowStyler, FowVerdict};
let g = open_room();
let mut fow = seeing_fow();
let zc = FLOOR_Z - 1;
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.5);
let styler = FowRender::new(&fow);
match styler.verdict(20, 0, zc) {
FowVerdict::Show {
dynamic,
dim,
desaturate,
} => {
assert!(!dynamic, "memory must skip the dynamic rig");
assert!(dim < 1.0, "memory must be dimmed, got {dim}");
assert!(desaturate > 0.0, "memory desaturated");
}
FowVerdict::Hide => panic!("expected Show(memory), got Hide"),
}
}
#[test]
fn hides_sprite_by_cell_state() {
use glam::DVec3;
let g = open_room();
let mut fow = seeing_fow();
let t = GridTransform::identity();
let fp = (IVec2::new(-128, -128), IVec2::new(128, 128));
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
let at = |x: f64, y: f64| DVec3::new(x, y, (FLOOR_Z - 1) as f64 + 0.5);
assert!(!fow.hides_sprite(&t, fp, at(20.5, 0.5)));
assert!(fow.hides_sprite(&t, fp, at(-30.5, 0.5)));
assert!(!fow.hides_sprite(&t, fp, at(500.5, 0.5)));
assert!(!fow.hides_sprite(&t, fp, DVec3::new(20.5, 0.5, 300.5)));
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.5);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Memory);
assert!(
fow.hides_sprite(&t, fp, at(20.5, 0.5)),
"memory hides sprites"
);
}
#[test]
fn sprite_epoch_tracks_visible_set_only() {
let g = open_room();
let mut fow = seeing_fow();
let e0 = fow.sprite_epoch();
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.01);
let e1 = fow.sprite_epoch();
assert!(e1 > e0, "seeing new cells bumps sprite_epoch");
let mv = fow.mask_version();
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), 0.01);
assert_eq!(fow.sprite_epoch(), e1, "a fade must not bump sprite_epoch");
assert!(
fow.mask_version() >= mv,
"mask_version still tracks the fade"
);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::Y), SETTLE);
assert!(fow.sprite_epoch() > e1, "a turn bumps sprite_epoch");
}
#[test]
fn hear_world_maps_and_stamps() {
use glam::DVec3;
let g = open_room();
let mut fow = seeing_fow();
let t = GridTransform::identity();
let fp = (IVec2::new(-128, -128), IVec2::new(128, 128));
assert!(fow.hear_world(&t, fp, DVec3::new(20.5, 0.5, 99.5), 1.0));
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.1);
assert_eq!(fow.state(0, IVec2::new(20, 0)).0, CellState::Heard);
assert!(!fow.hear_world(&t, fp, DVec3::new(500.5, 500.5, 99.5), 1.0));
}
#[test]
fn gpu_mask_flattens_seen_cells() {
let g = open_room();
let mut fow = seeing_fow();
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
let mask = fow.gpu_mask(IVec2::ZERO, 32, 32);
assert_eq!(mask.decks.len(), 1);
assert_eq!(mask.decks[0], [80, FLOOR_Z]); assert_eq!(mask.cells.len(), 32 * 32);
assert_eq!(mask.cells[20] >> 6, 2, "seen cell is Visible");
assert_eq!(mask.cells[20 * 32 + 5], 0, "unseen cell is 0");
}
#[test]
fn fow_render_classifies_by_own_deck() {
use roxlap_core::dda::{FowStyler, FowVerdict};
let g = open_room();
let mut fow = seeing_fow();
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
let styler = FowRender::new(&fow);
assert!(matches!(
styler.verdict(20, 0, 90),
FowVerdict::Show { dynamic: true, .. }
));
assert_eq!(
styler.verdict(20, 0, 200),
FowVerdict::Show {
dynamic: false,
dim: 0.0,
desaturate: 0.0
}
);
assert_eq!(styler.verdict(20, 0, 0), FowVerdict::Hide);
assert_eq!(styler.verdict(5, 20, 90), FowVerdict::Hide);
}
#[test]
fn fow_render_below_occludes_above_transparent() {
use roxlap_core::dda::{FowStyler, FowVerdict};
let cfg = VisionConfig::for_decks(vec![
DeckBand {
z_top: 0,
z_bottom: 20,
},
DeckBand {
z_top: 30,
z_bottom: 50,
},
]);
let mut fow = FogOfWar::new(cfg);
assert_eq!(
FowRender::new(&fow).verdict(0, 0, 40),
FowVerdict::Show {
dynamic: false,
dim: 0.0,
desaturate: 0.0
}
);
assert_eq!(FowRender::new(&fow).verdict(0, 0, 10), FowVerdict::Hide);
let g = Grid::new(GridTransform::identity());
let mut obs = observer(IVec2::new(0, 0), Vec2::X);
obs.deck = 1;
obs.eye_z = 40;
fow.update(&g, &obs, SETTLE);
assert_eq!(FowRender::new(&fow).verdict(9, 9, 10), FowVerdict::Hide);
}
#[test]
fn fow_render_memory_seam_no_pop() {
use roxlap_core::dda::{FowStyler, FowVerdict};
let g = open_room();
let mut fow = seeing_fow();
let zc = FLOOR_Z - 1;
let cell = (20, 0);
fow.update(&g, &observer(IVec2::new(0, 0), Vec2::X), SETTLE);
let dim_visible = match FowRender::new(&fow).verdict(cell.0, cell.1, zc) {
FowVerdict::Show { dim, .. } => dim,
FowVerdict::Hide => panic!("seen cell hidden"),
};
assert!((dim_visible - 1.0).abs() < 1e-6);
fow.update(&g, &observer(IVec2::new(0, 0), -Vec2::X), 0.01);
assert_eq!(
fow.state(0, IVec2::new(cell.0, cell.1)).0,
CellState::Memory
);
let dim_memory = match FowRender::new(&fow).verdict(cell.0, cell.1, zc) {
FowVerdict::Show { dim, dynamic, .. } => {
assert!(!dynamic, "memory skips the rig");
dim
}
FowVerdict::Hide => panic!("just-seen memory hidden"),
};
let memory_dim = fow.config().memory_dim;
assert!(
dim_memory > 0.5 * (1.0 + memory_dim),
"seam pop: dim dropped to {dim_memory} (memory_dim {memory_dim})"
);
}
#[test]
fn composed_render_fow_some_hides_unseen_shows_seen() {
use crate::render::{render_scene_composed_frame, ComposedFrameParams, SceneRenderScratch};
use roxlap_core::{Camera, OpticastSettings};
let (mut scene, real) = scene_with_room();
let mut twin = FowTwin::attach(&mut scene, real);
let mut seen = seeing_fow();
seen.update(
scene.grid(real).unwrap(),
&observer(IVec2::new(0, 0), Vec2::X),
SETTLE,
);
assert!(twin.sync(&mut scene, &seen));
let twin_id = twin.twin();
let camera = Camera {
pos: [18.0, 0.0, 60.0],
right: [1.0, 0.0, 0.0],
down: [0.0, 1.0, 0.0],
forward: [0.0, 0.0, 1.0],
};
let (w, h) = (48u32, 48u32);
let settings = OpticastSettings::for_oracle_framebuffer(w, h);
let render = |fow: Option<(GridId, &FogOfWar)>, scene: &mut Scene| {
let mut fb = vec![0u32; (w * h) as usize];
let mut zb = vec![f32::INFINITY; (w * h) as usize];
let mut scratch = SceneRenderScratch::default();
let mut params = ComposedFrameParams::new(&camera, &settings);
params.fow = fow;
let _ = render_scene_composed_frame(
&mut fb,
&mut zb,
w as usize,
w,
h,
scene,
¶ms,
&mut scratch,
);
fb
};
let non_sky = |fb: &[u32]| fb.iter().filter(|&&p| p != 0).count();
let base = render(None, &mut scene);
assert!(non_sky(&base) > 0, "the twin floor must render without fog");
let shown = render(Some((twin_id, &seen)), &mut scene);
assert!(non_sky(&shown) > 0, "seen cells must stay visible");
let blank = FogOfWar::new(seeing_fow().config().clone());
let hidden = render(Some((twin_id, &blank)), &mut scene);
assert_eq!(non_sky(&hidden), 0, "unseen cells must be hidden (sky)");
}
}