#![forbid(unsafe_code)]
mod cpu;
#[cfg(target_arch = "wasm32")]
mod cpu_blit;
#[cfg(feature = "hud")]
mod cpu_egui;
mod gpu;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use roxlap_core::opticast::OpticastSettings;
use roxlap_core::sky::Sky;
use roxlap_core::sprite::SpriteLighting;
use roxlap_core::Camera;
use roxlap_scene::Scene;
pub use roxlap_formats::kfa::KfaSprite;
pub use roxlap_formats::kv6::Kv6;
pub use roxlap_formats::sprite::Sprite;
pub use roxlap_gpu::{GpuInitError, GpuRendererSettings, PowerPreference};
pub use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
#[cfg(feature = "hud")]
pub use egui;
use crate::cpu::CpuBackend;
use crate::gpu::GpuBackend;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) type DynDisplay = dyn HasDisplayHandle + Send + Sync + 'static;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) type DynWindow = dyn HasWindowHandle + Send + Sync + 'static;
pub struct SpriteInstanceDesc {
pub model: usize,
pub pos: [f32; 3],
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct SpriteModelId(pub(crate) usize);
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct SpriteInstanceId {
slot: u32,
gen: u32,
}
#[derive(Default)]
struct DynInstanceMap {
slots: Vec<(u32, Option<u32>)>,
order: Vec<u32>,
free: Vec<u32>,
}
impl DynInstanceMap {
fn alloc(&mut self, dyn_index: u32) -> SpriteInstanceId {
debug_assert_eq!(self.order.len() as u32, dyn_index);
let slot = self.free.pop().unwrap_or_else(|| {
self.slots.push((0, None));
(self.slots.len() - 1) as u32
});
let gen = self.slots[slot as usize].0;
self.slots[slot as usize].1 = Some(dyn_index);
self.order.push(slot);
SpriteInstanceId { slot, gen }
}
fn dyn_index(&self, id: SpriteInstanceId) -> Option<u32> {
let (gen, idx) = *self.slots.get(id.slot as usize)?;
(gen == id.gen).then_some(idx).flatten()
}
fn remove(&mut self, id: SpriteInstanceId, removed: u32, moved: Option<u32>) {
self.slots[id.slot as usize].1 = None;
self.slots[id.slot as usize].0 += 1; self.free.push(id.slot);
if let Some(last) = moved {
let moved_slot = self.order[last as usize];
self.slots[moved_slot as usize].1 = Some(removed);
self.order[removed as usize] = moved_slot;
}
self.order.pop();
}
}
pub struct SpriteSet {
pub models: Vec<Sprite>,
pub instances: Vec<SpriteInstanceDesc>,
pub carve_model: Option<usize>,
}
pub struct FrameParams<'a> {
pub settings: &'a OpticastSettings,
pub sky_color: u32,
pub sky: Option<&'a Sky>,
pub fog_color: u32,
pub fog_max_scan_dist: i32,
pub treat_z_max_as_air: bool,
pub gpu_mip_scan_dist: f32,
pub gpu_max_outer_steps: u32,
pub gpu_fov_y_rad: f32,
pub sprite_lighting: Option<&'a SpriteLighting<'a>>,
pub side_shades: [i8; 6],
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct PickHit {
pub world: [f32; 3],
pub grid: roxlap_scene::GridId,
pub voxel: glam::IVec3,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Ray {
pub origin: glam::DVec3,
pub dir: glam::DVec3,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Line3 {
pub a: [f64; 3],
pub b: [f64; 3],
pub color: u32,
pub width_px: f32,
pub depth_test: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct ImageId(pub(crate) usize);
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum ImageFacing {
World { u: [f32; 3], v: [f32; 3] },
Billboard { up: [f32; 3] },
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct ImageSprite {
pub image: ImageId,
pub origin: [f32; 3],
pub facing: ImageFacing,
pub size: [f32; 2],
pub tint: u32,
pub alpha_cutoff: f32,
pub depth_test: bool,
pub double_sided: bool,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct QuadDraw {
pub corners: [[f32; 3]; 4],
pub image: ImageId,
pub tint: u32,
pub depth_test: bool,
pub alpha_cutoff: f32,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct ImagePickHit {
pub image: ImageId,
pub uv: [f32; 2],
pub texel: (u32, u32),
pub world: [f32; 3],
pub t: f32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Backend {
Cpu,
Gpu,
}
pub struct RenderOptions {
pub want_gpu: bool,
pub gpu: GpuRendererSettings,
pub clear_sky: u32,
pub cpu_max_grid_vsid: u32,
pub cpu_render_threads: usize,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
want_gpu: false,
gpu: GpuRendererSettings::default(),
clear_sky: 0x0099_b3d9,
cpu_max_grid_vsid: 32 * roxlap_scene::CHUNK_SIZE_XY,
cpu_render_threads: 4,
}
}
}
const PICK_DEPTH_BIAS: f32 = 0.5;
fn v_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn v_add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn v_scale(a: [f32; 3], s: f32) -> [f32; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn v_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn v_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn v_norm(a: [f32; 3]) -> [f32; 3] {
let len = v_dot(a, a).sqrt();
if len < 1e-12 {
a
} else {
v_scale(a, 1.0 / len)
}
}
fn ray_quad_uv(
origin: [f32; 3],
dir: [f32; 3],
corners: &[[f32; 3]; 4],
) -> Option<([f32; 2], f32)> {
let [tl, tr, bl, _br] = *corners;
let ue = v_sub(tr, tl); let ve = v_sub(bl, tl); let n = v_cross(ue, ve);
let denom = v_dot(dir, n);
if denom.abs() < 1e-12 {
return None; }
let t = v_dot(v_sub(tl, origin), n) / denom;
if t <= 1e-6 {
return None; }
let p = v_add(origin, v_scale(dir, t));
let rel = v_sub(p, tl);
let guu = v_dot(ue, ue);
let guv = v_dot(ue, ve);
let gvv = v_dot(ve, ve);
let det = guu * gvv - guv * guv;
if det.abs() < 1e-12 {
return None; }
let wu = v_dot(rel, ue);
let wv = v_dot(rel, ve);
let a = (gvv * wu - guv * wv) / det;
let b = (guu * wv - guv * wu) / det;
if !(0.0..=1.0).contains(&a) || !(0.0..=1.0).contains(&b) {
return None; }
Some(([a, b], t))
}
fn resolve_quad(sprite: &ImageSprite, camera: &Camera) -> Option<QuadDraw> {
let cam_pos = [
camera.pos[0] as f32,
camera.pos[1] as f32,
camera.pos[2] as f32,
];
let cam_fwd = v_norm([
camera.forward[0] as f32,
camera.forward[1] as f32,
camera.forward[2] as f32,
]);
let (u_hat, v_hat) = match sprite.facing {
ImageFacing::World { u, v } => (v_norm(u), v_norm(v)),
ImageFacing::Billboard { up } => {
let mut u_hat = v_norm(v_cross(up, cam_fwd));
if v_dot(u_hat, u_hat) < 1e-12 {
u_hat = v_norm([
camera.right[0] as f32,
camera.right[1] as f32,
camera.right[2] as f32,
]);
}
let mut v_hat = v_norm(v_cross(cam_fwd, u_hat));
if v_dot(v_hat, up) > 0.0 {
v_hat = v_scale(v_hat, -1.0);
}
(u_hat, v_hat)
}
};
let du = v_scale(u_hat, sprite.size[0]);
let dv = v_scale(v_hat, sprite.size[1]);
let tl = sprite.origin;
let tr = v_add(tl, du);
let bl = v_add(tl, dv);
let br = v_add(tr, dv);
if !sprite.double_sided {
if let ImageFacing::World { .. } = sprite.facing {
let normal = v_cross(du, dv);
if v_dot(normal, v_sub(cam_pos, tl)) <= 0.0 {
return None;
}
}
}
Some(QuadDraw {
corners: [tl, tr, bl, br],
image: sprite.image,
tint: sprite.tint,
depth_test: sprite.depth_test,
alpha_cutoff: sprite.alpha_cutoff,
})
}
enum BackendImpl {
Cpu(Box<CpuBackend>),
Gpu(Box<GpuBackend>),
}
pub struct SceneRenderer {
inner: BackendImpl,
dyn_map: DynInstanceMap,
}
impl SceneRenderer {
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
where
W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
{
if opts.want_gpu {
match GpuBackend::new(window.clone(), size, opts) {
Ok(g) => {
return Self {
inner: BackendImpl::Gpu(Box::new(g)),
dyn_map: DynInstanceMap::default(),
};
}
Err(e) => {
eprintln!(
"roxlap-render: GPU init failed ({e}); falling back to the CPU renderer",
);
}
}
}
Self {
inner: BackendImpl::Cpu(Box::new(CpuBackend::new(window, size, opts))),
dyn_map: DynInstanceMap::default(),
}
}
#[cfg(target_arch = "wasm32")]
pub async fn new_from_canvas_async(
canvas: web_sys::HtmlCanvasElement,
size: (u32, u32),
opts: &RenderOptions,
) -> Self {
if opts.want_gpu {
match GpuBackend::new_async(canvas.clone(), size, opts).await {
Ok(g) => {
return Self {
inner: BackendImpl::Gpu(Box::new(g)),
dyn_map: DynInstanceMap::default(),
};
}
Err(e) => {
web_sys::console::warn_1(
&format!("roxlap-render: WebGPU init failed ({e}); using the CPU renderer")
.into(),
);
}
}
}
Self {
inner: BackendImpl::Cpu(Box::new(CpuBackend::new_from_canvas(canvas, size, opts))),
dyn_map: DynInstanceMap::default(),
}
}
#[must_use]
pub fn backend(&self) -> Backend {
match self.inner {
BackendImpl::Cpu(_) => Backend::Cpu,
BackendImpl::Gpu(_) => Backend::Gpu,
}
}
#[must_use]
pub fn adapter_info(&self) -> Option<&str> {
match &self.inner {
BackendImpl::Gpu(g) => Some(g.adapter_info()),
BackendImpl::Cpu(_) => None,
}
}
pub fn set_sky_panorama(&mut self, rgba: &[u8], w: u32, h: u32) {
if let BackendImpl::Gpu(g) = &mut self.inner {
g.set_sky_panorama(rgba, w, h);
}
}
pub fn resize(&mut self, width: u32, height: u32) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.resize(width, height),
BackendImpl::Gpu(g) => g.resize(width, height),
}
}
pub fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.render(scene, camera, frame),
BackendImpl::Gpu(g) => g.render(scene, camera, frame),
}
}
pub fn draw_lines(&mut self, camera: &Camera, lines: &[Line3]) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.draw_lines(camera, lines),
BackendImpl::Gpu(g) => g.draw_lines(camera, lines),
}
}
pub fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
match &mut self.inner {
BackendImpl::Cpu(c) => c.upload_image(rgba, width, height),
BackendImpl::Gpu(g) => g.upload_image(rgba, width, height),
}
}
pub fn drop_image(&mut self, id: ImageId) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.drop_image(id),
BackendImpl::Gpu(g) => g.drop_image(id),
}
}
pub fn draw_images(&mut self, camera: &Camera, images: &[ImageSprite]) {
if images.is_empty() {
return;
}
let quads: Vec<QuadDraw> = images
.iter()
.filter_map(|s| resolve_quad(s, camera))
.collect();
if quads.is_empty() {
return;
}
match &mut self.inner {
BackendImpl::Cpu(c) => c.draw_images(camera, &quads),
BackendImpl::Gpu(g) => g.draw_images(camera, &quads),
}
}
#[must_use]
pub fn project_point(&self, camera: &Camera, world: [f32; 3]) -> Option<(f32, f32)> {
match &self.inner {
BackendImpl::Cpu(c) => c.project_point(camera, world),
BackendImpl::Gpu(g) => g.project_point(camera, world),
}
}
#[must_use]
pub fn pick_image(
&self,
camera: &Camera,
x: f64,
y: f64,
sprites: &[ImageSprite],
) -> Option<ImagePickHit> {
if sprites.is_empty() {
return None;
}
let dir = self.pixel_ray(camera, x, y)?;
let dir = [dir[0] as f32, dir[1] as f32, dir[2] as f32];
let dir_len = v_dot(dir, dir).sqrt();
if dir_len < 1e-9 {
return None;
}
let origin = [
camera.pos[0] as f32,
camera.pos[1] as f32,
camera.pos[2] as f32,
];
let scene_t = self.pick_depth(x as u32, y as u32);
let mut best: Option<ImagePickHit> = None;
for sprite in sprites {
let Some(q) = resolve_quad(sprite, camera) else {
continue;
};
let Some(([a, b], t)) = ray_quad_uv(origin, dir, &q.corners) else {
continue; };
let d_eucl = t * dir_len;
if best.is_some_and(|cur| d_eucl >= cur.t) {
continue; }
let p = v_add(origin, v_scale(dir, t));
let Some((iw, ih)) = self.image_dims(sprite.image) else {
continue; };
let tx = ((a * iw as f32) as i32).clamp(0, iw as i32 - 1) as u32;
let ty = ((b * ih as f32) as i32).clamp(0, ih as i32 - 1) as u32;
let cutoff_u8 = (sprite.alpha_cutoff.clamp(0.0, 1.0) * 255.0) as u32;
let solid_thresh = cutoff_u8.max(1);
if u32::from(self.image_alpha_at(sprite.image, tx, ty)) < solid_thresh {
continue;
}
if sprite.depth_test {
if let Some(st) = scene_t {
if d_eucl > st + PICK_DEPTH_BIAS {
continue;
}
}
}
best = Some(ImagePickHit {
image: sprite.image,
uv: [a, b],
texel: (tx, ty),
world: p,
t: d_eucl,
});
}
best
}
fn image_dims(&self, id: ImageId) -> Option<(u32, u32)> {
match &self.inner {
BackendImpl::Cpu(c) => c.image_dims(id),
BackendImpl::Gpu(g) => g.image_dims(id),
}
}
fn image_alpha_at(&self, id: ImageId, tx: u32, ty: u32) -> u8 {
match &self.inner {
BackendImpl::Cpu(c) => c.image_alpha_at(id, tx, ty),
BackendImpl::Gpu(g) => g.image_alpha_at(id, tx, ty),
}
}
pub fn set_flip_x(&mut self, flip: bool) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.set_flip_x(flip),
BackendImpl::Gpu(g) => g.set_flip_x(flip),
}
}
pub fn present(&mut self) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.present(),
BackendImpl::Gpu(g) => g.present(),
}
}
#[cfg(feature = "hud")]
pub fn paint_egui(
&mut self,
jobs: &[egui::ClippedPrimitive],
textures: &egui::TexturesDelta,
pixels_per_point: f32,
) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.paint_egui(jobs, textures, pixels_per_point),
BackendImpl::Gpu(g) => g.paint_egui(jobs, textures, pixels_per_point),
}
}
pub fn set_sprites(&mut self, set: &SpriteSet) -> Vec<SpriteModelId> {
match &mut self.inner {
BackendImpl::Cpu(c) => c.set_sprites(set),
BackendImpl::Gpu(g) => g.set_sprites(set),
}
self.dyn_map = DynInstanceMap::default();
(0..set.models.len()).map(SpriteModelId).collect()
}
pub fn refresh_sprite_model(&mut self, model: SpriteModelId, kv6: &Kv6) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.update_sprite_model(model.0, kv6),
BackendImpl::Gpu(g) => g.update_sprite_model(model.0, kv6),
}
}
pub fn add_sprite_instance(&mut self, model: SpriteModelId, pos: [f32; 3]) -> SpriteInstanceId {
let dyn_index = match &mut self.inner {
BackendImpl::Cpu(c) => c.add_dyn_instance(model.0, pos),
BackendImpl::Gpu(g) => g.add_dyn_instance(model.0, pos),
};
self.dyn_map.alloc(dyn_index as u32)
}
pub fn remove_sprite_instance(&mut self, id: SpriteInstanceId) -> bool {
let Some(dyn_index) = self.dyn_map.dyn_index(id) else {
return false;
};
let moved = match &mut self.inner {
BackendImpl::Cpu(c) => c.remove_dyn_instance(dyn_index as usize),
BackendImpl::Gpu(g) => g.remove_dyn_instance(dyn_index as usize),
};
self.dyn_map.remove(id, dyn_index, moved.map(|m| m as u32));
true
}
#[must_use]
pub fn dynamic_sprite_count(&self) -> usize {
self.dyn_map.order.len()
}
pub fn set_kfa_sprites(&mut self, kfas: &mut [KfaSprite]) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.set_kfa_sprites(kfas),
BackendImpl::Gpu(g) => g.set_kfa_sprites(kfas),
}
}
pub fn update_kfa_poses(&mut self, kfas: &mut [KfaSprite]) {
match &mut self.inner {
BackendImpl::Cpu(c) => c.update_kfa_poses(kfas),
BackendImpl::Gpu(g) => g.update_kfa_poses(kfas),
}
}
pub fn carve_active_sprite(&mut self) -> u32 {
match &mut self.inner {
BackendImpl::Cpu(_) => 0,
BackendImpl::Gpu(g) => g.carve_active_sprite(),
}
}
pub fn request_capture(&mut self) {
if let BackendImpl::Cpu(c) = &mut self.inner {
c.request_capture();
}
}
pub fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
match &mut self.inner {
BackendImpl::Cpu(c) => c.take_capture(),
BackendImpl::Gpu(_) => None,
}
}
#[must_use]
pub fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
match &self.inner {
BackendImpl::Cpu(c) => c.pick_depth(x, y),
BackendImpl::Gpu(g) => g.pick_depth(x, y),
}
}
#[must_use]
pub fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
match &self.inner {
BackendImpl::Cpu(c) => c.pixel_ray(camera, x, y),
BackendImpl::Gpu(g) => g.pixel_ray(camera, x, y),
}
}
#[must_use]
pub fn view_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<Ray> {
let d = self.pixel_ray(camera, x, y)?;
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if len < 1e-12 {
return None;
}
Some(Ray {
origin: glam::DVec3::from_array([camera.pos[0], camera.pos[1], camera.pos[2]]),
dir: glam::DVec3::new(d[0] / len, d[1] / len, d[2] / len),
})
}
#[must_use]
pub fn pick(&self, scene: &Scene, camera: &Camera, x: u32, y: u32) -> Option<PickHit> {
let dir = self.pixel_ray(camera, f64::from(x), f64::from(y))?;
let t = f64::from(self.pick_depth(x, y)?);
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
if len < 1e-9 {
return None;
}
let s = t / len; let world = glam::DVec3::new(
camera.pos[0] + dir[0] * s,
camera.pos[1] + dir[1] * s,
camera.pos[2] + dir[2] * s,
);
let (grid, voxel) = scene.resolve_voxel(world, glam::DVec3::from_array(dir))?;
#[allow(clippy::cast_possible_truncation)]
let world_f32 = [world.x as f32, world.y as f32, world.z as f32];
Some(PickHit {
world: world_f32,
grid,
voxel,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dyn_instance_map_survives_swap_removes() {
let mut map = DynInstanceMap::default();
let mut backend: Vec<u32> = Vec::new();
let mut expect: Vec<(SpriteInstanceId, u32)> = Vec::new();
let add = |map: &mut DynInstanceMap,
backend: &mut Vec<u32>,
expect: &mut Vec<(SpriteInstanceId, u32)>,
payload: u32| {
let dyn_index = backend.len() as u32;
backend.push(payload);
let id = map.alloc(dyn_index);
expect.push((id, payload));
};
for p in 0..6 {
add(&mut map, &mut backend, &mut expect, p);
}
for victim_payload in [2u32, 4, 5] {
let pos = expect
.iter()
.position(|&(_, p)| p == victim_payload)
.unwrap();
let (id, _) = expect.remove(pos);
let dyn_index = map.dyn_index(id).expect("live handle resolves");
let last = backend.len() - 1;
backend.swap_remove(dyn_index as usize);
let moved = (dyn_index as usize != last).then_some(last as u32);
map.remove(id, dyn_index, moved);
assert!(map.dyn_index(id).is_none(), "removed handle is stale");
}
for &(id, payload) in &expect {
let idx = map.dyn_index(id).expect("survivor resolves");
assert_eq!(
backend[idx as usize], payload,
"handle addresses its payload"
);
}
assert_eq!(map.order.len(), backend.len());
assert_eq!(backend.len(), expect.len());
}
#[test]
fn options_default_is_cpu_intent() {
let o = RenderOptions::default();
assert!(!o.want_gpu);
assert_eq!(o.clear_sky & 0xFF00_0000, 0, "clear_sky is 0x00RRGGBB");
}
fn cam_looking_y() -> Camera {
Camera {
pos: [0.0, 0.0, 0.0],
right: [1.0, 0.0, 0.0],
down: [0.0, 0.0, 1.0],
forward: [0.0, 1.0, 0.0],
}
}
#[test]
fn world_quad_corner_layout() {
let sprite = ImageSprite {
image: ImageId(0),
origin: [-5.0, 10.0, -5.0],
facing: ImageFacing::World {
u: [1.0, 0.0, 0.0],
v: [0.0, 0.0, 1.0],
},
size: [10.0, 10.0],
tint: 0xFFFF_FFFF,
alpha_cutoff: 0.0,
depth_test: true,
double_sided: true,
};
let q = resolve_quad(&sprite, &cam_looking_y()).expect("front-facing");
assert_eq!(q.corners[0], [-5.0, 10.0, -5.0], "TL = origin");
assert_eq!(q.corners[1], [5.0, 10.0, -5.0], "TR = origin + u·size");
assert_eq!(q.corners[2], [-5.0, 10.0, 5.0], "BL = origin + v·size");
assert_eq!(q.corners[3], [5.0, 10.0, 5.0], "BR = origin + u + v");
}
#[test]
fn world_quad_backface_culls_when_single_sided() {
let sprite = ImageSprite {
image: ImageId(0),
origin: [-5.0, 10.0, -5.0],
facing: ImageFacing::World {
u: [0.0, 0.0, 1.0], v: [1.0, 0.0, 0.0], },
size: [10.0, 10.0],
tint: 0xFFFF_FFFF,
alpha_cutoff: 0.0,
depth_test: true,
double_sided: false,
};
let a = resolve_quad(&sprite, &cam_looking_y()).is_some();
let mut flipped = sprite;
flipped.facing = ImageFacing::World {
u: [1.0, 0.0, 0.0],
v: [0.0, 0.0, 1.0],
};
let b = resolve_quad(&flipped, &cam_looking_y()).is_some();
assert!(a ^ b, "exactly one winding is front-facing");
}
#[test]
fn double_sided_never_culls() {
let mut sprite = ImageSprite {
image: ImageId(0),
origin: [-5.0, 10.0, -5.0],
facing: ImageFacing::World {
u: [0.0, 0.0, 1.0],
v: [1.0, 0.0, 0.0],
},
size: [10.0, 10.0],
tint: 0xFFFF_FFFF,
alpha_cutoff: 0.0,
depth_test: true,
double_sided: true,
};
assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
sprite.facing = ImageFacing::World {
u: [1.0, 0.0, 0.0],
v: [0.0, 0.0, 1.0],
};
assert!(resolve_quad(&sprite, &cam_looking_y()).is_some());
}
#[test]
fn ray_quad_uv_center_and_corners() {
let corners = [
[-5.0, 10.0, -5.0], [5.0, 10.0, -5.0], [-5.0, 10.0, 5.0], [5.0, 10.0, 5.0], ];
let (uv, t) = ray_quad_uv([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).expect("center hit");
assert!(
(uv[0] - 0.5).abs() < 1e-5 && (uv[1] - 0.5).abs() < 1e-5,
"centre → (.5,.5)"
);
assert!((t - 10.0).abs() < 1e-4, "t = plane distance");
let (uv_tl, _) = ray_quad_uv([0.0, 0.0, 0.0], [-4.0, 10.0, -4.0], &corners).unwrap();
assert!(uv_tl[0] < 0.2 && uv_tl[1] < 0.2, "toward TL → small uv");
}
#[test]
fn ray_quad_uv_misses_outside_and_behind() {
let corners = [
[-5.0, 10.0, -5.0],
[5.0, 10.0, -5.0],
[-5.0, 10.0, 5.0],
[5.0, 10.0, 5.0],
];
assert!(ray_quad_uv([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &corners).is_none());
assert!(ray_quad_uv([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], &corners).is_none());
assert!(ray_quad_uv([100.0, 0.0, 0.0], [0.0, 1.0, 0.0], &corners).is_none());
}
#[test]
fn billboard_axes_orthogonal_and_top_toward_up() {
let up = [0.0, 0.0, -1.0];
let sprite = ImageSprite {
image: ImageId(0),
origin: [0.0, 50.0, 0.0],
facing: ImageFacing::Billboard { up },
size: [4.0, 4.0],
tint: 0xFFFF_FFFF,
alpha_cutoff: 0.0,
depth_test: false,
double_sided: false, };
let q = resolve_quad(&sprite, &cam_looking_y()).expect("billboard always faces camera");
let u = v_sub(q.corners[1], q.corners[0]); let v = v_sub(q.corners[2], q.corners[0]); let fwd = [0.0, 1.0, 0.0];
assert!(v_dot(u, fwd).abs() < 1e-5, "u ⟂ view");
assert!(v_dot(v, fwd).abs() < 1e-5, "v ⟂ view");
assert!(v_dot(u, v).abs() < 1e-5, "u ⟂ v");
assert!(
v_dot(v, up) < 0.0,
"rows grow away from `up` (top edge toward up)"
);
}
}