#[cfg(not(target_arch = "wasm32"))]
use std::num::NonZeroU32;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use roxlap_core::camera_math;
use roxlap_core::dda_sprite::{
draw_sprite_dda_shaded, ClipFlipbook, SpriteDense, SpriteOccluder, SpriteShade,
};
use roxlap_core::kfa_draw::solve_kfa_limbs;
use roxlap_core::render_sky_fill;
use roxlap_core::Camera;
use roxlap_core::{CompositeOccluder, WorldOccluder};
use roxlap_formats::kv6::Kv6;
use roxlap_formats::material::{Material, MaterialTable};
use roxlap_formats::sprite::Sprite;
use roxlap_formats::voxel_clip::{DecodedClip, VoxelFrame};
use roxlap_scene::occluder::SceneOccluder;
use roxlap_scene::render::{render_scene_composed_with_materials, CpuFog};
use roxlap_scene::Scene;
#[cfg(not(target_arch = "wasm32"))]
use crate::{DynDisplay, DynWindow, HasDisplayHandle, HasWindowHandle};
use crate::{
DynSpriteTransform, FrameParams, ImageId, KfaSprite, Line3, QuadDraw, RenderOptions, SpriteSet,
};
fn empty_kv6() -> Kv6 {
Kv6 {
xsiz: 0,
ysiz: 0,
zsiz: 0,
xpiv: 0.0,
ypiv: 0.0,
zpiv: 0.0,
voxels: Vec::new(),
xlen: Vec::new(),
ylen: Vec::new(),
palette: None,
}
}
const NEAR_Z: f32 = 0.0625;
const DEPTH_BIAS: f32 = 0.5;
fn blend_rgb(dst: u32, rgb: u32, alpha: u32) -> u32 {
if alpha >= 255 {
return rgb & 0x00ff_ffff;
}
let ia = 255 - alpha;
let r = (((rgb >> 16) & 0xff) * alpha + ((dst >> 16) & 0xff) * ia) / 255;
let g = (((rgb >> 8) & 0xff) * alpha + ((dst >> 8) & 0xff) * ia) / 255;
let b = ((rgb & 0xff) * alpha + (dst & 0xff) * ia) / 255;
(r << 16) | (g << 8) | b
}
struct CpuImage {
rgba: Vec<u8>,
width: u32,
height: u32,
}
impl CpuImage {
fn sample(&self, u: f32, v: f32) -> (u32, u32, u32, u32) {
let w = self.width.max(1);
let h = self.height.max(1);
let tx = ((u * w as f32) as i32).clamp(0, w as i32 - 1) as u32;
let ty = ((v * h as f32) as i32).clamp(0, h as i32 - 1) as u32;
let idx = ((ty * w + tx) * 4) as usize;
(
u32::from(self.rgba[idx]),
u32::from(self.rgba[idx + 1]),
u32::from(self.rgba[idx + 2]),
u32::from(self.rgba[idx + 3]),
)
}
}
#[derive(Clone, Copy)]
struct ClipVert {
cam: [f32; 3],
uv: [f32; 2],
}
#[derive(Clone, Copy)]
struct ScreenVert {
sx: f32,
sy: f32,
inv_w: f32,
su: f32,
sv: f32,
}
fn clip_near(poly: &[ClipVert]) -> Vec<ClipVert> {
let n = poly.len();
let mut out: Vec<ClipVert> = Vec::with_capacity(n + 1);
for i in 0..n {
let cur = poly[i];
let prev = poly[(i + n - 1) % n];
let cur_in = cur.cam[2] >= NEAR_Z;
let prev_in = prev.cam[2] >= NEAR_Z;
if cur_in != prev_in {
let t = (NEAR_Z - prev.cam[2]) / (cur.cam[2] - prev.cam[2]);
out.push(ClipVert {
cam: [
prev.cam[0] + (cur.cam[0] - prev.cam[0]) * t,
prev.cam[1] + (cur.cam[1] - prev.cam[1]) * t,
NEAR_Z,
],
uv: [
prev.uv[0] + (cur.uv[0] - prev.uv[0]) * t,
prev.uv[1] + (cur.uv[1] - prev.uv[1]) * t,
],
});
}
if cur_in {
out.push(cur);
}
}
out
}
fn project_clip(v: ClipVert, hx: f32, hy: f32, hz: f32) -> ScreenVert {
let inv_w = 1.0 / v.cam[2];
ScreenVert {
sx: hx + v.cam[0] * hz * inv_w,
sy: hy + v.cam[1] * hz * inv_w,
inv_w,
su: v.uv[0] * inv_w,
sv: v.uv[1] * inv_w,
}
}
#[allow(clippy::too_many_arguments)]
fn fill_textured_tri(
fb: &mut [u32],
zb: &[f32],
w: u32,
h: u32,
v0: &ScreenVert,
v1: &ScreenVert,
v2: &ScreenVert,
image: &CpuImage,
tint: u32,
depth_test: bool,
alpha_cutoff: f32,
) {
let cutoff_u8 = (alpha_cutoff.clamp(0.0, 1.0) * 255.0) as u32;
let det = (v1.sx - v0.sx) * (v2.sy - v0.sy) - (v2.sx - v0.sx) * (v1.sy - v0.sy);
if det.abs() < 1e-6 {
return;
}
let inv_det = 1.0 / det;
let (wi, hi) = (w as i32, h as i32);
let minx = v0.sx.min(v1.sx).min(v2.sx).floor().max(0.0) as i32;
let maxx = v0.sx.max(v1.sx).max(v2.sx).ceil().min(wi as f32 - 1.0) as i32;
let miny = v0.sy.min(v1.sy).min(v2.sy).floor().max(0.0) as i32;
let maxy = v0.sy.max(v1.sy).max(v2.sy).ceil().min(hi as f32 - 1.0) as i32;
if minx > maxx || miny > maxy {
return;
}
let tint_a = (tint >> 24) & 0xff;
let tint_r = (tint >> 16) & 0xff;
let tint_g = (tint >> 8) & 0xff;
let tint_b = tint & 0xff;
for py in miny..=maxy {
let fy = py as f32 + 0.5;
for px in minx..=maxx {
let fx = px as f32 + 0.5;
let b0 = ((v1.sy - v2.sy) * (fx - v2.sx) + (v2.sx - v1.sx) * (fy - v2.sy)) * inv_det;
let b1 = ((v2.sy - v0.sy) * (fx - v2.sx) + (v0.sx - v2.sx) * (fy - v2.sy)) * inv_det;
let b2 = 1.0 - b0 - b1;
if b0 < -1e-4 || b1 < -1e-4 || b2 < -1e-4 {
continue;
}
let inv_w = b0 * v0.inv_w + b1 * v1.inv_w + b2 * v2.inv_w;
if inv_w <= 0.0 {
continue;
}
let fwd = 1.0 / inv_w;
let idx = (py as usize) * (w as usize) + (px as usize);
if depth_test && fwd > zb[idx] + DEPTH_BIAS {
continue; }
let u = (b0 * v0.su + b1 * v1.su + b2 * v2.su) * fwd;
let v = (b0 * v0.sv + b1 * v1.sv + b2 * v2.sv) * fwd;
let (tr, tg, tb, ta) = image.sample(u, v);
if ta < cutoff_u8 {
continue; }
let alpha = ta * tint_a / 255;
if alpha == 0 {
continue;
}
let rgb =
((tr * tint_r / 255) << 16) | ((tg * tint_g / 255) << 8) | (tb * tint_b / 255);
fb[idx] = blend_rgb(fb[idx], rgb, alpha);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
type Presenter = softbuffer::Surface<Arc<DynDisplay>, Arc<DynWindow>>;
#[cfg(target_arch = "wasm32")]
type Presenter = crate::cpu_blit::WebGlBlit;
#[must_use]
#[allow(clippy::too_many_arguments)]
pub(crate) fn setcamera_pixel_ray(
right: [f64; 3],
down: [f64; 3],
forward: [f64; 3],
x: f64,
y: f64,
hx: f32,
hy: f32,
hz: f32,
) -> [f64; 3] {
let (a, b, c) = (x - f64::from(hx), y - f64::from(hy), f64::from(hz));
[
a * right[0] + b * down[0] + c * forward[0],
a * right[1] + b * down[1] + c * forward[1],
a * right[2] + b * down[2] + c * forward[2],
]
}
fn downfilter_pixel(fb: &[u32], march_w: usize, lx: usize, ly: usize, s: usize) -> u32 {
let (mut ar, mut ag, mut ab) = (0u32, 0u32, 0u32);
for j in 0..s {
let row = (ly * s + j) * march_w;
for i in 0..s {
let px = fb[row + lx * s + i];
ar += (px >> 16) & 0xff;
ag += (px >> 8) & 0xff;
ab += px & 0xff;
}
}
let n = (s * s) as u32;
let half = n / 2;
(((ar + half) / n) << 16) | (((ag + half) / n) << 8) | ((ab + half) / n)
}
fn dither_offset(mode: crate::DitherMode, x: usize, y: usize) -> f32 {
match mode {
crate::DitherMode::None => 0.5,
crate::DitherMode::Bayer4x4 => {
const B: [u8; 16] = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
(f32::from(B[(y % 4) * 4 + (x % 4)]) + 0.5) / 16.0
}
crate::DitherMode::BlueNoise => {
#[allow(clippy::cast_precision_loss)]
let f = (x as f32) * 0.067_110_56 + (y as f32) * 0.005_837_15;
(52.982_918 * f.fract()).fract()
}
}
}
fn quantize_channel(c: u32, levels: u8, offset: f32) -> u32 {
if levels <= 1 {
return c;
}
let m = f32::from(levels - 1);
#[allow(clippy::cast_precision_loss)]
let scaled = (c as f32 / 255.0) * m;
let q = (scaled + offset).floor().clamp(0.0, m);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let out = (q / m * 255.0).round() as u32;
out
}
fn posterize_pixel(rgb: u32, x: usize, y: usize, cfg: crate::PosterizeConfig) -> u32 {
let off = dither_offset(cfg.dither, x, y);
let r = quantize_channel((rgb >> 16) & 0xff, cfg.levels_r, off);
let g = quantize_channel((rgb >> 8) & 0xff, cfg.levels_g, off);
let b = quantize_channel(rgb & 0xff, cfg.levels_b, off);
(r << 16) | (g << 8) | b
}
#[derive(Clone, Copy, PartialEq)]
enum CpuSrc {
Frame,
Resolve,
Output,
}
pub(crate) struct CpuBackend {
present_target: Presenter,
current_dims: (u32, u32),
render_res: crate::RenderResolution,
ssaa: u32,
resolve: Vec<u32>,
posterize: Option<crate::PosterizeConfig>,
output: Vec<u32>,
zbuffer: Vec<f32>,
last_dims: (u32, u32),
last_hxyz: (f32, f32, f32),
clear_sky: u32,
sprites: Vec<Sprite>,
sprite_models: Vec<usize>,
models: Vec<Sprite>,
dyn_sprites: Vec<Sprite>,
dyn_models: Vec<usize>,
dyn_clip: Vec<Option<(usize, usize)>>,
clip_books: Vec<ClipFlipbook>,
kfa_limbs: Vec<Sprite>,
capture_next: bool,
captured: Option<(Vec<u32>, u32, u32)>,
framebuffer: Vec<u32>,
flip_x: bool,
images: Vec<Option<CpuImage>>,
materials: MaterialTable,
terrain_materials: Vec<(u32, u8)>,
#[cfg(feature = "hud")]
egui_raster: crate::cpu_egui::EguiRaster,
}
impl CpuBackend {
fn assemble(present_target: Presenter, size: (u32, u32), opts: &RenderOptions) -> Self {
let (w, h) = (size.0.max(1), size.1.max(1));
let zbuffer = vec![f32::INFINITY; (w as usize) * (h as usize)];
let framebuffer = vec![opts.clear_sky; (w as usize) * (h as usize)];
Self {
present_target,
current_dims: (w, h),
render_res: crate::RenderResolution::Native,
ssaa: 1,
resolve: Vec::new(),
posterize: None,
output: Vec::new(),
zbuffer,
last_dims: (w, h),
last_hxyz: (0.0, 0.0, 0.0),
clear_sky: opts.clear_sky,
sprites: Vec::new(),
sprite_models: Vec::new(),
models: Vec::new(),
dyn_sprites: Vec::new(),
dyn_models: Vec::new(),
dyn_clip: Vec::new(),
clip_books: Vec::new(),
kfa_limbs: Vec::new(),
capture_next: false,
captured: None,
framebuffer,
flip_x: false,
images: Vec::new(),
materials: MaterialTable::new(),
terrain_materials: Vec::new(),
#[cfg(feature = "hud")]
egui_raster: crate::cpu_egui::EguiRaster::default(),
}
}
pub(crate) fn set_flip_x(&mut self, flip: bool) {
self.flip_x = flip;
}
fn flip_framebuffer(&mut self) {
let w = self.last_dims.0 as usize;
if w == 0 {
return;
}
for row in self.framebuffer.chunks_mut(w) {
row.reverse();
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn new<W>(window: Arc<W>, size: (u32, u32), opts: &RenderOptions) -> Self
where
W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
{
let display: Arc<DynDisplay> = window.clone();
let window: Arc<DynWindow> = window;
let context = softbuffer::Context::new(display).expect("softbuffer: Context::new");
let surface = softbuffer::Surface::new(&context, window).expect("softbuffer: Surface::new");
Self::assemble(surface, size, opts)
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn new_from_canvas(
canvas: web_sys::HtmlCanvasElement,
size: (u32, u32),
opts: &RenderOptions,
) -> Self {
let (w, h) = (size.0.max(1), size.1.max(1));
let blit = crate::cpu_blit::WebGlBlit::new(&canvas, w, h)
.expect("roxlap-render: WebGL2 blit init");
Self::assemble(blit, size, opts)
}
pub(crate) fn request_capture(&mut self) {
self.capture_next = true;
}
pub(crate) fn take_capture(&mut self) -> Option<(Vec<u32>, u32, u32)> {
self.captured.take()
}
pub(crate) fn pixel_ray(&self, camera: &Camera, x: f64, y: f64) -> Option<[f64; 3]> {
let (hx, hy, hz) = self.last_hxyz;
if hz <= 0.0 {
return None;
}
let (lx, ly) = self.window_to_logical_f(x, y);
Some(setcamera_pixel_ray(
camera.right,
camera.down,
camera.forward,
lx,
ly,
hx,
hy,
hz,
))
}
fn window_to_logical_f(&self, x: f64, y: f64) -> (f64, f64) {
let (lw, lh) = self.last_dims;
let (nw, nh) = self.current_dims;
if nw == 0 || nh == 0 || (lw, lh) == (nw, nh) {
return (x, y);
}
(
x * f64::from(lw) / f64::from(nw),
y * f64::from(lh) / f64::from(nh),
)
}
pub(crate) fn pick_depth(&self, x: u32, y: u32) -> Option<f32> {
let (lw, lh) = self.last_dims;
let (nw, nh) = self.current_dims;
let (lx, ly) = if (lw, lh) == (nw, nh) {
(x, y)
} else if nw == 0 || nh == 0 {
return None;
} else {
(
(x * lw / nw).min(lw.saturating_sub(1)),
(y * lh / nh).min(lh.saturating_sub(1)),
)
};
if lx >= lw || ly >= lh {
return None;
}
let t = *self.zbuffer.get((ly * lw + lx) as usize)?;
if t.is_finite() {
Some(t)
} else {
None
}
}
pub(crate) fn set_sprites(&mut self, set: &SpriteSet) {
let mut sprites = Vec::with_capacity(set.instances.len());
let mut sprite_models = Vec::with_capacity(set.instances.len());
for inst in &set.instances {
if let Some(model) = set.models.get(inst.model) {
let mut s = model.clone();
s.p = inst.pos;
sprites.push(s);
sprite_models.push(inst.model);
}
}
self.sprites = sprites;
self.sprite_models = sprite_models;
self.models.clone_from(&set.models);
self.dyn_sprites.clear();
self.dyn_models.clear();
self.dyn_clip.clear();
self.clip_books.clear();
}
pub(crate) fn add_dyn_instance_posed(
&mut self,
model_index: usize,
xf: DynSpriteTransform,
) -> usize {
let idx = self.dyn_sprites.len();
if let Some(model) = self.models.get(model_index) {
let mut s = model.clone();
xf.apply_to(&mut s);
self.dyn_sprites.push(s);
self.dyn_models.push(model_index);
self.dyn_clip.push(None);
}
idx
}
pub(crate) fn set_dyn_instance_transform(&mut self, idx: usize, xf: DynSpriteTransform) {
if let Some(s) = self.dyn_sprites.get_mut(idx) {
xf.apply_to(s);
}
}
pub(crate) fn set_dyn_instance_material(&mut self, idx: usize, material: u8) {
if let Some(s) = self.dyn_sprites.get_mut(idx) {
s.material = material;
}
}
pub(crate) fn set_dyn_instance_alpha(&mut self, idx: usize, alpha_mul: u8) {
if let Some(s) = self.dyn_sprites.get_mut(idx) {
s.alpha_mul = alpha_mul;
}
}
pub(crate) fn set_dyn_instance_tint(&mut self, idx: usize, tint: u32) {
if let Some(s) = self.dyn_sprites.get_mut(idx) {
s.tint = tint & 0x00FF_FFFF;
}
}
pub(crate) fn set_dyn_instance_shadow_flags(
&mut self,
idx: usize,
casts: bool,
receives: bool,
) {
if let Some(s) = self.dyn_sprites.get_mut(idx) {
crate::apply_shadow_flags(&mut s.flags, casts, receives);
}
}
pub(crate) fn set_dyn_instance_lighting(&mut self, idx: usize, mode: crate::BillboardLighting) {
if let Some(s) = self.dyn_sprites.get_mut(idx) {
crate::apply_lighting_flags(&mut s.flags, mode);
}
}
pub(crate) fn add_model(&mut self, kv6: &Kv6) -> usize {
let idx = self.models.len();
self.models
.push(Sprite::axis_aligned(kv6.clone(), [0.0, 0.0, 0.0]));
idx
}
pub(crate) fn add_model_with_materials(
&mut self,
kv6: &Kv6,
material_map: &[(u32, u8)],
) -> usize {
let idx = self.models.len();
let mut s = Sprite::axis_aligned(kv6.clone(), [0.0, 0.0, 0.0]);
s.material_map = material_map.to_vec();
self.models.push(s);
idx
}
pub(crate) fn remove_model(&mut self, host_idx: usize) {
if let Some(t) = self.models.get_mut(host_idx) {
*t = Sprite::axis_aligned(empty_kv6(), [0.0, 0.0, 0.0]);
}
}
#[allow(clippy::unused_self)]
pub(crate) fn compact_models(&mut self) {}
pub(crate) fn define_material(&mut self, id: u8, mat: Material) -> bool {
self.materials.set(id, mat)
}
pub(crate) fn material(&self, id: u8) -> Material {
self.materials.get(id)
}
pub(crate) fn set_terrain_materials(&mut self, map: &[(u32, u8)]) {
self.terrain_materials = map.to_vec();
}
pub(crate) fn remove_dyn_instance(&mut self, idx: usize) -> Option<usize> {
if idx >= self.dyn_sprites.len() {
return None;
}
let last = self.dyn_sprites.len() - 1;
self.dyn_sprites.swap_remove(idx);
self.dyn_models.swap_remove(idx);
self.dyn_clip.swap_remove(idx);
(idx != last).then_some(last)
}
pub(crate) fn add_voxel_clip_with_materials(
&mut self,
clip: &DecodedClip,
material_map: &[(u32, u8)],
) -> usize {
let idx = self.clip_books.len();
self.clip_books
.push(ClipFlipbook::from_decoded_with_materials(
clip,
material_map,
));
idx
}
pub(crate) fn remove_voxel_clip(&mut self, clip_idx: usize) {
if let Some(book) = self.clip_books.get_mut(clip_idx) {
*book = ClipFlipbook::empty();
}
for slot in &mut self.dyn_clip {
if matches!(slot, Some((book, _)) if *book == clip_idx) {
*slot = None;
}
}
}
pub(crate) fn add_clip_instance(&mut self, clip_idx: usize, xf: DynSpriteTransform) -> usize {
let idx = self.dyn_sprites.len();
let mut s = Sprite::axis_aligned(empty_kv6(), [0.0, 0.0, 0.0]);
xf.apply_to(&mut s);
self.dyn_sprites.push(s);
self.dyn_models.push(usize::MAX); self.dyn_clip.push(Some((clip_idx, 0)));
idx
}
pub(crate) fn set_clip_frame(&mut self, idx: usize, frame: usize) {
if let Some(Some((_book, f))) = self.dyn_clip.get_mut(idx) {
*f = frame;
}
}
pub(crate) fn clip_instance_frame(&self, idx: usize) -> Option<usize> {
match self.dyn_clip.get(idx) {
Some(Some((_book, frame))) => Some(*frame),
_ => None,
}
}
pub(crate) fn set_clip_instance_clip(&mut self, idx: usize, new_clip_idx: usize) -> bool {
if !matches!(self.dyn_clip.get(idx), Some(Some(_)))
|| self.clip_books.get(new_clip_idx).is_none()
{
return false;
}
self.dyn_clip[idx] = Some((new_clip_idx, 0));
true
}
pub(crate) fn update_clip_frame(
&mut self,
clip_idx: usize,
frame: usize,
vf: &VoxelFrame,
dims: [u32; 3],
pivot: [f32; 3],
material_map: &[(u32, u8)],
) -> bool {
let dense = SpriteDense::from_voxel_frame_with_materials(vf, dims, pivot, material_map);
self.clip_books
.get_mut(clip_idx)
.is_some_and(|b| b.set_frame(frame, dense))
}
pub(crate) fn update_sprite_model(&mut self, model_index: usize, kv6: &Kv6) {
self.update_sprite_model_with_materials(model_index, kv6, None);
}
pub(crate) fn update_sprite_model_with_materials(
&mut self,
model_index: usize,
kv6: &Kv6,
material_map: Option<&[(u32, u8)]>,
) {
for (s, &m) in self.sprites.iter_mut().zip(&self.sprite_models) {
if m == model_index {
s.kv6 = kv6.clone();
if let Some(map) = material_map {
s.material_map = map.to_vec();
}
}
}
for (s, &m) in self.dyn_sprites.iter_mut().zip(&self.dyn_models) {
if m == model_index {
s.kv6 = kv6.clone();
if let Some(map) = material_map {
s.material_map = map.to_vec();
}
}
}
if let Some(t) = self.models.get_mut(model_index) {
t.kv6 = kv6.clone();
if let Some(map) = material_map {
t.material_map = map.to_vec();
}
}
}
pub(crate) fn set_kfa_sprites(&mut self, kfas: &mut [KfaSprite]) {
self.update_kfa_poses(kfas);
}
pub(crate) fn update_kfa_poses(&mut self, kfas: &mut [KfaSprite]) {
self.kfa_limbs.clear();
for kfa in kfas.iter_mut() {
solve_kfa_limbs(kfa);
self.kfa_limbs.extend(kfa.limbs.iter().cloned());
}
}
pub(crate) fn resize(&mut self, width: u32, height: u32) {
self.current_dims = (width.max(1), height.max(1));
#[cfg(target_arch = "wasm32")]
self.present_target
.resize(self.current_dims.0, self.current_dims.1);
}
pub(crate) fn set_render_resolution(&mut self, res: crate::RenderResolution) {
self.render_res = res;
}
pub(crate) fn set_ssaa(&mut self, factor: u8) {
self.ssaa = u32::from(factor).clamp(1, 4);
}
pub(crate) fn set_posterize(&mut self, cfg: Option<crate::PosterizeConfig>) {
self.posterize = cfg;
}
fn resolve_active(&self) -> bool {
self.ssaa > 1 || self.posterize.is_some()
}
pub(crate) fn render_dims(&self) -> (u32, u32) {
let (lw, lh) = self.logical_dims();
(lw * self.ssaa, lh * self.ssaa)
}
pub(crate) fn logical_dims(&self) -> (u32, u32) {
self.render_res.logical_for(self.current_dims)
}
pub(crate) fn render(&mut self, scene: &mut Scene, camera: &Camera, frame: &FrameParams) {
let (width, height) = self.render_dims();
if width == 0 || height == 0 {
return;
}
let pixel_count = (width as usize) * (height as usize);
self.last_dims = (width, height);
let settings = {
let src = frame.settings;
if (src.xres, src.yres) == (width, height) {
*src
} else {
#[allow(clippy::cast_precision_loss)]
let sx = width as f32 / (src.xres.max(1) as f32);
#[allow(clippy::cast_precision_loss)]
let sy = height as f32 / (src.yres.max(1) as f32);
let mut s = *src;
s.xres = width;
s.yres = height;
s.y_start = 0;
s.y_end = height;
s.hx = src.hx * sx;
s.hy = src.hy * sy;
s.hz = src.hz * sy;
s
}
};
self.last_hxyz = (settings.hx, settings.hy, settings.hz);
if self.zbuffer.len() < pixel_count {
self.zbuffer.resize(pixel_count, f32::INFINITY);
}
let fog = CpuFog {
color: frame.fog_color,
max_scan_dist: frame.fog_max_scan_dist,
side_shades: frame.side_shades,
};
if self.framebuffer.len() < pixel_count {
self.framebuffer.resize(pixel_count, self.clear_sky);
}
let fb = &mut self.framebuffer[..pixel_count];
for px in fb.iter_mut() {
*px = self.clear_sky;
}
for z in &mut self.zbuffer[..pixel_count] {
*z = f32::INFINITY;
}
let mut world_points: Vec<roxlap_core::CpuPointLight> = Vec::new();
let cpu_lights = if let Some(rig) = frame.lights {
let (sun, sun_dir, sun_color, sun_intensity) = match rig.sun {
Some(s) => {
let d = s.direction;
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let to = if len > 1e-6 {
[-d[0] / len, -d[1] / len, -d[2] / len]
} else {
[0.0; 3]
};
(true, to, s.color, s.intensity)
}
None => (false, [0.0; 3], [0.0; 3], 0.0),
};
let sun_casts = rig.sun.is_some_and(|s| s.casts_shadow);
let mut budget = roxlap_gpu::MAX_SHADOW_CASTERS;
if sun && sun_casts {
budget = budget.saturating_sub(1);
}
let mut demoted = 0usize;
let mut grant = |casts: bool| -> bool {
if casts && budget > 0 {
budget -= 1;
true
} else {
if casts {
demoted += 1;
}
false
}
};
for p in rig.points {
let allow = grant(p.casts_shadow);
world_points.push(roxlap_core::CpuPointLight {
pos: p.position,
color: p.color,
intensity: p.intensity,
radius: p.radius,
casts_shadow: allow,
spot_dir: [0.0, 0.0, 1.0],
cos_inner: -1.0,
cos_outer: -1.0,
});
}
for s in rig.spots {
let allow = grant(s.casts_shadow);
world_points.push(roxlap_core::CpuPointLight {
pos: s.position,
color: s.color,
intensity: s.intensity,
radius: s.radius,
casts_shadow: allow,
spot_dir: s.axis(),
cos_inner: s.cos_inner(),
cos_outer: s.cos_outer(),
});
}
if demoted > 0 {
eprintln!(
"roxlap CPU: {demoted} shadow-casting point lights > MAX_SHADOW_CASTERS ({}); demoting the excess to shadowless",
roxlap_gpu::MAX_SHADOW_CASTERS
);
}
roxlap_core::CpuLights {
enabled: true,
sun,
sun_dir,
sun_color,
sun_intensity,
sun_casts_shadow: sun && sun_casts,
points: &world_points,
ambient: rig.ambient,
bands: rig.bands,
shadow_tint: rig.shadow_tint,
shadow_strength: rig.shadow_strength,
shadow_bias: rig.shadow_bias_voxels,
shadow_max_dist: rig.shadow_max_dist,
}
} else {
roxlap_core::CpuLights::default()
};
let shadows_active = cpu_lights.enabled
&& cpu_lights.shadow_strength > 0.0
&& (cpu_lights.sun_casts_shadow || cpu_lights.points.iter().any(|p| p.casts_shadow));
let invis = roxlap_formats::sprite::SPRITE_FLAG_INVISIBLE;
let casts = |s: &Sprite| s.flags & invis == 0 && s.casts_shadow();
let sprite_occ = if shadows_active && frame.draw_sprites {
let mut so = SpriteOccluder::new();
for s in self.sprites.iter().chain(self.kfa_limbs.iter()) {
if casts(s) {
so.push(SpriteDense::from_kv6(&s.kv6), s.p, s.s, s.h, s.f);
}
}
for (i, s) in self.dyn_sprites.iter().enumerate() {
if !casts(s) {
continue;
}
if let Some((book, fr)) = self.dyn_clip[i] {
if let Some(d) = self.clip_books.get(book).and_then(|b| b.frame(fr)) {
so.push(d.clone(), s.p, s.s, s.h, s.f);
}
} else {
so.push(SpriteDense::from_kv6(&s.kv6), s.p, s.s, s.h, s.f);
}
}
(!so.is_empty()).then_some(so)
} else {
None
};
let _ = render_scene_composed_with_materials(
fb,
&mut self.zbuffer[..pixel_count],
width as usize,
width,
height,
fog,
scene,
camera,
&settings,
frame.sky_color,
frame.sky,
Some(&self.materials),
&self.terrain_materials,
cpu_lights,
sprite_occ.as_ref().map(|o| o as &dyn WorldOccluder),
);
if let Some(sky) = frame.sky {
let cam_state =
camera_math::derive(camera, width, height, settings.hx, settings.hy, settings.hz);
render_sky_fill(
fb,
&self.zbuffer[..pixel_count],
width as usize,
width,
height,
&cam_state,
&settings,
sky,
);
}
if frame.draw_sprites
&& (!self.sprites.is_empty()
|| !self.dyn_sprites.is_empty()
|| !self.kfa_limbs.is_empty())
{
let cam_state =
camera_math::derive(camera, width, height, settings.hx, settings.hy, settings.hz);
let materials = &self.materials;
let grid_occ = shadows_active
.then(|| SceneOccluder::build(scene))
.filter(|o| !o.is_empty());
let composite_store;
let recv_occ: Option<&dyn WorldOccluder> =
match (grid_occ.as_ref(), sprite_occ.as_ref()) {
(Some(g), Some(s)) => {
composite_store = CompositeOccluder {
a: g,
b: s as &dyn WorldOccluder,
};
Some(&composite_store)
}
(Some(g), None) => Some(g as &dyn WorldOccluder),
(None, Some(s)) => Some(s as &dyn WorldOccluder),
(None, None) => None,
};
let shade_of = |s: &Sprite| SpriteShade {
materials,
material: s.material,
alpha_mul: s.alpha_mul,
tint: s.tint,
lights: cpu_lights,
shadow: if s.receives_shadow() { recv_occ } else { None },
};
for sprite in self.sprites.iter().chain(self.kfa_limbs.iter()) {
let _written = draw_sprite_dda_shaded(
fb,
&mut self.zbuffer[..pixel_count],
width as usize,
width,
height,
&cam_state,
&settings,
sprite,
Some(shade_of(sprite)),
);
}
for (i, sprite) in self.dyn_sprites.iter().enumerate() {
let zb = &mut self.zbuffer[..pixel_count];
let shade = shade_of(sprite);
if let Some((book, fr)) = self.dyn_clip[i] {
if let Some(b) = self.clip_books.get(book) {
let _written = b.draw_frame_shaded(
fb,
zb,
width as usize,
width,
height,
&cam_state,
&settings,
fr,
sprite.p,
sprite.s,
sprite.h,
sprite.f,
sprite.flags,
Some(shade),
);
}
} else {
let _written = draw_sprite_dda_shaded(
fb,
zb,
width as usize,
width,
height,
&cam_state,
&settings,
sprite,
Some(shade),
);
}
}
}
if self.capture_next {
self.capture_next = false;
self.captured = Some((fb.to_vec(), width, height));
}
}
pub(crate) fn present(&mut self) {
if self.flip_x {
self.flip_framebuffer();
}
let logical = self.logical_dims();
let native = self.current_dims;
let logical_src = self.resolve_scene(logical);
if logical == native {
self.blit_and_present_from(logical_src, native);
} else {
self.upscale_to_output(logical_src, logical, native);
self.blit_and_present_from(CpuSrc::Output, native);
}
}
fn resolve_scene(&mut self, logical: (u32, u32)) -> CpuSrc {
if !self.resolve_active() {
return CpuSrc::Frame;
}
let (lw, lh) = (logical.0 as usize, logical.1 as usize);
let s = self.ssaa as usize;
let (mw, mh) = (lw * s, lh * s);
let lpc = lw * lh;
if self.framebuffer.len() < mw * mh {
return CpuSrc::Frame; }
if self.resolve.len() < lpc {
self.resolve.resize(lpc, self.clear_sky);
}
let post = self.posterize;
let Self {
framebuffer,
resolve,
..
} = self;
for ly in 0..lh {
for lx in 0..lw {
let mut px = downfilter_pixel(framebuffer, mw, lx, ly, s);
if let Some(cfg) = post {
px = posterize_pixel(px, lx, ly, cfg);
}
resolve[ly * lw + lx] = px;
}
}
CpuSrc::Resolve
}
#[allow(clippy::unused_self)]
pub(crate) fn wait_idle(&mut self) {}
pub(crate) fn draw_lines(&mut self, camera: &Camera, lines: &[Line3]) {
let (w, h) = self.last_dims;
let (hx, hy, hz) = self.last_hxyz;
if w == 0 || h == 0 || hz <= 0.0 {
return; }
let pixel_count = (w as usize) * (h as usize);
if self.framebuffer.len() < pixel_count || self.zbuffer.len() < pixel_count {
return;
}
let cam = camera_math::derive(camera, w, h, hx, hy, hz);
let cam_coords = |p: [f32; 3]| -> [f32; 3] {
let d = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]];
[
cam.right[0] * d[0] + cam.right[1] * d[1] + cam.right[2] * d[2],
cam.down[0] * d[0] + cam.down[1] * d[1] + cam.down[2] * d[2],
cam.forward[0] * d[0] + cam.forward[1] * d[1] + cam.forward[2] * d[2],
]
};
let fb = &mut self.framebuffer[..pixel_count];
let zb = &self.zbuffer[..pixel_count];
let (wi, hi) = (w as i32, h as i32);
for line in lines {
let a = [line.a[0] as f32, line.a[1] as f32, line.a[2] as f32];
let b = [line.b[0] as f32, line.b[1] as f32, line.b[2] as f32];
let ca = cam_coords(a);
let cb = cam_coords(b);
let (cza, czb) = (ca[2], cb[2]);
if cza < NEAR_Z && czb < NEAR_Z {
continue;
}
let (mut t0, mut t1) = (0.0f32, 1.0f32);
let dz = czb - cza;
if dz.abs() > f32::EPSILON {
let t_near = (NEAR_Z - cza) / dz;
if dz > 0.0 {
t0 = t0.max(t_near); } else {
t1 = t1.min(t_near); }
}
if t0 > t1 {
continue;
}
let lerp3 = |t: f32| {
[
ca[0] + (cb[0] - ca[0]) * t,
ca[1] + (cb[1] - ca[1]) * t,
ca[2] + (cb[2] - ca[2]) * t,
]
};
let p0 = lerp3(t0);
let p1 = lerp3(t1);
let inv0 = 1.0 / p0[2];
let inv1 = 1.0 / p1[2];
let sx0 = hx + p0[0] * hz * inv0;
let sy0 = hy + p0[1] * hz * inv0;
let sx1 = hx + p1[0] * hz * inv1;
let sy1 = hy + p1[1] * hz * inv1;
let alpha = (line.color >> 24) & 0xff;
if alpha == 0 {
continue; }
let rgb = line.color & 0x00ff_ffff;
let dx = sx1 - sx0;
let dy = sy1 - sy0;
let steps = dx.abs().max(dy.abs()).ceil().max(1.0);
let len = (dx * dx + dy * dy).sqrt().max(1e-6);
let (perp_x, perp_y) = (-dy / len, dx / len);
let half = ((line.width_px - 1.0).max(0.0) * 0.5).round() as i32;
let nsteps = steps as i32;
for s in 0..=nsteps {
let t = s as f32 / steps;
let inv_z = inv0 + (inv1 - inv0) * t;
let depth = 1.0 / inv_z; let cx = sx0 + dx * t;
let cy = sy0 + dy * t;
for woff in -half..=half {
let px = (cx + perp_x * woff as f32).round() as i32;
let py = (cy + perp_y * woff as f32).round() as i32;
if px < 0 || py < 0 || px >= wi || py >= hi {
continue;
}
let idx = (py as usize) * (w as usize) + (px as usize);
if line.depth_test && depth > zb[idx] + DEPTH_BIAS {
continue; }
fb[idx] = blend_rgb(fb[idx], rgb, alpha);
}
}
}
}
pub(crate) fn upload_image(&mut self, rgba: &[u8], width: u32, height: u32) -> ImageId {
if width == 0 || height == 0 || rgba.len() != (width as usize) * (height as usize) * 4 {
return ImageId(0); }
let img = CpuImage {
rgba: rgba.to_vec(),
width,
height,
};
if let Some(slot) = self.images.iter().position(Option::is_none) {
self.images[slot] = Some(img);
ImageId(slot)
} else {
self.images.push(Some(img));
ImageId(self.images.len() - 1)
}
}
pub(crate) fn drop_image(&mut self, id: ImageId) {
if let Some(slot) = self.images.get_mut(id.0) {
*slot = None;
}
}
pub(crate) fn image_dims(&self, id: ImageId) -> Option<(u32, u32)> {
self.images
.get(id.0)
.and_then(Option::as_ref)
.map(|img| (img.width, img.height))
}
pub(crate) fn image_alpha_at(&self, id: ImageId, tx: u32, ty: u32) -> u8 {
let Some(Some(img)) = self.images.get(id.0) else {
return 0;
};
if tx >= img.width || ty >= img.height {
return 0;
}
let idx = ((ty * img.width + tx) * 4 + 3) as usize;
img.rgba.get(idx).copied().unwrap_or(0)
}
pub(crate) fn project_point(&self, camera: &Camera, world: [f32; 3]) -> Option<(f32, f32)> {
let (hx, hy, hz) = self.last_hxyz;
let (w, h) = self.last_dims;
if hz <= 0.0 || w == 0 || h == 0 {
return None;
}
let cam = camera_math::derive(camera, w, h, hx, hy, hz);
let d = [
world[0] - cam.pos[0],
world[1] - cam.pos[1],
world[2] - cam.pos[2],
];
let cz = cam.forward[0] * d[0] + cam.forward[1] * d[1] + cam.forward[2] * d[2];
if cz < NEAR_Z {
return None;
}
let cx = cam.right[0] * d[0] + cam.right[1] * d[1] + cam.right[2] * d[2];
let cy = cam.down[0] * d[0] + cam.down[1] * d[1] + cam.down[2] * d[2];
Some((hx + cx * hz / cz, hy + cy * hz / cz))
}
pub(crate) fn draw_images(&mut self, camera: &Camera, quads: &[QuadDraw]) {
let (w, h) = self.last_dims;
let (hx, hy, hz) = self.last_hxyz;
if w == 0 || h == 0 || hz <= 0.0 {
return; }
let pixel_count = (w as usize) * (h as usize);
if self.framebuffer.len() < pixel_count || self.zbuffer.len() < pixel_count {
return;
}
let cam = camera_math::derive(camera, w, h, hx, hy, hz);
let cam_coords = |p: [f32; 3]| -> [f32; 3] {
let d = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]];
[
cam.right[0] * d[0] + cam.right[1] * d[1] + cam.right[2] * d[2],
cam.down[0] * d[0] + cam.down[1] * d[1] + cam.down[2] * d[2],
cam.forward[0] * d[0] + cam.forward[1] * d[1] + cam.forward[2] * d[2],
]
};
let fb = &mut self.framebuffer[..pixel_count];
let zb = &self.zbuffer[..pixel_count];
for quad in quads {
let Some(Some(image)) = self.images.get(quad.image.0) else {
continue; };
let [tl, tr, bl, br] = quad.corners;
let verts = [
ClipVert {
cam: cam_coords(tl),
uv: [0.0, 0.0],
},
ClipVert {
cam: cam_coords(tr),
uv: [1.0, 0.0],
},
ClipVert {
cam: cam_coords(bl),
uv: [0.0, 1.0],
},
ClipVert {
cam: cam_coords(br),
uv: [1.0, 1.0],
},
];
for tri in [[0usize, 1, 2], [1, 3, 2]] {
let poly = [verts[tri[0]], verts[tri[1]], verts[tri[2]]];
let clipped = clip_near(&poly);
if clipped.len() < 3 {
continue;
}
let screen: Vec<ScreenVert> = clipped
.iter()
.map(|v| project_clip(*v, hx, hy, hz))
.collect();
for i in 1..screen.len() - 1 {
fill_textured_tri(
fb,
zb,
w,
h,
&screen[0],
&screen[i],
&screen[i + 1],
image,
quad.tint,
quad.depth_test,
quad.alpha_cutoff,
);
}
}
}
}
fn src_slice<'a>(
framebuffer: &'a [u32],
resolve: &'a [u32],
output: &'a [u32],
src: CpuSrc,
) -> &'a [u32] {
match src {
CpuSrc::Frame => framebuffer,
CpuSrc::Resolve => resolve,
CpuSrc::Output => output,
}
}
fn upscale_to_output(&mut self, src: CpuSrc, logical: (u32, u32), native: (u32, u32)) {
let (lw, lh) = (logical.0 as usize, logical.1 as usize);
let (nw, nh) = (native.0 as usize, native.1 as usize);
let (npc, lpc) = (nw * nh, lw * lh);
if lw == 0 || lh == 0 || nw == 0 || nh == 0 {
return;
}
if self.output.len() < npc {
self.output.resize(npc, self.clear_sky);
}
let Self {
framebuffer,
resolve,
output,
..
} = self;
let src_buf = Self::src_slice(framebuffer, resolve, &[], src);
if src_buf.len() < lpc {
return;
}
for y in 0..nh {
let sy = (y * lh) / nh;
let src_row = sy * lw;
let dst_row = y * nw;
for x in 0..nw {
let sx = (x * lw) / nw;
output[dst_row + x] = src_buf[src_row + sx];
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn blit_and_present_from(&mut self, src: CpuSrc, dims: (u32, u32)) {
let (width, height) = dims;
let (Some(w_nz), Some(h_nz)) = (NonZeroU32::new(width), NonZeroU32::new(height)) else {
return;
};
let pixel_count = (width as usize) * (height as usize);
let Self {
present_target,
framebuffer,
resolve,
output,
..
} = self;
let src: &[u32] = Self::src_slice(framebuffer, resolve, output, src);
if src.len() < pixel_count {
return;
}
present_target
.resize(w_nz, h_nz)
.expect("softbuffer: resize");
let mut buffer = present_target.buffer_mut().expect("softbuffer: buffer_mut");
buffer[..pixel_count].copy_from_slice(&src[..pixel_count]);
buffer.present().expect("softbuffer: present");
}
#[cfg(target_arch = "wasm32")]
fn blit_and_present_from(&mut self, src: CpuSrc, dims: (u32, u32)) {
let (width, height) = dims;
let pixel_count = (width as usize) * (height as usize);
if width == 0 || height == 0 {
return;
}
self.present_target.resize(width, height);
let Self {
present_target,
framebuffer,
resolve,
output,
..
} = self;
let src: &[u32] = Self::src_slice(framebuffer, resolve, output, src);
if src.len() < pixel_count {
return;
}
present_target.present(&src[..pixel_count]);
}
#[cfg(feature = "hud")]
pub(crate) fn paint_egui(
&mut self,
jobs: &[egui::ClippedPrimitive],
textures: &egui::TexturesDelta,
pixels_per_point: f32,
) {
let logical = self.logical_dims();
let native = self.current_dims;
let lpc = (logical.0 as usize) * (logical.1 as usize);
if self.flip_x {
self.flip_framebuffer();
}
let logical_src = self.resolve_scene(logical);
self.egui_raster
.update_textures(&textures.set, &textures.free);
if logical == native {
let Self {
framebuffer,
resolve,
egui_raster,
..
} = self;
let buf: &mut [u32] = match logical_src {
CpuSrc::Resolve => &mut resolve[..lpc],
_ => &mut framebuffer[..lpc],
};
egui_raster.paint(buf, native.0, native.1, jobs, pixels_per_point);
self.blit_and_present_from(logical_src, native);
} else {
self.upscale_to_output(logical_src, logical, native);
let npc = (native.0 as usize) * (native.1 as usize);
self.egui_raster.paint(
&mut self.output[..npc],
native.0,
native.1,
jobs,
pixels_per_point,
);
self.blit_and_present_from(CpuSrc::Output, native);
}
}
}
#[cfg(test)]
mod posterize_tests {
use super::{posterize_pixel, quantize_channel};
use crate::{DitherMode, PosterizeConfig};
#[test]
fn levels_one_is_identity() {
for c in [0, 1, 127, 128, 200, 255] {
assert_eq!(quantize_channel(c, 1, 0.5), c);
assert_eq!(quantize_channel(c, 0, 0.5), c);
}
}
#[test]
fn two_levels_round_to_nearest() {
assert_eq!(quantize_channel(0, 2, 0.5), 0);
assert_eq!(quantize_channel(127, 2, 0.5), 0);
assert_eq!(quantize_channel(128, 2, 0.5), 255);
assert_eq!(quantize_channel(255, 2, 0.5), 255);
}
#[test]
fn four_levels_palette() {
let p = |c| quantize_channel(c, 4, 0.5);
assert_eq!(p(0), 0);
assert_eq!(p(255), 255);
assert_eq!(p(85), 85);
assert_eq!(p(170), 170);
for c in 0..=255u32 {
assert!(matches!(p(c), 0 | 85 | 170 | 255), "c={c} → {}", p(c));
}
}
#[test]
fn posterize_pixel_per_channel() {
let cfg = PosterizeConfig::uniform(2, DitherMode::None);
assert_eq!(posterize_pixel(0x00_c8_0a_82, 0, 0, cfg), 0x00_ff_00_ff);
}
#[test]
fn dither_varies_by_pixel() {
let cfg = PosterizeConfig::uniform(2, DitherMode::Bayer4x4);
let mut blacks = 0;
let mut whites = 0;
for y in 0..4 {
for x in 0..4 {
match posterize_pixel(0x00_80_80_80, x, y, cfg) & 0xff {
0 => blacks += 1,
255 => whites += 1,
other => panic!("unexpected {other}"),
}
}
}
assert!(blacks > 0 && whites > 0, "blacks={blacks} whites={whites}");
}
}
#[cfg(test)]
mod downfilter_tests {
use super::downfilter_pixel;
#[test]
fn ssaa1_is_identity() {
let fb = [0x00_12_34_56, 0x00_ab_cd_ef, 0x00_00_00_00, 0x00_ff_ff_ff];
for (i, &px) in fb.iter().enumerate() {
assert_eq!(downfilter_pixel(&fb, 2, i % 2, i / 2, 1), px);
}
}
#[test]
fn uniform_block_is_exact() {
let fb = vec![0x00_40_80_c0_u32; 16]; assert_eq!(downfilter_pixel(&fb, 4, 0, 0, 2), 0x00_40_80_c0);
assert_eq!(downfilter_pixel(&fb, 4, 1, 1, 2), 0x00_40_80_c0);
}
#[test]
fn averages_with_rounding() {
let fb = [0x00_00_0a_00, 0x00_00_0a_01, 0x00_00_0a_02, 0x00_02_0a_03];
assert_eq!(downfilter_pixel(&fb, 2, 0, 0, 2), 0x00_01_0a_02);
}
}
#[cfg(test)]
mod cpu_ray_tests {
use super::setcamera_pixel_ray;
const RIGHT: [f64; 3] = [1.0, 0.0, 0.0];
const DOWN: [f64; 3] = [0.0, 1.0, 0.0];
const FWD: [f64; 3] = [0.0, 0.0, 1.0];
#[test]
fn centre_pixel_is_forward() {
let d = setcamera_pixel_ray(RIGHT, DOWN, FWD, 320.0, 240.0, 320.0, 240.0, 320.0);
assert_eq!(d, [0.0, 0.0, 320.0]);
}
#[test]
fn offcentre_pixel_tilts_linearly() {
let d = setcamera_pixel_ray(RIGHT, DOWN, FWD, 384.0, 272.0, 320.0, 240.0, 320.0);
assert_eq!(d, [64.0, 32.0, 320.0]);
}
}
#[cfg(test)]
mod image_raster_tests {
use super::{clip_near, fill_textured_tri, ClipVert, CpuImage, ScreenVert, NEAR_Z};
fn cv(cam: [f32; 3], uv: [f32; 2]) -> ClipVert {
ClipVert { cam, uv }
}
#[test]
fn clip_near_keeps_a_front_triangle() {
let tri = [
cv([0.0, 0.0, 10.0], [0.0, 0.0]),
cv([1.0, 0.0, 10.0], [1.0, 0.0]),
cv([0.0, 1.0, 10.0], [0.0, 1.0]),
];
assert_eq!(clip_near(&tri).len(), 3, "fully in front: unchanged");
}
#[test]
fn clip_near_splits_a_straddling_triangle() {
let tri = [
cv([0.0, 0.0, -1.0], [0.0, 0.0]), cv([1.0, 0.0, 10.0], [1.0, 0.0]),
cv([0.0, 1.0, 10.0], [0.0, 1.0]),
];
let out = clip_near(&tri);
assert_eq!(out.len(), 4, "one-behind triangle clips to a quad");
for v in &out {
assert!(v.cam[2] >= NEAR_Z - 1e-6, "no vertex behind the near plane");
}
}
fn render_quad(depth_test: bool, zb_fill: f32) -> Vec<u32> {
render_quad_cutoff(depth_test, zb_fill, 0.0)
}
fn render_quad_cutoff(depth_test: bool, zb_fill: f32, alpha_cutoff: f32) -> Vec<u32> {
let rgba = vec![
255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 255, 255, 255, 255, 255, 255,
];
let image = CpuImage {
rgba,
width: 2,
height: 2,
};
let (w, h) = (10u32, 10u32);
let mut fb = vec![0u32; (w * h) as usize];
let zb = vec![zb_fill; (w * h) as usize];
let fwd = 10.0f32;
let iw = 1.0 / fwd;
let sv = |sx: f32, sy: f32, u: f32, v: f32| ScreenVert {
sx,
sy,
inv_w: iw,
su: u * iw,
sv: v * iw,
};
let tl = sv(0.0, 0.0, 0.0, 0.0);
let tr = sv(10.0, 0.0, 1.0, 0.0);
let bl = sv(0.0, 10.0, 0.0, 1.0);
let br = sv(10.0, 10.0, 1.0, 1.0);
for tri in [[tl, tr, bl], [tr, br, bl]] {
fill_textured_tri(
&mut fb,
&zb,
w,
h,
&tri[0],
&tri[1],
&tri[2],
&image,
0xFFFF_FFFF,
depth_test,
alpha_cutoff,
);
}
fb
}
#[test]
fn textured_quad_maps_uv_corners() {
let fb = render_quad(false, f32::INFINITY);
let at = |x: u32, y: u32| fb[(y * 10 + x) as usize];
assert_eq!(at(1, 1), 0x00FF_0000, "TL → red");
assert_eq!(at(8, 1), 0x0000_FF00, "TR → green");
assert_eq!(at(1, 8), 0x0000_00FF, "BL → blue");
assert_eq!(at(8, 8), 0x00FF_FFFF, "BR → white");
}
#[test]
fn depth_test_occludes_quad_behind_geometry() {
let fb = render_quad(true, 5.0);
assert!(fb.iter().all(|&p| p == 0), "occluded quad writes nothing");
}
#[test]
fn depth_test_passes_when_in_front() {
let fb = render_quad(true, 100.0);
assert!(fb.iter().any(|&p| p != 0), "unoccluded quad draws");
}
#[test]
fn alpha_cutoff_discards_below_threshold() {
let image = CpuImage {
rgba: vec![255, 255, 255, 100], width: 1,
height: 1,
};
let render = |cutoff: f32| {
let (w, h) = (4u32, 4u32);
let mut fb = vec![0u32; (w * h) as usize];
let zb = vec![f32::INFINITY; (w * h) as usize];
let iw = 0.1f32;
let sv = |sx: f32, sy: f32, u: f32, v: f32| ScreenVert {
sx,
sy,
inv_w: iw,
su: u * iw,
sv: v * iw,
};
let tl = sv(0.0, 0.0, 0.0, 0.0);
let tr = sv(4.0, 0.0, 1.0, 0.0);
let bl = sv(0.0, 4.0, 0.0, 1.0);
let br = sv(4.0, 4.0, 1.0, 1.0);
for tri in [[tl, tr, bl], [tr, br, bl]] {
fill_textured_tri(
&mut fb,
&zb,
w,
h,
&tri[0],
&tri[1],
&tri[2],
&image,
0xFFFF_FFFF,
false,
cutoff,
);
}
fb
};
assert!(
render(0.3).iter().any(|&p| p != 0),
"alpha 100 > cutoff 0.3 draws"
);
assert!(
render(0.5).iter().all(|&p| p == 0),
"alpha 100 < cutoff 0.5 discarded"
);
}
}
#[cfg(test)]
mod blend_tests {
use super::blend_rgb;
#[test]
fn opaque_replaces_destination() {
assert_eq!(blend_rgb(0x00_12_34_56, 0xAA_BB_CC, 255), 0x00_AA_BB_CC);
}
#[test]
fn zero_alpha_keeps_destination() {
assert_eq!(blend_rgb(0x00_12_34_56, 0xAA_BB_CC, 0), 0x00_12_34_56);
}
#[test]
fn half_alpha_is_midpoint() {
let out = blend_rgb(0x00_00_00_00, 0x00_FF_FF_FF, 128);
assert_eq!(out, 0x00_80_80_80);
}
#[test]
fn result_has_no_high_byte() {
assert_eq!(
blend_rgb(0x00_FF_FF_FF, 0xFF_FF_FF_FF, 200) & 0xFF00_0000,
0
);
}
}