use glam::{DVec3, IVec3};
use roxlap_core::opticast::{opticast, OpticastOutcome, OpticastSettings};
use roxlap_core::rasterizer::ScratchPool;
use roxlap_core::scalar_rasterizer::ScalarRasterizer;
use roxlap_core::Camera;
use crate::{Grid, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
#[derive(Debug, Clone, Copy)]
pub struct GridLocalBounds {
pub centre: DVec3,
pub radius: f64,
}
#[must_use]
pub fn grid_bounds(grid: &Grid) -> GridLocalBounds {
let (centre, radius) = grid_local_centre_and_radius(grid);
GridLocalBounds { centre, radius }
}
const CAMERA_DISTANCE_FACTOR: f64 = 8.0;
pub const DEFAULT_RESOLUTION: u32 = 128;
pub const SKY_SENTINEL: u32 = 0x00_00_00_00;
#[derive(Debug, Clone)]
pub struct BillboardSnapshot {
pub view_dir: DVec3,
pub width: u32,
pub height: u32,
pub color: Vec<u32>,
pub depth: Vec<f32>,
}
#[derive(Debug, Clone)]
pub struct BillboardCache {
pub resolution: u32,
pub snapshots: Vec<BillboardSnapshot>,
}
impl BillboardCache {
#[must_use]
pub fn new_empty(resolution: u32) -> Self {
Self {
resolution,
snapshots: Vec::new(),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.snapshots.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.snapshots.is_empty()
}
#[must_use]
pub fn build(grid: &Grid, resolution: u32) -> Self {
let viewpoints = canonical_viewpoints();
let mut snapshots = Vec::with_capacity(viewpoints.len());
let (centre, radius) = grid_local_centre_and_radius(grid);
let r = radius.max(1.0);
let d = CAMERA_DISTANCE_FACTOR * r;
let max_scan_dist = ((d + r) * 1.25).ceil().max(64.0) as i32;
let pool_vsid = CHUNK_SIZE_XY.max(resolution).max(64);
let mut pool = ScratchPool::new(resolution, resolution, pool_vsid);
let sentinel_i = i32::from_ne_bytes(SKY_SENTINEL.to_ne_bytes());
pool.set_skycast(sentinel_i, 0);
pool.set_treat_z_max_as_air(true);
for view_dir in viewpoints {
let camera = snapshot_camera(view_dir, centre, d);
let mut color = vec![SKY_SENTINEL; (resolution as usize) * (resolution as usize)];
let mut depth = vec![f32::INFINITY; color.len()];
let outcome = if let Some(backing) = grid.chunk_xyz_backing() {
let cg = roxlap_core::ChunkGrid {
chunks: &backing.chunks,
origin_chunk_xy: backing.origin_chunk_xy,
origin_chunk_z: backing.origin_chunk_z,
chunks_x: backing.chunks_x,
chunks_y: backing.chunks_y,
chunks_z: backing.chunks_z,
};
let grid_view = roxlap_core::GridView::from_chunk_grid(&cg, CHUNK_SIZE_XY);
let settings = snapshot_settings(resolution, d, r, max_scan_dist);
let mut rasterizer =
ScalarRasterizer::new(&mut color, &mut depth, resolution as usize, grid_view);
opticast(&mut rasterizer, &mut pool, &camera, &settings, grid_view)
} else {
OpticastOutcome::Rendered
};
let _ = outcome;
for (px, z) in color.iter().zip(depth.iter_mut()) {
if *px == SKY_SENTINEL {
*z = f32::INFINITY;
}
}
snapshots.push(BillboardSnapshot {
view_dir,
width: resolution,
height: resolution,
color,
depth,
});
}
Self {
resolution,
snapshots,
}
}
#[must_use]
pub fn pick_nearest(&self, query: DVec3) -> Option<&BillboardSnapshot> {
if self.snapshots.is_empty() {
return None;
}
let mut best_idx = 0usize;
let mut best_dot = self.snapshots[0].view_dir.dot(query);
for (i, snap) in self.snapshots.iter().enumerate().skip(1) {
let d = snap.view_dir.dot(query);
if d > best_dot {
best_dot = d;
best_idx = i;
}
}
Some(&self.snapshots[best_idx])
}
}
#[must_use]
pub fn canonical_viewpoints() -> Vec<DVec3> {
let mut out = Vec::with_capacity(26);
for &axis in &[
DVec3::X,
DVec3::NEG_X,
DVec3::Y,
DVec3::NEG_Y,
DVec3::Z,
DVec3::NEG_Z,
] {
out.push(axis);
}
let signs = [-1.0_f64, 1.0_f64];
for &sa in &signs {
for &sb in &signs {
out.push(DVec3::new(sa, sb, 0.0).normalize());
out.push(DVec3::new(sa, 0.0, sb).normalize());
out.push(DVec3::new(0.0, sa, sb).normalize());
}
}
for &sx in &signs {
for &sy in &signs {
for &sz in &signs {
out.push(DVec3::new(sx, sy, sz).normalize());
}
}
}
debug_assert_eq!(out.len(), 26);
out
}
fn grid_local_centre_and_radius(grid: &Grid) -> (DVec3, f64) {
if grid.chunks.is_empty() {
return (DVec3::ZERO, 0.0);
}
let mut lo = IVec3::splat(i32::MAX);
let mut hi = IVec3::splat(i32::MIN);
for &idx in grid.chunks.keys() {
lo = lo.min(idx);
hi = hi.max(idx);
}
let sx = f64::from(CHUNK_SIZE_XY);
let sz = f64::from(CHUNK_SIZE_Z);
let lo_v = DVec3::new(
f64::from(lo.x) * sx,
f64::from(lo.y) * sx,
f64::from(lo.z) * sz,
);
let hi_v = DVec3::new(
f64::from(hi.x + 1) * sx,
f64::from(hi.y + 1) * sx,
f64::from(hi.z + 1) * sz,
);
let centre = (lo_v + hi_v) * 0.5;
let half_extent = (hi_v - lo_v) * 0.5;
let radius = half_extent.length();
(centre, radius)
}
fn snapshot_camera(view_dir: DVec3, centre: DVec3, d: f64) -> Camera {
let pos = centre + view_dir * d;
let forward = -view_dir;
let up_ref = if forward.z.abs() < 0.99 {
DVec3::NEG_Z
} else {
DVec3::X
};
let right = forward.cross(up_ref).normalize();
let down = forward.cross(right);
Camera {
pos: pos.to_array(),
right: right.to_array(),
down: down.to_array(),
forward: forward.to_array(),
}
}
fn snapshot_settings(resolution: u32, d: f64, r: f64, max_scan_dist: i32) -> OpticastSettings {
let n = f64::from(resolution);
let half_n = (n * 0.5) as f32;
#[allow(clippy::cast_possible_truncation)]
let hz = ((n * d) / (2.0 * r)) as f32;
OpticastSettings {
xres: resolution,
yres: resolution,
y_start: 0,
y_end: resolution,
hx: half_n,
hy: half_n,
hz,
anginc: 1,
mip_levels: 1,
mip_scan_dist: 4,
max_scan_dist,
}
}
#[allow(clippy::too_many_arguments)]
pub fn billboard_blit_into(
fb: &mut [u32],
zb: &mut [f32],
pitch_pixels: usize,
width: u32,
height: u32,
snapshot: &BillboardSnapshot,
grid_world_centre: DVec3,
grid_world_radius: f64,
camera: &Camera,
settings: &OpticastSettings,
) {
let cam_pos = DVec3::from_array(camera.pos);
let forward = DVec3::from_array(camera.forward);
let right = DVec3::from_array(camera.right);
let down = DVec3::from_array(camera.down);
let to_centre = grid_world_centre - cam_pos;
let depth = to_centre.dot(forward);
if depth <= 0.0 || !depth.is_finite() {
return;
}
let x_off = to_centre.dot(right);
let y_off = to_centre.dot(down);
let scale = f64::from(settings.hz) / depth;
let cx = f64::from(settings.hx) + x_off * scale;
let cy = f64::from(settings.hy) + y_off * scale;
let pixel_radius_f = grid_world_radius * scale;
if !pixel_radius_f.is_finite() || pixel_radius_f < 1.0 {
return;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pixel_radius = pixel_radius_f.ceil() as i32;
let dst_size = pixel_radius * 2;
if dst_size <= 0 {
return;
}
let src_w = snapshot.width as i32;
let src_h = snapshot.height as i32;
if src_w <= 0 || src_h <= 0 {
return;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let dst_left = (cx - pixel_radius_f) as i32;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let dst_top = (cy - pixel_radius_f) as i32;
#[allow(clippy::cast_possible_truncation)]
let z = depth as f32;
let w_i = width as i32;
let h_i = height as i32;
for dy in 0..dst_size {
let screen_y = dst_top + dy;
if screen_y < 0 || screen_y >= h_i {
continue;
}
let sy = (dy * src_h) / dst_size;
let row_src_base = (sy as usize) * (src_w as usize);
let row_dst_base = (screen_y as usize) * pitch_pixels;
for dx in 0..dst_size {
let screen_x = dst_left + dx;
if screen_x < 0 || screen_x >= w_i {
continue;
}
let sx = (dx * src_w) / dst_size;
let src_idx = row_src_base + sx as usize;
if snapshot.color[src_idx] == SKY_SENTINEL || snapshot.depth[src_idx].is_infinite() {
continue;
}
let dst_idx = row_dst_base + screen_x as usize;
if z < zb[dst_idx] {
fb[dst_idx] = snapshot.color[src_idx];
zb[dst_idx] = z;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GridTransform;
#[test]
fn canonical_viewpoints_has_26() {
let v = canonical_viewpoints();
assert_eq!(v.len(), 26);
}
#[test]
fn canonical_viewpoints_all_unit_length() {
for (i, d) in canonical_viewpoints().iter().enumerate() {
let len = d.length();
assert!(
(len - 1.0).abs() < 1e-12,
"viewpoint {i}: {d:?} length={len}",
);
}
}
#[test]
fn canonical_viewpoints_all_distinct() {
let v = canonical_viewpoints();
for i in 0..v.len() {
for j in (i + 1)..v.len() {
let same = (v[i] - v[j]).length() < 1e-9;
assert!(!same, "viewpoint {i} and {j} are equal: {:?}", v[i]);
}
}
}
#[test]
fn canonical_viewpoints_cover_all_octants() {
let mut octants_seen = std::collections::HashSet::new();
for v in canonical_viewpoints() {
let sig = (
v.x.partial_cmp(&0.0).unwrap(),
v.y.partial_cmp(&0.0).unwrap(),
v.z.partial_cmp(&0.0).unwrap(),
);
use std::cmp::Ordering::*;
if !matches!(sig.0, Equal) && !matches!(sig.1, Equal) && !matches!(sig.2, Equal) {
octants_seen.insert(sig);
}
}
assert_eq!(octants_seen.len(), 8);
}
fn build_small_grid() -> Grid {
let mut g = Grid::new(GridTransform::identity());
g.set_rect(
IVec3::new(40, 40, 40),
IVec3::new(80, 80, 80),
Some(0x80_22_aa_22),
);
g
}
#[test]
fn build_populates_26_snapshots() {
let grid = build_small_grid();
let cache = BillboardCache::build(&grid, 32);
assert_eq!(cache.resolution, 32);
assert_eq!(cache.len(), 26);
for (i, snap) in cache.snapshots.iter().enumerate() {
assert_eq!(snap.width, 32);
assert_eq!(snap.height, 32);
assert_eq!(snap.color.len(), 32 * 32);
assert_eq!(snap.depth.len(), 32 * 32);
let expected = canonical_viewpoints()[i];
assert!(
(snap.view_dir - expected).length() < 1e-12,
"snapshot {i} view_dir mismatch",
);
}
}
#[test]
fn build_renders_some_non_sky_pixels_per_viewpoint() {
let grid = build_small_grid();
let cache = BillboardCache::build(&grid, 32);
for (i, snap) in cache.snapshots.iter().enumerate() {
let non_sky = snap.color.iter().filter(|&&p| p != SKY_SENTINEL).count();
assert!(
non_sky > 0,
"snapshot {i} (view_dir={:?}) rendered all-sky",
snap.view_dir,
);
}
}
#[test]
fn build_empty_grid_yields_26_all_sky_snapshots() {
let grid = Grid::new(GridTransform::identity());
let cache = BillboardCache::build(&grid, 16);
assert_eq!(cache.len(), 26);
for (i, snap) in cache.snapshots.iter().enumerate() {
for &px in &snap.color {
assert_eq!(
px, SKY_SENTINEL,
"empty grid snapshot {i} produced non-sky pixel {px:#010x}",
);
}
for &z in &snap.depth {
assert!(z.is_infinite(), "empty grid snapshot {i} depth not INF",);
}
}
}
#[test]
fn pick_nearest_returns_face_viewpoint_for_axis_query() {
let grid = build_small_grid();
let cache = BillboardCache::build(&grid, 16);
let snap = cache.pick_nearest(DVec3::X).expect("non-empty cache");
assert!(
(snap.view_dir - DVec3::X).length() < 1e-12,
"+x query picked {:?}",
snap.view_dir,
);
let snap = cache.pick_nearest(DVec3::NEG_Z).expect("non-empty cache");
assert!(
(snap.view_dir - DVec3::NEG_Z).length() < 1e-12,
"-z query picked {:?}",
snap.view_dir,
);
}
#[test]
fn pick_nearest_routes_oblique_to_a_corner_viewpoint() {
let grid = build_small_grid();
let cache = BillboardCache::build(&grid, 16);
let query = DVec3::new(1.0, 1.0, 1.0).normalize();
let snap = cache.pick_nearest(query).expect("non-empty cache");
assert!(
(snap.view_dir - query).length() < 1e-9,
"diagonal query picked {:?}",
snap.view_dir,
);
}
#[test]
fn pick_nearest_returns_none_for_empty_cache() {
let cache = BillboardCache::new_empty(32);
assert!(cache.is_empty());
assert!(cache.pick_nearest(DVec3::X).is_none());
}
#[test]
fn new_empty_allocates_no_snapshots() {
let cache = BillboardCache::new_empty(64);
assert_eq!(cache.resolution, 64);
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
}