#![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);
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 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,
}
#[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,
}
}
}
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 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,
})
}
enum BackendImpl {
Cpu(Box<CpuBackend>),
Gpu(Box<GpuBackend>),
}
pub struct SceneRenderer {
inner: BackendImpl,
}
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)),
};
}
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))),
}
}
#[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)),
};
}
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))),
}
}
#[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),
}
}
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),
}
(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 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 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,
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,
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,
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 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,
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)"
);
}
}