#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::many_single_char_names,
clippy::similar_names
)]
use bytemuck::{Pod, Zeroable};
use roxlap_formats::kv6::Kv6;
use roxlap_formats::sprite::Sprite;
#[derive(Debug, Clone)]
pub struct SpriteModel {
pub dims: [u32; 3],
pub occ_words_per_col: u32,
pub pivot: [f32; 3],
pub occupancy: Vec<u32>,
pub colors: Vec<u32>,
pub dirs: Vec<u32>,
pub color_offsets: Vec<u32>,
pub voxel_world_size: f32,
}
#[must_use]
pub fn build_sprite_model(kv6: &Kv6) -> SpriteModel {
let (mx, my, mz) = (kv6.xsiz, kv6.ysiz, kv6.zsiz);
let occ_words_per_col = mz.div_ceil(32).max(1);
let cols = (mx * my) as usize;
let mut occupancy = vec![0u32; cols * occ_words_per_col as usize];
let mut color_offsets = vec![0u32; cols + 1];
let mut colors: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
let mut dirs: Vec<u32> = Vec::with_capacity(kv6.voxels.len());
let mut buckets: Vec<Vec<(u16, u32, u8)>> = vec![Vec::new(); cols];
let mut voxel_iter = kv6.voxels.iter();
for x in 0..mx {
for y in 0..my {
let col = (x + y * mx) as usize;
let count = kv6.ylen[x as usize][y as usize];
for _ in 0..count {
let v = voxel_iter.next().expect("KV6 ylen / voxels.len mismatch");
buckets[col].push((v.z, v.col, v.dir));
}
}
}
for (col, bucket) in buckets.iter_mut().enumerate() {
color_offsets[col] = colors.len() as u32;
bucket.sort_by_key(|(z, _, _)| *z);
for &(z, col_rgba, dir) in bucket.iter() {
let z = u32::from(z);
let base = col * occ_words_per_col as usize + (z >> 5) as usize;
occupancy[base] |= 1u32 << (z & 31);
colors.push(col_rgba);
dirs.push(u32::from(dir));
}
}
color_offsets[cols] = colors.len() as u32;
SpriteModel {
dims: [mx, my, mz],
occ_words_per_col,
pivot: [kv6.xpiv, kv6.ypiv, kv6.zpiv],
occupancy,
color_offsets,
colors,
dirs,
voxel_world_size: 1.0,
}
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
pub struct SpriteInstanceTransform {
pub inv_rot: [[f32; 4]; 3],
pub pos: [f32; 3],
_pad: f32,
}
impl SpriteInstanceTransform {
#[must_use]
pub fn from_sprite(sprite: &Sprite) -> Self {
let inv = mat3_inverse([sprite.s, sprite.h, sprite.f]);
Self {
inv_rot: [
[inv[0][0], inv[0][1], inv[0][2], 0.0],
[inv[1][0], inv[1][1], inv[1][2], 0.0],
[inv[2][0], inv[2][1], inv[2][2], 0.0],
],
pos: sprite.p,
_pad: 0.0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SpriteModelRegistry {
entries: Vec<SpriteModel>,
chains: Vec<Vec<u32>>,
}
impl SpriteModelRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn push_entry(&mut self, model: SpriteModel) -> u32 {
let id = self.entries.len() as u32;
self.entries.push(model);
id
}
pub fn add(&mut self, model: SpriteModel) -> u32 {
let e = self.push_entry(model);
let id = self.chains.len() as u32;
self.chains.push(vec![e]);
id
}
pub fn add_lod(&mut self, model: SpriteModel, max_levels: u32) -> u32 {
let mut levels = vec![self.push_entry(model.clone())];
let mut cur = model;
for _ in 1..max_levels.max(1) {
if cur.dims == [1, 1, 1] {
break;
}
cur = cur.downsample();
levels.push(self.push_entry(cur.clone()));
}
let id = self.chains.len() as u32;
self.chains.push(levels);
id
}
pub fn fork(&mut self, parent: u32) -> u32 {
let src = self.chains[parent as usize].clone();
let levels: Vec<u32> = src
.iter()
.map(|&e| {
let copy = self.entries[e as usize].clone();
self.push_entry(copy)
})
.collect();
let id = self.chains.len() as u32;
self.chains.push(levels);
id
}
#[must_use]
pub fn model(&self, id: u32) -> &SpriteModel {
&self.entries[self.chains[id as usize][0] as usize]
}
pub fn model_mut(&mut self, id: u32) -> &mut SpriteModel {
let e = self.chains[id as usize][0] as usize;
&mut self.entries[e]
}
pub fn recolor_chain(&mut self, id: u32, f: impl Fn(u32) -> u32 + Copy) {
for li in 0..self.chains[id as usize].len() {
let e = self.chains[id as usize][li] as usize;
self.entries[e].recolor(f);
}
}
pub fn rebuild_lod(&mut self, id: u32) {
let levels = self.chains[id as usize].clone();
if levels.len() <= 1 {
return;
}
let mut cur = self.entries[levels[0] as usize].clone();
for &e in &levels[1..] {
cur = cur.downsample();
self.entries[e as usize] = cur.clone();
}
}
#[must_use]
pub fn len(&self) -> usize {
self.chains.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.chains.is_empty()
}
}
impl SpriteModel {
pub fn recolor(&mut self, f: impl Fn(u32) -> u32) {
for c in &mut self.colors {
*c = f(*c);
}
}
pub fn set_voxel(&mut self, x: u32, y: u32, z: u32, color: Option<u32>) -> bool {
if x >= self.dims[0] || y >= self.dims[1] || z >= self.dims[2] {
return false;
}
let owpc = self.occ_words_per_col as usize;
let cols = (self.dims[0] * self.dims[1]) as usize;
let col = (x + y * self.dims[0]) as usize;
let base = col * owpc;
let zw = (z >> 5) as usize;
let zb = z & 31;
let mut rank = 0usize;
for w in 0..zw {
rank += self.occupancy[base + w].count_ones() as usize;
}
let below_mask = if zb > 0 { (1u32 << zb) - 1 } else { 0 };
rank += (self.occupancy[base + zw] & below_mask).count_ones() as usize;
let idx = self.color_offsets[col] as usize + rank;
let was_set = (self.occupancy[base + zw] >> zb) & 1 == 1;
if let Some(rgba) = color {
if was_set {
self.colors[idx] = rgba; } else {
self.occupancy[base + zw] |= 1u32 << zb;
self.colors.insert(idx, rgba);
self.dirs.insert(idx, 0);
for c in &mut self.color_offsets[col + 1..=cols] {
*c += 1;
}
}
true
} else {
if !was_set {
return false;
}
self.occupancy[base + zw] &= !(1u32 << zb);
self.colors.remove(idx);
self.dirs.remove(idx);
for c in &mut self.color_offsets[col + 1..=cols] {
*c -= 1;
}
true
}
}
#[must_use]
pub fn bound_radius(&self) -> f32 {
let mut r2 = 0.0_f32;
for &cx in &[0.0, self.dims[0] as f32] {
for &cy in &[0.0, self.dims[1] as f32] {
for &cz in &[0.0, self.dims[2] as f32] {
let d = [cx - self.pivot[0], cy - self.pivot[1], cz - self.pivot[2]];
r2 = r2.max(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
}
}
}
r2.sqrt()
}
#[must_use]
#[allow(clippy::manual_checked_ops)] pub fn downsample(&self) -> SpriteModel {
let [fx, fy, fz] = self.dims;
let fidx = |x: u32, y: u32, z: u32| (x + y * fx + z * fx * fy) as usize;
let mut solid = vec![false; (fx * fy * fz) as usize];
let mut fine = vec![0u32; (fx * fy * fz) as usize];
let mut fine_dir = vec![0u32; (fx * fy * fz) as usize];
for x in 0..fx {
for y in 0..fy {
let col = (x + y * fx) as usize;
let base = col * self.occ_words_per_col as usize;
let off = self.color_offsets[col] as usize;
let mut seen = 0usize;
for z in 0..fz {
let w = base + (z >> 5) as usize;
if (self.occupancy[w] >> (z & 31)) & 1 == 1 {
fine[fidx(x, y, z)] = self.colors[off + seen];
fine_dir[fidx(x, y, z)] = self.dirs[off + seen];
solid[fidx(x, y, z)] = true;
seen += 1;
}
}
}
}
let nx = fx.div_ceil(2).max(1);
let ny = fy.div_ceil(2).max(1);
let nz = fz.div_ceil(2).max(1);
let owpc = nz.div_ceil(32).max(1);
let cols = (nx * ny) as usize;
let mut occupancy = vec![0u32; cols * owpc as usize];
let mut color_offsets = vec![0u32; cols + 1];
let mut colors: Vec<u32> = Vec::new();
let mut dirs: Vec<u32> = Vec::new();
for cy in 0..ny {
for cx in 0..nx {
let ccol = (cx + cy * nx) as usize;
color_offsets[ccol] = colors.len() as u32;
for cz in 0..nz {
let (mut a, mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32, 0u32);
let mut rep_dir = 0u32;
for dz in 0..2 {
for dy in 0..2 {
for dx in 0..2 {
let (x, y, z) = (2 * cx + dx, 2 * cy + dy, 2 * cz + dz);
if x < fx && y < fy && z < fz && solid[fidx(x, y, z)] {
let c = fine[fidx(x, y, z)];
if n == 0 {
rep_dir = fine_dir[fidx(x, y, z)];
}
a += (c >> 24) & 0xff;
r += (c >> 16) & 0xff;
g += (c >> 8) & 0xff;
b += c & 0xff;
n += 1;
}
}
}
}
if n > 0 {
let avg = ((a / n) << 24) | ((r / n) << 16) | ((g / n) << 8) | (b / n);
let base = ccol * owpc as usize + (cz >> 5) as usize;
occupancy[base] |= 1u32 << (cz & 31);
colors.push(avg);
dirs.push(rep_dir);
}
}
}
}
color_offsets[cols] = colors.len() as u32;
SpriteModel {
dims: [nx, ny, nz],
occ_words_per_col: owpc,
pivot: [
self.pivot[0] * 0.5,
self.pivot[1] * 0.5,
self.pivot[2] * 0.5,
],
occupancy,
colors,
dirs,
color_offsets,
voxel_world_size: self.voxel_world_size * 2.0,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct ViewFrustum {
pub pos: [f32; 3],
pub right: [f32; 3],
pub down: [f32; 3],
pub forward: [f32; 3],
pub half_w: f32,
pub half_h: f32,
pub far: f32,
}
#[derive(Clone)]
struct CullInstance {
gpu: SpriteInstanceGpu,
chain_id: u32,
center: [f32; 3],
radius: f32,
colmul: Box<[u64; 256]>,
}
fn identity_colmul() -> Box<[u64; 256]> {
const LANE: u64 = 0x0100;
let w = LANE | (LANE << 16) | (LANE << 32) | (LANE << 48);
Box::new([w; 256])
}
fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn make_cull(registry: &SpriteModelRegistry, i: &SpriteInstance) -> CullInstance {
CullInstance {
gpu: SpriteInstanceGpu {
inv_rot0: i.transform.inv_rot[0],
inv_rot1: i.transform.inv_rot[1],
inv_rot2: i.transform.inv_rot[2],
pos: i.transform.pos,
model_id: i.model_id, },
chain_id: i.model_id,
center: i.transform.pos,
radius: registry.model(i.model_id).bound_radius(),
colmul: identity_colmul(),
}
}
fn instances_buffer(device: &wgpu::Device, cap: u32) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("roxlap-gpu sprite_reg.instances"),
size: u64::from(cap.max(1)) * std::mem::size_of::<SpriteInstanceGpu>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
#[derive(Debug, Clone, Copy)]
pub struct SpriteInstance {
pub model_id: u32,
pub transform: SpriteInstanceTransform,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
struct SpriteModelMeta {
occupancy_offset: u32,
colors_offset: u32,
color_offsets_offset: u32,
occ_words_per_col: u32,
dims: [u32; 3],
_pad0: u32,
pivot: [f32; 3],
voxel_world_size: f32,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
struct SpriteInstanceGpu {
inv_rot0: [f32; 4],
inv_rot1: [f32; 4],
inv_rot2: [f32; 4],
pos: [f32; 3],
model_id: u32,
}
#[must_use]
fn mat3_inverse(cols: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
let [a, b, c] = cols; let cross = |u: [f32; 3], v: [f32; 3]| {
[
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
]
};
let bc = cross(b, c);
let ca = cross(c, a);
let ab = cross(a, b);
let det = a[0] * bc[0] + a[1] * bc[1] + a[2] * bc[2];
let inv_det = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det };
[
[bc[0] * inv_det, ca[0] * inv_det, ab[0] * inv_det],
[bc[1] * inv_det, ca[1] * inv_det, ab[1] * inv_det],
[bc[2] * inv_det, ca[2] * inv_det, ab[2] * inv_det],
]
}
pub struct SpriteRegistryResident {
pub occupancy: wgpu::Buffer,
pub colors: wgpu::Buffer,
pub dirs: wgpu::Buffer,
pub color_offsets: wgpu::Buffer,
pub model_meta: wgpu::Buffer,
pub instances: wgpu::Buffer,
pub instance_capacity: u32,
pub colmul: wgpu::Buffer,
colmul_cap: u32,
pub tile_ranges: wgpu::Buffer,
tile_ranges_cap: u32,
pub tile_instances: wgpu::Buffer,
tile_instances_cap: u32,
cull: Vec<CullInstance>,
chains: Vec<Vec<u32>>,
meta: Vec<SpriteModelMeta>,
colors_alloc: ColorsAllocator,
occ_lens: Vec<u32>,
coloff_lens: Vec<u32>,
occ_used: u32,
occ_cap: u32,
coloff_used: u32,
coloff_cap: u32,
meta_cap: u32,
dead: Vec<bool>,
}
#[derive(Clone, Copy)]
enum ConcatBuf {
Occupancy,
ColorOffsets,
}
fn concat_data(m: &SpriteModel, which: ConcatBuf) -> &[u32] {
match which {
ConcatBuf::Occupancy => &m.occupancy,
ConcatBuf::ColorOffsets => &m.color_offsets,
}
}
impl SpriteRegistryResident {
#[must_use]
pub fn upload(
device: &wgpu::Device,
registry: &SpriteModelRegistry,
instances: &[SpriteInstance],
) -> Self {
let entry_lens: Vec<u32> = registry
.entries
.iter()
.map(|m| m.colors.len() as u32)
.collect();
let colors_alloc = ColorsAllocator::new(&entry_lens);
let cap_total = colors_alloc.cap_total();
let mut all_occ: Vec<u32> = Vec::new();
let mut all_offsets: Vec<u32> = Vec::new();
let mut all_colors: Vec<u32> = vec![0; cap_total as usize];
let mut all_dirs: Vec<u32> = vec![0; cap_total as usize];
let mut meta: Vec<SpriteModelMeta> = Vec::with_capacity(registry.entries.len());
let mut occ_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
let mut coloff_lens: Vec<u32> = Vec::with_capacity(registry.entries.len());
for (e, m) in registry.entries.iter().enumerate() {
let slot = colors_alloc.slot(e);
meta.push(SpriteModelMeta {
occupancy_offset: all_occ.len() as u32,
colors_offset: slot.off,
color_offsets_offset: all_offsets.len() as u32,
occ_words_per_col: m.occ_words_per_col,
dims: m.dims,
_pad0: 0,
pivot: m.pivot,
voxel_world_size: m.voxel_world_size,
});
occ_lens.push(m.occupancy.len() as u32);
coloff_lens.push(m.color_offsets.len() as u32);
all_occ.extend_from_slice(&m.occupancy);
all_offsets.extend_from_slice(&m.color_offsets);
let off = slot.off as usize;
all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
}
let cull: Vec<CullInstance> = instances.iter().map(|i| make_cull(registry, i)).collect();
let seed: Vec<SpriteInstanceGpu> = cull.iter().map(|c| c.gpu).collect();
let instances_buf = {
use wgpu::util::DeviceExt;
let one = [SpriteInstanceGpu::zeroed()];
let src: &[SpriteInstanceGpu] = if seed.is_empty() { &one } else { &seed };
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("roxlap-gpu sprite_reg.instances"),
contents: bytemuck::cast_slice(src),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
})
};
let tile_ranges = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_ranges", 1);
let tile_instances = storage_dst_u32(device, "roxlap-gpu sprite_reg.tile_instances", 1);
let colmul_cap = (cull.len() as u32).max(1) * 256 * 2;
let colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", colmul_cap);
Self {
occupancy: storage_dst_u32_cap(
device,
"roxlap-gpu sprite_reg.occupancy",
&all_occ,
all_occ.len() as u32,
),
colors: storage_dst_u32_cap(
device,
"roxlap-gpu sprite_reg.colors",
&all_colors,
cap_total,
),
dirs: storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total),
color_offsets: storage_dst_u32_cap(
device,
"roxlap-gpu sprite_reg.color_offsets",
&all_offsets,
all_offsets.len() as u32,
),
model_meta: storage_dst_pod(device, "roxlap-gpu sprite_reg.model_meta", &meta),
instances: instances_buf,
instance_capacity: cull.len() as u32,
colmul,
colmul_cap,
tile_ranges,
tile_ranges_cap: 1,
tile_instances,
tile_instances_cap: 1,
cull,
chains: registry.chains.clone(),
occ_used: all_occ.len() as u32,
occ_cap: all_occ.len() as u32,
coloff_used: all_offsets.len() as u32,
coloff_cap: all_offsets.len() as u32,
meta_cap: meta.len() as u32,
dead: vec![false; meta.len()],
meta,
colors_alloc,
occ_lens,
coloff_lens,
}
}
#[must_use]
pub fn instance_count(&self) -> usize {
self.cull.len()
}
pub fn append_instances(
&mut self,
device: &wgpu::Device,
registry: &SpriteModelRegistry,
instances: &[SpriteInstance],
) -> u32 {
let base = self.cull.len() as u32;
if instances.is_empty() {
return base;
}
for i in instances {
debug_assert!(
(i.model_id as usize) < self.chains.len(),
"append_instances: model_id {} not resident (run upload to register new models)",
i.model_id
);
self.cull.push(make_cull(registry, i));
}
let need = self.cull.len() as u32;
if need > self.instance_capacity {
self.instance_capacity = need.next_power_of_two();
self.instances = instances_buffer(device, self.instance_capacity);
}
base
}
pub fn remove_instance(&mut self, index: usize) -> Option<usize> {
if index >= self.cull.len() {
return None;
}
let last = self.cull.len() - 1;
self.cull.swap_remove(index);
(index != last).then_some(last)
}
pub fn set_instance_colmul(&mut self, tables: &[[u64; 256]]) {
for (ci, t) in self.cull.iter_mut().zip(tables) {
ci.colmul.copy_from_slice(t);
}
}
pub fn update_transforms(&mut self, instances: &[SpriteInstance]) {
debug_assert_eq!(
instances.len(),
self.cull.len(),
"update_transforms instance count must match upload"
);
for (ci, inst) in self.cull.iter_mut().zip(instances) {
ci.gpu.inv_rot0 = inst.transform.inv_rot[0];
ci.gpu.inv_rot1 = inst.transform.inv_rot[1];
ci.gpu.inv_rot2 = inst.transform.inv_rot[2];
ci.gpu.pos = inst.transform.pos;
ci.center = inst.transform.pos;
}
}
pub fn update_model(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
registry: &SpriteModelRegistry,
chain_id: u32,
) {
let entries = self.chains[chain_id as usize].clone();
let mut grew = false;
for &e in &entries {
let e = e as usize;
let m = ®istry.entries[e];
debug_assert_eq!(
m.occupancy.len() as u32,
self.occ_lens[e],
"update_model: entry {e} occupancy length changed (dims grew?)"
);
debug_assert_eq!(
m.color_offsets.len() as u32,
self.coloff_lens[e],
"update_model: entry {e} color_offsets length changed (dims grew?)"
);
queue.write_buffer(
&self.occupancy,
u64::from(self.meta[e].occupancy_offset) * 4,
bytemuck::cast_slice(&m.occupancy),
);
queue.write_buffer(
&self.color_offsets,
u64::from(self.meta[e].color_offsets_offset) * 4,
bytemuck::cast_slice(&m.color_offsets),
);
let new_len = m.colors.len() as u32;
match self.colors_alloc.place(e, new_len) {
Some(off) => {
queue.write_buffer(
&self.colors,
u64::from(off) * 4,
bytemuck::cast_slice(&m.colors),
);
queue.write_buffer(
&self.dirs,
u64::from(off) * 4,
bytemuck::cast_slice(&m.dirs),
);
if self.meta[e].colors_offset != off {
self.meta[e].colors_offset = off;
queue.write_buffer(
&self.model_meta,
(e * std::mem::size_of::<SpriteModelMeta>()) as u64,
bytemuck::bytes_of(&self.meta[e]),
);
}
}
None => grew = true,
}
}
if grew {
self.grow_and_repack(device, queue, registry);
}
}
fn grow_and_repack(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
registry: &SpriteModelRegistry,
) {
self.repack_colors_dirs(device, registry);
queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
}
fn repack_colors_dirs(&mut self, device: &wgpu::Device, registry: &SpriteModelRegistry) {
let new_lens: Vec<u32> = registry
.entries
.iter()
.enumerate()
.map(|(e, m)| {
if self.dead[e] {
0
} else {
m.colors.len() as u32
}
})
.collect();
self.colors_alloc.repack(&new_lens);
let cap_total = self.colors_alloc.cap_total();
let mut all_colors = vec![0u32; cap_total as usize];
let mut all_dirs = vec![0u32; cap_total as usize];
for (e, m) in registry.entries.iter().enumerate() {
if self.dead[e] {
self.meta[e].colors_offset = 0;
continue;
}
let off = self.colors_alloc.slot(e).off as usize;
all_colors[off..off + m.colors.len()].copy_from_slice(&m.colors);
all_dirs[off..off + m.dirs.len()].copy_from_slice(&m.dirs);
self.meta[e].colors_offset = off as u32;
}
self.colors = storage_dst_u32_cap(
device,
"roxlap-gpu sprite_reg.colors",
&all_colors,
cap_total,
);
self.dirs = storage_dst_u32_cap(device, "roxlap-gpu sprite_reg.dirs", &all_dirs, cap_total);
eprintln!("roxlap-gpu: sprite registry colors/dirs grew + repacked to {cap_total} words");
}
pub fn add_model(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
registry: &SpriteModelRegistry,
chain_id: u32,
) {
let entries = registry.chains[chain_id as usize].clone();
debug_assert_eq!(
chain_id as usize,
self.chains.len(),
"add_model: chains must be appended in order"
);
let mut need_colors_grow = false;
for &e in &entries {
let e = e as usize;
debug_assert_eq!(
e,
self.meta.len(),
"add_model: entries must be appended in order"
);
let m = ®istry.entries[e];
let occ_off = self.occ_used;
let coloff_off = self.coloff_used;
self.occ_used += m.occupancy.len() as u32;
self.coloff_used += m.color_offsets.len() as u32;
let colors_off = match self.colors_alloc.push(m.colors.len() as u32) {
Some(off) => off,
None => {
need_colors_grow = true;
0 }
};
self.meta.push(SpriteModelMeta {
occupancy_offset: occ_off,
colors_offset: colors_off,
color_offsets_offset: coloff_off,
occ_words_per_col: m.occ_words_per_col,
dims: m.dims,
_pad0: 0,
pivot: m.pivot,
voxel_world_size: m.voxel_world_size,
});
self.occ_lens.push(m.occupancy.len() as u32);
self.coloff_lens.push(m.color_offsets.len() as u32);
self.dead.push(false);
}
self.chains.push(entries.clone());
self.sync_concat(device, queue, registry, &entries, ConcatBuf::Occupancy);
self.sync_concat(device, queue, registry, &entries, ConcatBuf::ColorOffsets);
if need_colors_grow {
self.repack_colors_dirs(device, registry);
} else {
for &e in &entries {
let e = e as usize;
let m = ®istry.entries[e];
let off = u64::from(self.meta[e].colors_offset) * 4;
queue.write_buffer(&self.colors, off, bytemuck::cast_slice(&m.colors));
queue.write_buffer(&self.dirs, off, bytemuck::cast_slice(&m.dirs));
}
}
let count = self.meta.len() as u32;
if count > self.meta_cap {
self.meta_cap = grow_records(count);
self.model_meta = storage_dst_pod_cap(
device,
"roxlap-gpu sprite_reg.model_meta",
&self.meta,
self.meta_cap,
);
} else {
queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
}
}
fn sync_concat(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
registry: &SpriteModelRegistry,
new_entries: &[u32],
which: ConcatBuf,
) {
let (used, cap) = match which {
ConcatBuf::Occupancy => (self.occ_used, self.occ_cap),
ConcatBuf::ColorOffsets => (self.coloff_used, self.coloff_cap),
};
if used > cap {
let new_cap = grow_words(used);
let all: Vec<u32> = registry
.entries
.iter()
.flat_map(|m| concat_data(m, which).iter().copied())
.collect();
let label = match which {
ConcatBuf::Occupancy => "roxlap-gpu sprite_reg.occupancy",
ConcatBuf::ColorOffsets => "roxlap-gpu sprite_reg.color_offsets",
};
let buf = storage_dst_u32_cap(device, label, &all, new_cap);
match which {
ConcatBuf::Occupancy => {
self.occupancy = buf;
self.occ_cap = new_cap;
}
ConcatBuf::ColorOffsets => {
self.color_offsets = buf;
self.coloff_cap = new_cap;
}
}
} else {
let target = match which {
ConcatBuf::Occupancy => &self.occupancy,
ConcatBuf::ColorOffsets => &self.color_offsets,
};
for &e in new_entries {
let e = e as usize;
let off = match which {
ConcatBuf::Occupancy => self.meta[e].occupancy_offset,
ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset,
};
queue.write_buffer(
target,
u64::from(off) * 4,
bytemuck::cast_slice(concat_data(®istry.entries[e], which)),
);
}
}
}
#[must_use]
pub fn dead_model_count(&self) -> usize {
self.chains.iter().filter(|c| c.is_empty()).count()
}
#[must_use]
pub fn live_model_count(&self) -> usize {
self.chains.iter().filter(|c| !c.is_empty()).count()
}
pub fn remove_model(&mut self, chain_id: u32) {
let Some(entries) = self.chains.get(chain_id as usize).cloned() else {
return;
};
if entries.is_empty() {
return; }
for &e in &entries {
let e = e as usize;
self.dead[e] = true;
self.colors_alloc.free(e);
}
self.chains[chain_id as usize] = Vec::new(); }
pub fn compact(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
registry: &SpriteModelRegistry,
) {
self.compact_concat(device, registry, ConcatBuf::Occupancy);
self.compact_concat(device, registry, ConcatBuf::ColorOffsets);
self.repack_colors_dirs(device, registry);
queue.write_buffer(&self.model_meta, 0, bytemuck::cast_slice(&self.meta));
}
fn compact_concat(
&mut self,
device: &wgpu::Device,
registry: &SpriteModelRegistry,
which: ConcatBuf,
) {
let mut all: Vec<u32> = Vec::new();
for e in 0..self.meta.len() {
if self.dead[e] {
match which {
ConcatBuf::Occupancy => self.meta[e].occupancy_offset = 0,
ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = 0,
}
continue;
}
let off = all.len() as u32;
match which {
ConcatBuf::Occupancy => self.meta[e].occupancy_offset = off,
ConcatBuf::ColorOffsets => self.meta[e].color_offsets_offset = off,
}
all.extend_from_slice(concat_data(®istry.entries[e], which));
}
let used = all.len() as u32;
let cap = grow_words(used);
let (label, buf) = match which {
ConcatBuf::Occupancy => ("roxlap-gpu sprite_reg.occupancy", &mut self.occupancy),
ConcatBuf::ColorOffsets => (
"roxlap-gpu sprite_reg.color_offsets",
&mut self.color_offsets,
),
};
*buf = storage_dst_u32_cap(device, label, &all, cap);
match which {
ConcatBuf::Occupancy => {
self.occ_used = used;
self.occ_cap = cap;
}
ConcatBuf::ColorOffsets => {
self.coloff_used = used;
self.coloff_cap = cap;
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn cull_bin_upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
f: &ViewFrustum,
screen_w: u32,
screen_h: u32,
tile_size: u32,
lod_px: f32,
) -> (u32, u32, u32) {
let tiles_x = screen_w.div_ceil(tile_size).max(1);
let tiles_y = screen_h.div_ceil(tile_size).max(1);
let n_tiles = (tiles_x * tiles_y) as usize;
let nw = (1.0 + f.half_w * f.half_w).sqrt();
let nh = (1.0 + f.half_h * f.half_h).sqrt();
let cx = screen_w as f32 * 0.5;
let cy = screen_h as f32 * 0.5;
let px_per_world = cx / f.half_w; let ts = tile_size as f32;
let tx_max = tiles_x as i32 - 1;
let ty_max = tiles_y as i32 - 1;
let mut visible: Vec<SpriteInstanceGpu> = Vec::with_capacity(self.cull.len());
let mut boxes: Vec<[i32; 4]> = Vec::with_capacity(self.cull.len());
let mut visible_colmul: Vec<u32> = Vec::with_capacity(self.cull.len() * 512);
let mut counts = vec![0u32; n_tiles];
for ci in &self.cull {
if self.chains[ci.chain_id as usize].is_empty() {
continue;
}
let rel = [
ci.center[0] - f.pos[0],
ci.center[1] - f.pos[1],
ci.center[2] - f.pos[2],
];
let z = dot3(rel, f.forward);
let r = ci.radius;
if z + r < 0.0 || z - r > f.far {
continue; }
let x = dot3(rel, f.right);
if (x - f.half_w * z) > r * nw || (-x - f.half_w * z) > r * nw {
continue; }
let y = dot3(rel, f.down);
if (y - f.half_h * z) > r * nh || (-y - f.half_h * z) > r * nh {
continue; }
let (tx0, tx1, ty0, ty1) = if z > 1e-3 {
let sx = cx + (x / z) * px_per_world;
let sy = cy + (y / z) * px_per_world;
let sr = (r / z) * px_per_world;
(
(((sx - sr) / ts).floor() as i32).clamp(0, tx_max),
(((sx + sr) / ts).floor() as i32).clamp(0, tx_max),
(((sy - sr) / ts).floor() as i32).clamp(0, ty_max),
(((sy + sr) / ts).floor() as i32).clamp(0, ty_max),
)
} else {
(0, tx_max, 0, ty_max)
};
let chain = &self.chains[ci.chain_id as usize];
let level = if z > 1e-3 && chain.len() > 1 {
let voxel_px = px_per_world / z; ((lod_px / voxel_px).log2().ceil().max(0.0) as usize).min(chain.len() - 1)
} else {
0
};
let mut g = ci.gpu;
g.model_id = chain[level];
visible.push(g);
boxes.push([tx0, tx1, ty0, ty1]);
for &w in ci.colmul.iter() {
visible_colmul.push((w & 0xffff_ffff) as u32);
visible_colmul.push((w >> 32) as u32);
}
for ty in ty0..=ty1 {
for tx in tx0..=tx1 {
counts[(ty * tiles_x as i32 + tx) as usize] += 1;
}
}
}
if visible.is_empty() {
return (0, tiles_x, tiles_y);
}
let mut tile_ranges = vec![0u32; n_tiles * 2];
let mut running = 0u32;
for t in 0..n_tiles {
tile_ranges[2 * t] = running; tile_ranges[2 * t + 1] = counts[t]; running += counts[t];
}
let total = running as usize;
let mut tile_instances = vec![0u32; total.max(1)];
let mut cursor: Vec<u32> = (0..n_tiles).map(|t| tile_ranges[2 * t]).collect();
for (vis_idx, b) in boxes.iter().enumerate() {
for ty in b[2]..=b[3] {
for tx in b[0]..=b[1] {
let t = (ty * tiles_x as i32 + tx) as usize;
tile_instances[cursor[t] as usize] = vis_idx as u32;
cursor[t] += 1;
}
}
}
queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&visible));
let need_ranges = tile_ranges.len() as u32;
if need_ranges > self.tile_ranges_cap {
self.tile_ranges_cap = need_ranges.next_power_of_two();
self.tile_ranges = storage_dst_u32(
device,
"roxlap-gpu sprite_reg.tile_ranges",
self.tile_ranges_cap,
);
}
let need_inst = tile_instances.len() as u32;
if need_inst > self.tile_instances_cap {
self.tile_instances_cap = need_inst.next_power_of_two();
self.tile_instances = storage_dst_u32(
device,
"roxlap-gpu sprite_reg.tile_instances",
self.tile_instances_cap,
);
}
queue.write_buffer(&self.tile_ranges, 0, bytemuck::cast_slice(&tile_ranges));
queue.write_buffer(
&self.tile_instances,
0,
bytemuck::cast_slice(&tile_instances),
);
let need_colmul = visible_colmul.len() as u32;
if need_colmul > self.colmul_cap {
self.colmul_cap = need_colmul.next_power_of_two();
self.colmul = storage_dst_u32(device, "roxlap-gpu sprite_reg.colmul", self.colmul_cap);
}
queue.write_buffer(&self.colmul, 0, bytemuck::cast_slice(&visible_colmul));
(visible.len() as u32, tiles_x, tiles_y)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ColorSlot {
off: u32,
cap: u32,
len: u32,
}
#[derive(Debug, Default)]
struct ColorsAllocator {
slots: Vec<ColorSlot>,
free: Vec<(u32, u32)>,
tail: u32,
cap_total: u32,
}
fn slot_cap(len: u32) -> u32 {
len + len / 4 + 16
}
fn grow_words(used: u32) -> u32 {
used + used / 2 + 256
}
fn grow_records(count: u32) -> u32 {
count + count / 2 + 8
}
impl ColorsAllocator {
fn new(entry_lens: &[u32]) -> Self {
let mut a = Self::default();
a.repack(entry_lens);
a
}
fn slot(&self, entry: usize) -> ColorSlot {
self.slots[entry]
}
fn cap_total(&self) -> u32 {
self.cap_total
}
fn repack(&mut self, new_lens: &[u32]) {
self.free.clear();
let mut off = 0u32;
let mut slots = Vec::with_capacity(new_lens.len());
for &len in new_lens {
let cap = if len == 0 { 0 } else { slot_cap(len) };
slots.push(ColorSlot { off, cap, len });
off += cap;
}
self.slots = slots;
self.tail = off;
self.cap_total = off + off / 2 + 256;
}
fn place(&mut self, entry: usize, new_len: u32) -> Option<u32> {
let cur = self.slots[entry];
if new_len <= cur.cap {
self.slots[entry] = ColorSlot {
len: new_len,
..cur
};
return Some(cur.off);
}
let old = (cur.off, cur.cap);
if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
let (off, cap) = self.free.remove(i);
self.free.push(old);
self.slots[entry] = ColorSlot {
off,
cap,
len: new_len,
};
return Some(off);
}
let want = slot_cap(new_len);
if self.tail + want <= self.cap_total {
let off = self.tail;
self.tail += want;
self.free.push(old);
self.slots[entry] = ColorSlot {
off,
cap: want,
len: new_len,
};
return Some(off);
}
None
}
fn push(&mut self, new_len: u32) -> Option<u32> {
if let Some(i) = self.free.iter().position(|&(_, c)| c >= new_len) {
let (off, cap) = self.free.remove(i);
self.slots.push(ColorSlot {
off,
cap,
len: new_len,
});
return Some(off);
}
let want = slot_cap(new_len);
if self.tail + want <= self.cap_total {
let off = self.tail;
self.tail += want;
self.slots.push(ColorSlot {
off,
cap: want,
len: new_len,
});
return Some(off);
}
None
}
fn free(&mut self, entry: usize) {
let s = self.slots[entry];
if s.cap > 0 {
self.free.push((s.off, s.cap));
}
self.slots[entry] = ColorSlot {
off: 0,
cap: 0,
len: 0,
};
}
}
#[allow(dead_code)]
fn storage_u32(device: &wgpu::Device, label: &str, data: &[u32]) -> wgpu::Buffer {
use wgpu::util::DeviceExt;
let bytes: &[u8] = if data.is_empty() {
bytemuck::cast_slice(&[0u32])
} else {
bytemuck::cast_slice(data)
};
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytes,
usage: wgpu::BufferUsages::STORAGE,
})
}
fn storage_dst_u32(device: &wgpu::Device, label: &str, cap: u32) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: u64::from(cap.max(1)) * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn storage_dst_u32_cap(device: &wgpu::Device, label: &str, data: &[u32], cap: u32) -> wgpu::Buffer {
let cap = cap.max(data.len() as u32).max(1);
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: u64::from(cap) * 4,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: true,
});
if !data.is_empty() {
buf.slice(..(data.len() as u64 * 4))
.get_mapped_range_mut()
.copy_from_slice(bytemuck::cast_slice(data));
}
buf.unmap();
buf
}
fn storage_dst_pod<T: Pod + Zeroable>(
device: &wgpu::Device,
label: &str,
data: &[T],
) -> wgpu::Buffer {
let one = [T::zeroed()];
let src: &[T] = if data.is_empty() { &one } else { data };
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: std::mem::size_of_val(src) as u64,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: true,
});
buf.slice(..)
.get_mapped_range_mut()
.copy_from_slice(bytemuck::cast_slice(src));
buf.unmap();
buf
}
fn storage_dst_pod_cap<T: Pod + Zeroable>(
device: &wgpu::Device,
label: &str,
data: &[T],
cap: u32,
) -> wgpu::Buffer {
let rec = std::mem::size_of::<T>() as u64;
let cap = u64::from(cap.max(data.len() as u32).max(1));
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: cap * rec,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: true,
});
if !data.is_empty() {
buf.slice(..(data.len() as u64 * rec))
.get_mapped_range_mut()
.copy_from_slice(bytemuck::cast_slice(data));
}
buf.unmap();
buf
}
#[allow(dead_code)]
fn storage_pod<T: Pod + Zeroable>(device: &wgpu::Device, label: &str, data: &[T]) -> wgpu::Buffer {
use wgpu::util::DeviceExt;
let one = [T::zeroed()];
let src: &[T] = if data.is_empty() { &one } else { data };
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: bytemuck::cast_slice(src),
usage: wgpu::BufferUsages::STORAGE,
})
}
#[cfg(test)]
mod tests {
use super::*;
use roxlap_formats::kv6::{Kv6, Voxel};
fn kv6_unsorted() -> Kv6 {
let mk = |z, col| Voxel {
col,
z,
vis: 0,
dir: 0,
};
Kv6 {
xsiz: 2,
ysiz: 1,
zsiz: 8,
xpiv: 0.0,
ypiv: 0.0,
zpiv: 0.0,
voxels: vec![mk(5, 0xAA), mk(1, 0xBB), mk(3, 0xCC)],
xlen: vec![2, 1],
ylen: vec![vec![2], vec![1]],
palette: None,
}
}
#[test]
fn occupancy_bits_set_at_voxel_z() {
let m = build_sprite_model(&kv6_unsorted());
assert_eq!(m.dims, [2, 1, 8]);
assert_eq!(m.occ_words_per_col, 1); assert_eq!(m.occupancy[0], (1 << 1) | (1 << 5));
assert_eq!(m.occupancy[1], 1 << 3);
}
#[test]
fn colors_are_ascending_z_for_rank_lookup() {
let m = build_sprite_model(&kv6_unsorted());
assert_eq!(m.color_offsets, vec![0, 2, 3]);
assert_eq!(&m.colors, &[0xBB, 0xAA, 0xCC]);
}
#[test]
fn identity_basis_inverts_to_identity() {
let inv = mat3_inverse([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
assert_eq!(inv, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
}
#[test]
fn fork_is_independent_of_parent() {
let mut reg = SpriteModelRegistry::new();
let base = reg.add(build_sprite_model(&kv6_unsorted()));
let forked = reg.fork(base);
assert_ne!(base, forked);
reg.model_mut(forked).recolor(|_| 0x11);
assert_eq!(®.model(base).colors, &[0xBB, 0xAA, 0xCC]);
assert_eq!(®.model(forked).colors, &[0x11, 0x11, 0x11]);
}
#[test]
fn registry_gpu_structs_have_expected_sizes() {
assert_eq!(std::mem::size_of::<SpriteModelMeta>(), 48);
assert_eq!(std::mem::size_of::<SpriteInstanceGpu>(), 64);
}
#[test]
fn add_lod_builds_halving_mip_chain() {
let mut reg = SpriteModelRegistry::new();
let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 4);
let m0 = reg.model(id);
assert_eq!(m0.dims, [2, 1, 8]);
assert!((m0.voxel_world_size - 1.0).abs() < 1e-6);
}
fn kv6_from(xsiz: u32, ysiz: u32, zsiz: u32, voxels: &[(u32, u32, u16, u32)]) -> Kv6 {
let mut ylen = vec![vec![0u16; ysiz as usize]; xsiz as usize];
let mut flat = Vec::new();
for x in 0..xsiz {
for y in 0..ysiz {
let mut col: Vec<(u16, u32)> = voxels
.iter()
.filter(|(vx, vy, _, _)| *vx == x && *vy == y)
.map(|(_, _, z, c)| (*z, *c))
.collect();
col.sort_by_key(|(z, _)| *z);
ylen[x as usize][y as usize] = col.len() as u16;
for (z, c) in col {
flat.push(Voxel {
col: c,
z,
vis: 0,
dir: 0,
});
}
}
}
let xlen = ylen
.iter()
.map(|c| c.iter().map(|&v| u32::from(v)).sum())
.collect();
Kv6 {
xsiz,
ysiz,
zsiz,
xpiv: 0.0,
ypiv: 0.0,
zpiv: 0.0,
voxels: flat,
xlen,
ylen,
palette: None,
}
}
fn offsets_consistent(m: &SpriteModel) -> bool {
let cols = (m.dims[0] * m.dims[1]) as usize;
if m.color_offsets.len() != cols + 1 {
return false;
}
for w in m.color_offsets.windows(2) {
if w[1] < w[0] {
return false;
}
}
m.color_offsets[cols] as usize == m.colors.len()
}
#[test]
fn carve_two_layers_keeps_offsets_consistent() {
let kv6 = kv6_from(
3,
2,
8,
&[
(0, 0, 0, 0xA0),
(0, 0, 1, 0xA1),
(0, 0, 5, 0xA5),
(1, 0, 1, 0xB1),
(2, 1, 0, 0xC0),
(2, 1, 3, 0xC3),
],
);
let mut m = build_sprite_model(&kv6);
assert!(offsets_consistent(&m));
for z in 0..2u32 {
for y in 0..m.dims[1] {
for x in 0..m.dims[0] {
m.set_voxel(x, y, z, None);
}
}
assert!(offsets_consistent(&m), "inconsistent after carving z={z}");
let _ = m.downsample();
}
}
#[test]
fn set_voxel_inserts_replaces_and_clears() {
let mut m = build_sprite_model(&kv6_unsorted());
assert!(m.set_voxel(0, 0, 3, Some(0x55)));
assert_eq!(m.occupancy[0], (1 << 1) | (1 << 3) | (1 << 5));
assert_eq!(m.color_offsets, vec![0, 3, 4]);
assert_eq!(&m.colors, &[0xBB, 0x55, 0xAA, 0xCC]);
assert!(m.set_voxel(0, 0, 3, Some(0x66)));
assert_eq!(&m.colors, &[0xBB, 0x66, 0xAA, 0xCC]);
assert_eq!(m.color_offsets, vec![0, 3, 4]);
assert!(m.set_voxel(0, 0, 1, None));
assert_eq!(m.occupancy[0], (1 << 3) | (1 << 5));
assert_eq!(m.color_offsets, vec![0, 2, 3]);
assert_eq!(&m.colors, &[0x66, 0xAA, 0xCC]);
assert!(!m.set_voxel(0, 0, 2, None));
assert!(!m.set_voxel(9, 0, 0, Some(1)));
}
#[test]
fn rebuild_lod_refreshes_coarse_levels_from_mip0() {
let mut reg = SpriteModelRegistry::new();
let id = reg.add_lod(build_sprite_model(&kv6_unsorted()), 3);
reg.model_mut(id).recolor(|_| 0x0000_2000);
reg.rebuild_lod(id);
let lvl1_entry = reg.chains[id as usize][1] as usize;
assert!(reg.entries[lvl1_entry]
.colors
.iter()
.all(|&c| c == 0x0000_2000));
}
fn alloc_invariants(a: &ColorsAllocator, lens: &[u32]) {
let mut prev_end = 0u32;
for (e, &len) in lens.iter().enumerate() {
let s = a.slot(e);
assert_eq!(s.len, len, "slot {e} len");
assert!(s.cap >= s.len, "slot {e} cap >= len");
assert!(s.off >= prev_end, "slot {e} overlaps previous");
assert!(s.off + s.cap <= a.cap_total(), "slot {e} past cap_total");
prev_end = s.off + s.cap;
}
assert!(a.cap_total() >= prev_end, "tail headroom");
}
#[test]
fn allocator_new_lays_out_with_slack_and_headroom() {
let lens = [10u32, 0, 64, 7];
let a = ColorsAllocator::new(&lens);
alloc_invariants(&a, &lens);
assert!(a.slot(2).cap > 64);
assert!(a.cap_total() > a.slot(3).off + a.slot(3).cap);
}
#[test]
fn allocator_place_in_place_when_within_cap() {
let mut a = ColorsAllocator::new(&[10, 20]);
let off0 = a.slot(0).off;
let cap0 = a.slot(0).cap;
assert_eq!(a.place(0, 5), Some(off0));
assert_eq!(a.slot(0).len, 5);
assert_eq!(a.slot(0).cap, cap0);
assert_eq!(a.place(0, cap0), Some(off0));
assert_eq!(a.slot(0).off, off0);
assert!(a.free.is_empty(), "no relocation should free anything");
}
#[test]
fn allocator_place_relocates_to_tail_and_frees_old() {
let mut a = ColorsAllocator::new(&[10, 20]);
let old0 = (a.slot(0).off, a.slot(0).cap);
let tail_before = a.tail;
let new_len = a.slot(0).cap + 5;
let off = a.place(0, new_len).expect("fits in headroom");
assert_eq!(off, tail_before, "relocated to old tail");
assert_eq!(a.slot(0).off, off);
assert_eq!(a.slot(0).len, new_len);
assert!(a.free.contains(&old0), "old slot freed");
}
#[test]
fn allocator_reuses_freed_block_first_fit() {
let mut a = ColorsAllocator::new(&[10, 2]);
let old0 = (a.slot(0).off, a.slot(0).cap);
let _ = a.place(0, a.slot(0).cap + 5).unwrap();
assert!(a.free.contains(&old0));
let new1 = a.slot(1).cap + 1;
assert!(new1 <= old0.1, "freed block big enough");
let off = a.place(1, new1).expect("reuses freed block");
assert_eq!(off, old0.0, "first-fit reused the freed slot offset");
assert!(!a.free.contains(&old0), "freed block consumed");
}
#[test]
fn allocator_signals_grow_then_repack_restores() {
let mut a = ColorsAllocator::new(&[8, 8]);
let huge = a.cap_total() + 100;
assert_eq!(a.place(0, huge), None, "overflow must signal grow");
a.repack(&[huge, 8]);
alloc_invariants(&a, &[huge, 8]);
assert!(a.cap_total() > huge);
assert_eq!(a.place(0, huge), Some(a.slot(0).off));
}
#[test]
fn allocator_carve_loop_keeps_live_windows_disjoint() {
let mut a = ColorsAllocator::new(&[40, 12, 40]);
let mut lens = [40u32, 12, 40];
let walk = [13u32, 30, 60, 18, 9, 80, 80, 25, 200, 7];
let mut grew = false;
for &len in &walk {
lens[1] = len;
if a.place(1, len).is_none() {
grew = true;
a.repack(&lens);
} else {
assert_eq!(a.place(0, 40), Some(a.slot(0).off));
assert_eq!(a.place(2, 40), Some(a.slot(2).off));
}
assert_eq!(a.slot(1).len, len);
let mut wins: Vec<(u32, u32)> =
(0..3).map(|e| (a.slot(e).off, a.slot(e).len)).collect();
wins.sort_by_key(|w| w.0);
for pair in wins.windows(2) {
let (o0, l0) = pair[0];
let (o1, _) = pair[1];
assert!(o0 + l0 <= o1, "live windows overlap: {pair:?}");
}
}
assert!(grew, "the 200-word jump should have forced a repack");
}
fn headless() -> Option<crate::HeadlessGpu> {
match crate::HeadlessGpu::new_blocking(crate::GpuRendererSettings::default()) {
Ok(h) => Some(h),
Err(e) => {
eprintln!("[skip] no GPU adapter reachable: {e}");
None
}
}
}
fn one_model_registry() -> (SpriteModelRegistry, u32) {
let mut reg = SpriteModelRegistry::new();
let id = reg.add(build_sprite_model(&kv6_unsorted()));
(reg, id)
}
fn inst(model_id: u32, pos: [f32; 3]) -> SpriteInstance {
use roxlap_formats::sprite::Sprite;
SpriteInstance {
model_id,
transform: SpriteInstanceTransform::from_sprite(&Sprite::axis_aligned(
kv6_unsorted(),
pos,
)),
}
}
#[test]
fn append_grows_count_and_capacity_pow2() {
let Some(h) = headless() else { return };
let (reg, m) = one_model_registry();
let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
assert_eq!(res.instance_count(), 1);
assert_eq!(res.instance_capacity, 1);
let more: Vec<_> = (1..=4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
let base = res.append_instances(&h.device, ®, &more);
assert_eq!(base, 1, "first appended index follows the seed instance");
assert_eq!(res.instance_count(), 5);
assert_eq!(res.instance_capacity, 8, "power-of-two growth");
let base2 = res.append_instances(&h.device, ®, &[inst(m, [9.0, 0.0, 0.0])]);
assert_eq!(base2, 5);
assert_eq!(res.instance_count(), 6);
assert_eq!(res.instance_capacity, 8, "fits existing capacity, no grow");
}
#[test]
fn append_empty_is_noop() {
let Some(h) = headless() else { return };
let (reg, m) = one_model_registry();
let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(m, [0.0; 3])]);
let base = res.append_instances(&h.device, ®, &[]);
assert_eq!(base, 1);
assert_eq!(res.instance_count(), 1);
assert_eq!(res.instance_capacity, 1);
}
fn read_u32(h: &crate::HeadlessGpu, buf: &wgpu::Buffer, words: u64) -> Vec<u32> {
let bytes = words * 4;
let staging = h.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: bytes,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = h
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
enc.copy_buffer_to_buffer(buf, 0, &staging, 0, bytes);
h.queue.submit(std::iter::once(enc.finish()));
let slice = staging.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
h.device.poll(wgpu::PollType::wait_indefinitely()).ok();
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let out = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
drop(data);
staging.unmap();
out
}
fn kv6_other() -> Kv6 {
let mk = |z, col| Voxel {
col,
z,
vis: 0,
dir: 0,
};
Kv6 {
xsiz: 1,
ysiz: 1,
zsiz: 4,
xpiv: 0.0,
ypiv: 0.0,
zpiv: 0.0,
voxels: vec![mk(0, 0x11), mk(2, 0x22)],
xlen: vec![2],
ylen: vec![vec![2]],
palette: None,
}
}
#[test]
fn add_model_uploads_new_volume_incrementally() {
let Some(h) = headless() else { return };
let mut reg = SpriteModelRegistry::new();
let a = reg.add(build_sprite_model(&kv6_unsorted()));
let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
assert_eq!(res.chains.len(), 1);
let entries_before = res.meta.len();
let b = reg.add(build_sprite_model(&kv6_other()));
res.add_model(&h.device, &h.queue, ®, b);
assert_eq!(res.chains.len(), 2);
assert_eq!(res.meta.len(), entries_before + 1, "one new entry");
let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
let coloff = read_u32(&h, &res.color_offsets, u64::from(res.coloff_cap));
let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
for (e, m) in reg.entries.iter().enumerate() {
let meta = res.meta[e];
let oo = meta.occupancy_offset as usize;
assert_eq!(
&occ[oo..oo + m.occupancy.len()],
&m.occupancy[..],
"occ entry {e}"
);
let co = meta.color_offsets_offset as usize;
assert_eq!(
&coloff[co..co + m.color_offsets.len()],
&m.color_offsets[..],
"color_offsets entry {e}"
);
let cc = meta.colors_offset as usize;
assert_eq!(
&cols[cc..cc + m.colors.len()],
&m.colors[..],
"colors entry {e}"
);
}
let base = res.append_instances(&h.device, ®, &[inst(b, [5.0, 0.0, 0.0])]);
assert_eq!(base, 1);
assert_eq!(res.instance_count(), 2);
}
#[test]
fn add_model_survives_buffer_growth() {
let Some(h) = headless() else { return };
let mut reg = SpriteModelRegistry::new();
let a = reg.add(build_sprite_model(&kv6_unsorted()));
let mut res = SpriteRegistryResident::upload(&h.device, ®, &[inst(a, [0.0; 3])]);
let occ_cap0 = res.occ_cap;
for _ in 0..40 {
let id = reg.add(build_sprite_model(&kv6_other()));
res.add_model(&h.device, &h.queue, ®, id);
}
assert_eq!(res.chains.len(), 41);
assert!(res.occ_cap > occ_cap0, "occupancy buffer grew");
let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
for (e, m) in reg.entries.iter().enumerate() {
let meta = res.meta[e];
let oo = meta.occupancy_offset as usize;
assert_eq!(
&occ[oo..oo + m.occupancy.len()],
&m.occupancy[..],
"occ entry {e}"
);
let cc = meta.colors_offset as usize;
assert_eq!(
&cols[cc..cc + m.colors.len()],
&m.colors[..],
"colors entry {e}"
);
}
}
fn test_frustum() -> ViewFrustum {
ViewFrustum {
pos: [0.0, 0.0, 0.0],
right: [1.0, 0.0, 0.0],
down: [0.0, 1.0, 0.0],
forward: [0.0, 0.0, 1.0],
half_w: 1.0,
half_h: 1.0,
far: 10_000.0,
}
}
#[test]
fn remove_model_tombstones_frees_and_reuses() {
let Some(h) = headless() else { return };
let mut reg = SpriteModelRegistry::new();
let a = reg.add(build_sprite_model(&kv6_unsorted()));
let b = reg.add(build_sprite_model(&kv6_other()));
let mut res = SpriteRegistryResident::upload(
&h.device,
®,
&[inst(a, [0.0; 3]), inst(b, [1.0, 0.0, 0.0])],
);
assert_eq!(res.live_model_count(), 2);
assert_eq!(res.dead_model_count(), 0);
res.remove_model(b);
assert_eq!(res.live_model_count(), 1);
assert_eq!(res.dead_model_count(), 1);
assert_eq!(res.dead.iter().filter(|&&d| d).count(), 1, "one entry dead");
assert!(!res.colors_alloc.free.is_empty(), "B's colour slot freed");
let c = reg.add(build_sprite_model(&kv6_other()));
res.add_model(&h.device, &h.queue, ®, c);
assert_eq!(res.live_model_count(), 2);
let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
for e in [a as usize, c as usize] {
let m = ®.entries[e];
let cc = res.meta[e].colors_offset as usize;
assert_eq!(
&cols[cc..cc + m.colors.len()],
&m.colors[..],
"colors entry {e}"
);
}
let f = test_frustum();
let _ = res.cull_bin_upload(&h.device, &h.queue, &f, 64, 64, 16, 1.0);
}
#[test]
fn compact_reclaims_holes_keeps_ids_stable() {
let Some(h) = headless() else { return };
let mut reg = SpriteModelRegistry::new();
let a = reg.add(build_sprite_model(&kv6_unsorted()));
let b = reg.add(build_sprite_model(&kv6_other()));
let c = reg.add(build_sprite_model(&kv6_other()));
let mut res = SpriteRegistryResident::upload(
&h.device,
®,
&[inst(a, [0.0; 3]), inst(b, [1.0; 3]), inst(c, [2.0; 3])],
);
let occ_used_full = res.occ_used;
res.remove_model(b);
res.compact(&h.device, &h.queue, ®);
let live_occ: u32 = [a, c]
.iter()
.map(|&e| reg.entries[e as usize].occupancy.len() as u32)
.sum();
assert_eq!(res.occ_used, live_occ);
assert!(res.occ_used < occ_used_full, "compaction shrank occupancy");
assert_eq!(res.meta[b as usize].occupancy_offset, 0);
assert_eq!(res.live_model_count(), 2);
assert_eq!(res.dead_model_count(), 1);
let occ = read_u32(&h, &res.occupancy, u64::from(res.occ_cap));
let cols = read_u32(&h, &res.colors, u64::from(res.colors_alloc.cap_total()));
for &e in &[a as usize, c as usize] {
let m = ®.entries[e];
let oo = res.meta[e].occupancy_offset as usize;
assert_eq!(
&occ[oo..oo + m.occupancy.len()],
&m.occupancy[..],
"occ {e}"
);
let cc = res.meta[e].colors_offset as usize;
assert_eq!(&cols[cc..cc + m.colors.len()], &m.colors[..], "cols {e}");
}
assert!(!res.chains[c as usize].is_empty());
assert!(res.chains[b as usize].is_empty());
}
#[test]
fn remove_swap_semantics_and_capacity_retained() {
let Some(h) = headless() else { return };
let (reg, m) = one_model_registry();
let seed: Vec<_> = (0..4).map(|i| inst(m, [i as f32, 0.0, 0.0])).collect();
let mut res = SpriteRegistryResident::upload(&h.device, ®, &seed);
assert_eq!(res.instance_count(), 4);
let cap = res.instance_capacity;
assert_eq!(res.remove_instance(1), Some(3));
assert_eq!(res.instance_count(), 3);
assert_eq!(res.remove_instance(2), None);
assert_eq!(res.instance_count(), 2);
assert_eq!(res.remove_instance(99), None);
assert_eq!(res.instance_count(), 2);
assert_eq!(res.instance_capacity, cap);
}
}