M0: engine command registry, file formats, view math and CPU renderer

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 17:30:37 +00:00
1 parent a97af44979
commit cdca1e59f3
17 files changed
+3515 -1

No files matched your search

+6
View File
@@ -1,8 +1,14 @@
[package]
name = "solvecraft-render"
description = "SolveCraft view math (orbit camera, projection, picking rays) and a CPU rasterizer for headless snapshots"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solvecraft-geom = { workspace = true }
serde = { workspace = true }
image = { workspace = true }
+259
View File
@@ -0,0 +1,259 @@
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Aabb3, Vec3};
/// Column-major 4×4 matrix.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Mat4(pub [[f64; 4]; 4]);
impl Mat4 {
pub const IDENTITY: Mat4 = Mat4([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]);
pub fn mul(&self, o: &Mat4) -> Mat4 {
let mut r = [[0.0; 4]; 4];
for (c, col) in r.iter_mut().enumerate() {
for (rr, cell) in col.iter_mut().enumerate() {
*cell = (0..4).map(|k| self.0[k][rr] * o.0[c][k]).sum();
}
}
Mat4(r)
}
/// Transform a point; returns (x, y, z, w).
pub fn apply(&self, p: Vec3) -> [f64; 4] {
let v = [p.x, p.y, p.z, 1.0];
let mut out = [0.0; 4];
for (r, o) in out.iter_mut().enumerate() {
*o = (0..4).map(|c| self.0[c][r] * v[c]).sum();
}
out
}
pub fn to_f32(&self) -> [[f32; 4]; 4] {
self.0.map(|c| c.map(|x| x as f32))
}
}
/// Named view orientations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StandardView {
Front,
Back,
Top,
Bottom,
Left,
Right,
Iso,
}
impl StandardView {
pub fn parse(s: &str) -> Option<StandardView> {
Some(match s.to_ascii_lowercase().as_str() {
"front" => StandardView::Front,
"back" => StandardView::Back,
"top" => StandardView::Top,
"bottom" => StandardView::Bottom,
"left" => StandardView::Left,
"right" => StandardView::Right,
"iso" | "home" | "isometric" => StandardView::Iso,
_ => return None,
})
}
/// (yaw, pitch) in radians. Yaw 0 looks along +Y (front view); the iso view looks from the
/// front-right-top corner. Pitch is the elevation above the XY plane.
pub fn angles(self) -> (f64, f64) {
use std::f64::consts::{FRAC_PI_2, PI};
match self {
StandardView::Front => (0.0, 0.0),
StandardView::Back => (PI, 0.0),
StandardView::Right => (-FRAC_PI_2, 0.0),
StandardView::Left => (FRAC_PI_2, 0.0),
StandardView::Top => (0.0, FRAC_PI_2),
StandardView::Bottom => (0.0, -FRAC_PI_2),
StandardView::Iso => (-std::f64::consts::FRAC_PI_4, 0.6154797086703874),
}
}
}
/// Orbit camera around `target` (Z up).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Camera {
pub target: Vec3,
/// Rotation about Z; 0 = looking along +Y (from the front).
pub yaw: f64,
/// Elevation above the XY plane, in (−π/2, π/2].
pub pitch: f64,
/// Distance from target to eye.
pub distance: f64,
/// Vertical field of view (radians); 0 = orthographic.
pub fov: f64,
}
impl Default for Camera {
fn default() -> Self {
let (yaw, pitch) = StandardView::Iso.angles();
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 }
}
}
impl Camera {
/// Direction from the target toward the eye.
pub fn back(&self) -> Vec3 {
let (cp, sp) = (self.pitch.cos(), self.pitch.sin());
Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)
}
pub fn eye(&self) -> Vec3 {
self.target + self.back() * self.distance
}
/// Camera basis: right, up, back (orthonormal).
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
let b = self.back();
let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0);
let up = b.cross(right).normalized().unwrap_or(Vec3::Z);
(right, up, b)
}
pub fn view(&self) -> Mat4 {
let (r, u, b) = self.basis();
let e = self.eye();
Mat4([[r.x, u.x, b.x, 0.0], [r.y, u.y, b.y, 0.0], [r.z, u.z, b.z, 0.0], [-r.dot(e), -u.dot(e), -b.dot(e), 1.0]])
}
/// Half the visible height at the target distance.
pub fn half_height(&self) -> f64 {
if self.fov > 0.0 { self.distance * (self.fov * 0.5).tan() } else { self.distance * 0.4 }
}
/// Near/far planes that keep a scene of radius `radius` around the target visible.
pub fn clip(&self, radius: f64) -> (f64, f64) {
let r = radius.max(1e-3);
if self.fov > 0.0 {
((self.distance - r * 2.0).max(self.distance * 1e-3).max(1e-3), self.distance + r * 2.0)
} else {
(self.distance - r * 4.0 - 1.0, self.distance + r * 4.0 + 1.0)
}
}
/// Projection to clip space (OpenGL/wgpu style, depth 0..1).
pub fn proj(&self, aspect: f64, near: f64, far: f64) -> Mat4 {
let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect } else { 1.0 };
let (n, f) = (near, far.max(near + 1e-6));
if self.fov > 0.0 {
let t = 1.0 / (self.fov * 0.5).tan();
Mat4([[t / aspect, 0.0, 0.0, 0.0], [0.0, t, 0.0, 0.0], [0.0, 0.0, f / (n - f), -1.0], [0.0, 0.0, n * f / (n - f), 0.0]])
} else {
let h = self.half_height();
let w = h * aspect;
Mat4([[1.0 / w, 0.0, 0.0, 0.0], [0.0, 1.0 / h, 0.0, 0.0], [0.0, 0.0, 1.0 / (n - f), 0.0], [0.0, 0.0, n / (n - f), 1.0]])
}
}
pub fn view_proj(&self, aspect: f64, radius: f64) -> Mat4 {
let (n, f) = self.clip(radius);
self.proj(aspect, n, f).mul(&self.view())
}
/// World point → pixel coordinates (x right, y down) and depth (0 near … 1 far).
pub fn to_screen(&self, p: Vec3, w: f64, h: f64, radius: f64) -> Option<(f64, f64, f64)> {
let c = self.view_proj(w / h.max(1.0), radius).apply(p);
if c[3].abs() < 1e-12 {
return None;
}
let (x, y, z) = (c[0] / c[3], c[1] / c[3], c[2] / c[3]);
(c[3] > 0.0).then_some(((x + 1.0) * 0.5 * w, (1.0 - y) * 0.5 * h, z))
}
/// Picking ray through pixel (x, y): origin and unit direction.
pub fn ray(&self, x: f64, y: f64, w: f64, h: f64) -> (Vec3, Vec3) {
let (r, u, b) = self.basis();
let hh = self.half_height();
let aspect = w / h.max(1.0);
let nx = (x / w.max(1.0)) * 2.0 - 1.0;
let ny = 1.0 - (y / h.max(1.0)) * 2.0;
if self.fov > 0.0 {
let d = (r * (nx * hh * aspect) + u * (ny * hh) - b * self.distance).normalized().unwrap_or(-b);
(self.eye(), d)
} else {
let o = self.target + r * (nx * hh * aspect) + u * (ny * hh) + b * (self.distance * 4.0);
(o, -b)
}
}
/// Zoom by `factor` (< 1 zooms in) toward a world point that stays put on screen.
pub fn zoom_at(&mut self, factor: f64, anchor: Option<Vec3>) {
let f = if factor.is_finite() { factor.clamp(0.05, 20.0) } else { 1.0 };
let nd = (self.distance * f).clamp(1e-3, 1e7);
if let Some(a) = anchor {
self.target = a + (self.target - a) * (nd / self.distance);
}
self.distance = nd;
}
/// Pan by a screen delta in pixels for a viewport of height `h`.
pub fn pan(&mut self, dx: f64, dy: f64, h: f64) {
let (r, u, _) = self.basis();
let k = 2.0 * self.half_height() / h.max(1.0);
self.target = self.target - r * (dx * k) + u * (dy * k);
}
/// Orbit by pixel deltas.
pub fn orbit(&mut self, dx: f64, dy: f64) {
let lim = std::f64::consts::FRAC_PI_2 - 1e-4;
self.yaw = (self.yaw - dx * 0.008) % std::f64::consts::TAU;
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
}
pub fn set_view(&mut self, v: StandardView) {
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
let (y, p) = v.angles();
self.yaw = y;
self.pitch = p.clamp(-lim, lim);
}
/// Frame a bounding box.
pub fn fit(&mut self, b: &Aabb3) {
if b.is_empty() {
return;
}
self.target = b.center();
let r = (b.diagonal() * 0.5).max(1.0);
self.distance = if self.fov > 0.0 { r / (self.fov * 0.5).sin() * 1.1 } else { r / 0.4 * 1.15 };
}
pub fn is_valid(&self) -> bool {
self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn front_view_projects_target_to_centre() {
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
c.set_view(StandardView::Front);
let (x, y, z) = c.to_screen(c.target, 800.0, 600.0, 10.0).unwrap();
assert!((x - 400.0).abs() < 1e-6 && (y - 300.0).abs() < 1e-6 && (0.0..=1.0).contains(&z));
// Front view looks along +Y: +X is to the right, +Z up.
let (xr, _, _) = c.to_screen(c.target + Vec3::X, 800.0, 600.0, 10.0).unwrap();
let (_, yu, _) = c.to_screen(c.target + Vec3::Z, 800.0, 600.0, 10.0).unwrap();
assert!(xr > 400.0 && yu < 300.0);
let (o, d) = c.ray(400.0, 300.0, 800.0, 600.0);
assert!(d.dist(Vec3::Y) < 1e-9, "{d:?}");
assert!((o - c.target).cross(d).len() < 1e-6);
}
#[test]
fn top_view_and_perspective() {
let mut c = Camera { distance: 100.0, fov: 0.8, ..Default::default() };
c.set_view(StandardView::Top);
let (xr, _, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
let (_, yu, _) = c.to_screen(Vec3::Y, 800.0, 600.0, 10.0).unwrap();
assert!(xr > 400.0 && yu < 300.0, "{xr} {yu}");
let near = c.to_screen(Vec3::new(0.0, 0.0, 10.0), 800.0, 600.0, 20.0).unwrap().2;
let far = c.to_screen(Vec3::new(0.0, 0.0, -10.0), 800.0, 600.0, 20.0).unwrap().2;
assert!(near < far);
let (o, d) = c.ray(600.0, 300.0, 800.0, 600.0);
let hit = o + d * (o.z / -d.z);
let (sx, sy, _) = c.to_screen(hit, 800.0, 600.0, 20.0).unwrap();
assert!((sx - 600.0).abs() < 1e-6 && (sy - 300.0).abs() < 1e-6);
}
#[test]
fn fit_and_hostile() {
let mut c = Camera::default();
let b = Aabb3 { min: Vec3::ZERO, max: Vec3::new(40.0, 30.0, 20.0) };
c.fit(&b);
assert!(c.target.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-9);
c.zoom_at(f64::NAN, None);
c.orbit(1e9, -1e9);
assert!(c.is_valid());
c.fit(&Aabb3::EMPTY);
assert!(c.is_valid());
}
}
+10
View File
@@ -1 +1,11 @@
//! View math shared by the GPU viewport and headless rendering: an orbit camera with
//! perspective or orthographic projection, screen ↔ world mapping and picking rays, plus a small
//! CPU rasterizer (z-buffer, shaded triangles, depth-tested lines) for snapshots without a GPU.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod camera;
mod raster;
pub use camera::{Camera, Mat4, StandardView};
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, render, render_png};
+234
View File
@@ -0,0 +1,234 @@
//! CPU rasterizer for headless snapshots: z-buffered, smooth-shaded triangles and depth-tested
//! lines, rendered at 2× and downsampled for anti-aliasing.
use std::sync::Arc;
use solvecraft_geom::{Mesh, Vec3};
use crate::Camera;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
fn f(self) -> [f64; 3] {
[self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0]
}
}
pub struct SceneMesh {
pub mesh: Arc<Mesh>,
pub color: Rgb,
}
pub struct SceneLine {
pub points: Vec<Vec3>,
pub color: Rgb,
/// Width in output pixels.
pub width: f64,
/// Draw over everything (sketch geometry being edited).
pub on_top: bool,
}
#[derive(Default)]
pub struct Scene {
pub meshes: Vec<SceneMesh>,
pub lines: Vec<SceneLine>,
/// Background gradient top → bottom.
pub background: Option<(Rgb, Rgb)>,
/// Scene radius for clipping planes.
pub radius: f64,
}
/// RGBA pixels plus depth.
pub struct Canvas {
pub w: usize,
pub h: usize,
pub rgba: Vec<u8>,
depth: Vec<f32>,
}
const SS: usize = 2;
const MAX_DIM: usize = 8192;
impl Canvas {
fn new(w: usize, h: usize) -> Canvas {
Canvas { w, h, rgba: vec![255; w * h * 4], depth: vec![f32::INFINITY; w * h] }
}
fn put(&mut self, x: usize, y: usize, c: [f64; 3], a: f64) {
let i = (y * self.w + x) * 4;
if let Some(px) = self.rgba.get_mut(i..i + 3) {
for (k, v) in px.iter_mut().enumerate() {
let old = *v as f64 / 255.0;
*v = ((old * (1.0 - a) + c[k] * a).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
}
}
fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
let n = if n.dot(view_dir) < 0.0 { -n } else { n };
let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir);
let diff = n.dot(key).max(0.0);
let fill = n.dot(view_dir).max(0.0);
let h = (key + view_dir).normalized().unwrap_or(view_dir);
let spec = n.dot(h).max(0.0).powi(40) * 0.25;
let k = 0.28 + 0.52 * diff + 0.22 * fill;
[(base[0] * k + spec).min(1.0), (base[1] * k + spec).min(1.0), (base[2] * k + spec).min(1.0)]
}
/// Render a scene with the camera into an RGBA canvas of `w × h` pixels.
pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
let (w, h) = (w.clamp(1, MAX_DIM), h.clamp(1, MAX_DIM));
let (sw, sh) = (w * SS, h * SS);
let mut c = Canvas::new(sw, sh);
let (top, bot) = scene.background.unwrap_or((Rgb(236, 240, 245), Rgb(205, 212, 222)));
for y in 0..sh {
let t = y as f64 / sh.max(1) as f64;
let (a, b) = (top.f(), bot.f());
let col = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
for x in 0..sw {
c.put(x, y, col, 1.0);
}
}
let vp = cam.view_proj(sw as f64 / sh as f64, scene.radius.max(1.0));
let project = |p: Vec3| -> Option<[f64; 3]> {
let q = vp.apply(p);
if q[3] <= 1e-9 {
return None;
}
Some([(q[0] / q[3] + 1.0) * 0.5 * sw as f64, (1.0 - q[1] / q[3]) * 0.5 * sh as f64, q[2] / q[3]])
};
let view_dir = cam.back();
for sm in &scene.meshes {
let m = &sm.mesh;
let base = sm.color.f();
let pts: Vec<Option<[f64; 3]>> = m.positions.iter().map(|p| project(*p)).collect();
for t in &m.triangles {
let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else {
continue;
};
let ns: Vec<Vec3> = t.iter().map(|k| m.normals.get(*k as usize).copied().unwrap_or(Vec3::Z)).collect();
let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0));
let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0));
if !(x0 <= x1 && y0 <= y1) {
continue;
}
let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]);
if area.abs() < 1e-12 {
continue;
}
for y in y0 as usize..=y1 as usize {
for x in x0 as usize..=x1 as usize {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let w0 = ((b[0] - px) * (cc[1] - py) - (b[1] - py) * (cc[0] - px)) / area;
let w1 = ((cc[0] - px) * (a[1] - py) - (cc[1] - py) * (a[0] - px)) / area;
let w2 = 1.0 - w0 - w1;
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
continue;
}
let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32;
let i = y * sw + x;
if c.depth.get(i).is_some_and(|d| z < *d) {
if let Some(d) = c.depth.get_mut(i) {
*d = z;
}
let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z);
c.put(x, y, shade(n, view_dir, base), 1.0);
}
}
}
}
}
for l in &scene.lines {
let col = l.color.f();
let half = (l.width * SS as f64 * 0.5).max(0.5);
for seg in l.points.windows(2) {
let (Some(p), Some(q)) = (seg.first(), seg.get(1)) else { continue };
let (Some(a), Some(b)) = (project(*p), project(*q)) else { continue };
let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
let steps = (len.ceil() as usize).clamp(1, 20_000);
for k in 0..=steps {
let t = k as f64 / steps as f64;
let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
let r = half.ceil() as i64;
for dy in -r..=r {
for dx in -r..=r {
let (ix, iy) = (x.floor() as i64 + dx, y.floor() as i64 + dy);
if ix < 0 || iy < 0 || ix >= sw as i64 || iy >= sh as i64 {
continue;
}
let d = ((ix as f64 + 0.5 - x).powi(2) + (iy as f64 + 0.5 - y).powi(2)).sqrt();
let cov = (half + 0.5 - d).clamp(0.0, 1.0);
if cov <= 0.0 {
continue;
}
let i = iy as usize * sw + ix as usize;
let visible = l.on_top || c.depth.get(i).is_some_and(|dd| (z as f32) <= *dd + 2e-4);
if visible {
c.put(ix as usize, iy as usize, col, cov);
}
}
}
}
}
}
// Downsample.
let mut out = Canvas::new(w, h);
for y in 0..h {
for x in 0..w {
let mut acc = [0u32; 4];
for sy in 0..SS {
for sx in 0..SS {
let i = ((y * SS + sy) * sw + x * SS + sx) * 4;
for (k, a) in acc.iter_mut().enumerate() {
*a += c.rgba.get(i + k).copied().unwrap_or(0) as u32;
}
}
}
let o = (y * w + x) * 4;
for (k, a) in acc.iter().enumerate() {
if let Some(v) = out.rgba.get_mut(o + k) {
*v = (*a / (SS * SS) as u32) as u8;
}
}
}
}
out
}
/// Render to PNG bytes.
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option<Vec<u8>> {
let c = render(scene, cam, w, h);
let img = image::RgbaImage::from_raw(c.w as u32, c.h as u32, c.rgba)?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
Some(buf.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::StandardView;
#[test]
fn renders_a_triangle() {
let mesh = Mesh {
positions: vec![Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 0.0, -10.0), Vec3::new(0.0, 0.0, 10.0)],
normals: vec![Vec3::new(0.0, -1.0, 0.0); 3],
triangles: vec![[0, 1, 2]],
tri_face: vec![0],
edges: vec![],
};
let scene =
Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 };
let mut cam = Camera { distance: 50.0, ..Default::default() };
cam.set_view(StandardView::Front);
let c = render(&scene, &cam, 64, 64);
let i = (32 * 64 + 32) * 4;
assert!(c.rgba[i] > c.rgba[i + 1] + 50, "centre pixel is red: {:?}", &c.rgba[i..i + 4]);
assert!(render_png(&scene, &cam, 16, 16).unwrap().starts_with(&[0x89, b'P', b'N', b'G']));
// Hostile sizes are clamped.
assert_eq!(render(&scene, &cam, 0, usize::MAX).w, 1);
}
}