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:
1 parent
a97af44979
commit
cdca1e59f3
17 files changed
+3515
-1
No files matched your search
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user