diff --git a/CHANGELOG.md b/CHANGELOG.md index 399eb2e6..be4ad403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ - Add a new `TileSizes(Vec)` object representing tile sizes used during rendering. Unlike the previous `Vec`, this data type checks our tile size invariants at construction. It is used in the `struct RenderConfig`. +- Rethink rendering and viewport configuration: + - Add a new `RegionSize` type (with `ImageSize` and + `VoxelSize` aliases), representing a render region. This type is + responsible for the screen-to-world transform + - Add `View2` and `View3` types, which stores the world-to-model + transform (scaling, panning, etc) + - Image rendering uses both `RegionSize` and `ViewX`; this means that we + can now render non-square images! + - Meshing uses just a `View3`, to position the model within the ±1 bounds + - The previous `fidget::shape::Bounds` type is removed + - Remove `fidget::render::render2d/3d` from the public API, as they're + equivalent to the functions on `ImageRenderConfig` / `VoxelRenderConfig` # 0.3.3 - `Function` and evaluator types now produce multiple outputs diff --git a/Cargo.lock b/Cargo.lock index 75e64e90..cafcfaa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,14 +290,6 @@ dependencies = [ "wayland-client", ] -[[package]] -name = "camera" -version = "0.1.0" -dependencies = [ - "nalgebra", - "workspace-hack", -] - [[package]] name = "cast" version = "0.3.0" @@ -914,7 +906,6 @@ name = "fidget-viewer" version = "0.1.0" dependencies = [ "anyhow", - "camera", "clap", "crossbeam-channel", "eframe", diff --git a/Cargo.toml b/Cargo.toml index 0035b40c..91e853f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ members = [ "demos/constraints", "demos/cli", "demos/viewer", - "demos/camera", "workspace-hack", ] exclude = ["demos/web-editor/crate"] diff --git a/demos/camera/Cargo.toml b/demos/camera/Cargo.toml deleted file mode 100644 index 300abd63..00000000 --- a/demos/camera/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "camera" -version = "0.1.0" -edition = "2021" - -[dependencies] -nalgebra.workspace = true -workspace-hack = { version = "0.1", path = "../../workspace-hack" } diff --git a/demos/camera/src/lib.rs b/demos/camera/src/lib.rs deleted file mode 100644 index 4d7659ea..00000000 --- a/demos/camera/src/lib.rs +++ /dev/null @@ -1,212 +0,0 @@ -use nalgebra::Vector2; - -#[derive(Copy, Clone, Debug)] -pub struct Camera2D { - /// Scale to translate between screen and world units - scale: f32, - - /// Offset in world units - offset: Vector2, - - /// Starting position for a drag, in world units - drag_start: Option>, - - /// Size of the viewport in screen units - viewport: Rect, -} - -#[derive(Copy, Clone, Debug)] -pub struct Rect { - pub min: Vector2, - pub max: Vector2, -} - -impl Default for Camera2D { - fn default() -> Self { - Camera2D { - drag_start: None, - scale: 1.0, - offset: Vector2::zeros(), - viewport: Rect { - min: Vector2::new(f32::NAN, f32::NAN), - max: Vector2::new(f32::NAN, f32::NAN), - }, - } - } -} - -impl Camera2D { - /// Builds a new camera with an empty viewport - pub fn new() -> Self { - Self::default() - } - - /// Returns the camera's current offset, in world units - pub fn offset(&self) -> Vector2 { - self.offset - } - - /// Returns the camera's current scale, mapping from screen to world units - pub fn scale(&self) -> f32 { - self.scale - } - - /// Returns the current viewport, in screen units - pub fn viewport(&self) -> Rect { - self.viewport - } - - /// Returns UV coordinates that cover the window - pub fn uv(&self) -> Rect { - let size = self.viewport.max - self.viewport.min; - if size.x > size.y { - let r = (1.0 - (size.y / size.x)) / 2.0; - Rect { - min: Vector2::new(0.0, r), - max: Vector2::new(1.0, 1.0 - r), - } - } else { - let r = (1.0 - (size.x / size.y)) / 2.0; - Rect { - min: Vector2::new(r, 0.0), - max: Vector2::new(1.0 - r, 1.0), - } - } - } - - /// Updates the screen viewport size - pub fn set_viewport(&mut self, viewport: Rect) { - self.viewport = viewport; - } - - /// Converts from mouse position to a UV position within the render window - fn screen_to_world(&self, p: Vector2) -> Vector2 { - let size = self.viewport.max - self.viewport.min; - let out = (p - (self.viewport.min + self.viewport.max) / 2.0) - * self.scale - / size.x.max(size.y); - self.offset + out.component_mul(&Vector2::new(2.0, -2.0)) - } - - /// Updates the camera position when the mouse is held and dragged - /// - /// Returns `true` if the camera has changed - pub fn drag(&mut self, pos: Vector2) -> bool { - if let Some(start) = self.drag_start { - let prev_offset = self.offset; - self.offset = Vector2::zeros(); - let pos = self.screen_to_world(pos); - let new_offset = start - pos; - let changed = prev_offset != new_offset; - self.offset = new_offset; - changed - } else { - let pos = self.screen_to_world(pos); - self.drag_start = Some(pos); - false - } - } - - /// Releases the drag - pub fn release(&mut self) { - self.drag_start = None - } - - /// Updates the camera zoom when the mouse is scrolled - /// - /// If the mouse cursor position is provided (in screen units), then the - /// camera offset is updated to keep the same point under the cursor. - /// - /// Returns `true` if the camera has changed - pub fn scroll(&mut self, pos: Option>, scroll: f32) -> bool { - if scroll != 0.0 { - let new_scale = self.scale / (scroll / 100.0).exp2(); - match pos { - Some(p) => { - let pos_before = self.screen_to_world(p); - self.scale = new_scale; - let pos_after = self.screen_to_world(p); - self.offset += pos_before - pos_after; - } - None => { - self.scale = new_scale; - } - } - true - } else { - false - } - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_drag() { - let mut c = Camera2D::new(); - c.set_viewport(Rect { - min: Vector2::new(0.0, 0.0), - max: Vector2::new(100.0, 100.0), - }); - let p = c.screen_to_world(Vector2::new(0.0, 0.0)); - assert_eq!(p, Vector2::new(-1.0, 1.0)); - let p = c.screen_to_world(Vector2::new(100.0, 0.0)); - assert_eq!(p, Vector2::new(1.0, 1.0)); - let p = c.screen_to_world(Vector2::new(0.0, 100.0)); - assert_eq!(p, Vector2::new(-1.0, -1.0)); - let p = c.screen_to_world(Vector2::new(100.0, 100.0)); - assert_eq!(p, Vector2::new(1.0, -1.0)); - - c.drag(Vector2::new(50.0, 50.0)); - c.drag(Vector2::new(100.0, 50.0)); - c.release(); - - let p = c.screen_to_world(Vector2::new(50.0, 50.0)); - assert_eq!(p, Vector2::new(-1.0, 0.0)); - - c.drag(Vector2::new(50.0, 50.0)); - c.drag(Vector2::new(50.0, 100.0)); - c.release(); - - let p = c.screen_to_world(Vector2::new(50.0, 50.0)); - assert_eq!(p, Vector2::new(-1.0, 1.0)); - } - - #[test] - fn test_zoom() { - let mut c = Camera2D::new(); - c.set_viewport(Rect { - min: Vector2::new(0.0, 0.0), - max: Vector2::new(100.0, 100.0), - }); - - let points = [ - (75.0, 75.0), - (75.0, 25.0), - (25.0, 75.0), - (25.0, 25.0), - (50.0, 50.0), - ] - .map(|(x, y)| Vector2::new(x, y)); - for &p in &points { - let corner = Vector2::zeros(); - let prev_p = c.screen_to_world(p); - let prev_corner = c.screen_to_world(corner); - c.scroll(Some(p), 100.0); - let next_p = c.screen_to_world(p); - let next_corner = c.screen_to_world(corner); - - assert_eq!(prev_p, next_p); - assert_ne!(prev_corner, next_corner); - } - - // Undo the zooming to put us back at the origin - assert_ne!(c.offset, Vector2::new(0.0, 0.0)); - for &p in points.iter().rev() { - c.scroll(Some(p), -100.0); - } - assert_eq!(c.offset, Vector2::new(0.0, 0.0)); - } -} diff --git a/demos/cli/src/main.rs b/demos/cli/src/main.rs index 98b11da8..01fd9eb4 100644 --- a/demos/cli/src/main.rs +++ b/demos/cli/src/main.rs @@ -119,10 +119,10 @@ fn run3d( if !isometric { *mat.matrix_mut().get_mut((3, 2)).unwrap() = 0.3; } - let cfg = fidget::render::RenderConfig { - image_size: settings.size as usize, + let cfg = fidget::render::VoxelRenderConfig { + image_size: fidget::render::VoxelSize::from(settings.size), tile_sizes: F::tile_sizes_3d(), - threads: settings.threads, + threads: settings.threads.into(), ..Default::default() }; let shape = shape.apply_transform(mat.into()); @@ -130,7 +130,7 @@ fn run3d( let mut depth = vec![]; let mut color = vec![]; for _ in 0..settings.n { - (depth, color) = fidget::render::render3d(shape.clone(), &cfg); + (depth, color) = cfg.run(shape.clone()); } let out = if mode_color { @@ -197,19 +197,17 @@ fn run2d( .flat_map(|i| i.into_iter()) .collect() } else { - let cfg = fidget::render::RenderConfig { - image_size: settings.size as usize, + let cfg = fidget::render::ImageRenderConfig { + image_size: fidget::render::ImageSize::from(settings.size), tile_sizes: F::tile_sizes_2d(), - threads: settings.threads, + threads: settings.threads.into(), ..Default::default() }; if sdf { let mut image = vec![]; for _ in 0..settings.n { - image = fidget::render::render2d::< - _, - fidget::render::SdfRenderMode, - >(shape.clone(), &cfg); + image = + cfg.run::<_, fidget::render::SdfRenderMode>(shape.clone()); } image .into_iter() @@ -218,10 +216,8 @@ fn run2d( } else { let mut image = vec![]; for _ in 0..settings.n { - image = fidget::render::render2d::< - _, - fidget::render::DebugRenderMode, - >(shape.clone(), &cfg); + image = cfg + .run::<_, fidget::render::DebugRenderMode>(shape.clone()); } image .into_iter() @@ -241,7 +237,7 @@ fn run_mesh( for _ in 0..settings.n { let settings = fidget::mesh::Settings { - threads: settings.threads, + threads: settings.threads.into(), depth: settings.depth, ..Default::default() }; diff --git a/demos/viewer/Cargo.toml b/demos/viewer/Cargo.toml index 958a7cc7..0a616e6f 100644 --- a/demos/viewer/Cargo.toml +++ b/demos/viewer/Cargo.toml @@ -16,7 +16,6 @@ notify.workspace = true rhai.workspace = true fidget = { path = "../../fidget", default-features = false, features = ["render", "rhai"] } -camera = { path = "../camera" } workspace-hack = { version = "0.1", path = "../../workspace-hack" } [features] diff --git a/demos/viewer/src/main.rs b/demos/viewer/src/main.rs index 8da36a74..cf3d1b8f 100644 --- a/demos/viewer/src/main.rs +++ b/demos/viewer/src/main.rs @@ -3,11 +3,12 @@ use clap::Parser; use crossbeam_channel::{unbounded, Receiver, Sender}; use eframe::egui; use env_logger::Env; -use fidget::render::RenderConfig; use log::{debug, error, info, warn}; -use nalgebra::{Vector2, Vector3}; +use nalgebra::{Point2, Vector3}; use notify::Watcher; +use fidget::render::{ImageRenderConfig, View2, View3, VoxelRenderConfig}; + use std::{error::Error, path::Path}; /// Minimal viewer, using Fidget to render a Rhai script @@ -65,14 +66,14 @@ fn rhai_script_thread( } struct RenderSettings { - image_size: usize, + image_size: fidget::render::ImageSize, mode: RenderMode, } struct RenderResult { dt: std::time::Duration, image: egui::ImageData, - image_size: usize, + image_size: fidget::render::ImageSize, } fn render_thread( @@ -123,7 +124,10 @@ where if let (Some(out), Some(render_config)) = (&script_ctx, &config) { debug!("Rendering..."); let mut image = egui::ColorImage::new( - [render_config.image_size; 2], + [ + render_config.image_size.width() as usize, + render_config.image_size.height() as usize, + ], egui::Color32::BLACK, ); let render_start = std::time::Instant::now(); @@ -153,28 +157,23 @@ where fn render( mode: &RenderMode, shape: fidget::shape::Shape, - image_size: usize, + image_size: fidget::render::ImageSize, color: [u8; 3], pixels: &mut [egui::Color32], ) { match mode { - RenderMode::TwoD(camera, mode) => { - let config = RenderConfig { + RenderMode::TwoD { view, mode, .. } => { + let config = ImageRenderConfig { image_size, tile_sizes: F::tile_sizes_2d(), - bounds: fidget::shape::Bounds { - center: camera.offset(), - size: camera.scale(), - }, - ..RenderConfig::default() + view: *view, + ..Default::default() }; match mode { Mode2D::Color => { - let image = fidget::render::render2d::< - _, - fidget::render::BitRenderMode, - >(shape, &config); + let image = + config.run::<_, fidget::render::BitRenderMode>(shape); let c = egui::Color32::from_rgba_unmultiplied( color[0], color[1], @@ -189,20 +188,16 @@ fn render( } Mode2D::Sdf => { - let image = fidget::render::render2d::< - _, - fidget::render::SdfRenderMode, - >(shape, &config); + let image = + config.run::<_, fidget::render::SdfRenderMode>(shape); for (p, i) in pixels.iter_mut().zip(&image) { *p = egui::Color32::from_rgb(i[0], i[1], i[2]); } } Mode2D::Debug => { - let image = fidget::render::render2d::< - _, - fidget::render::DebugRenderMode, - >(shape, &config); + let image = + config.run::<_, fidget::render::DebugRenderMode>(shape); for (p, i) in pixels.iter_mut().zip(&image) { let c = i.as_debug_color(); *p = egui::Color32::from_rgb(c[0], c[1], c[2]); @@ -211,16 +206,21 @@ fn render( } } RenderMode::ThreeD(camera, mode) => { - let config = RenderConfig { - image_size, - tile_sizes: F::tile_sizes_2d(), - bounds: fidget::shape::Bounds { - center: Vector3::new(camera.offset.x, camera.offset.y, 0.0), - size: camera.scale, - }, - ..RenderConfig::default() + // XXX allow selection of depth? + let config = VoxelRenderConfig { + image_size: fidget::render::VoxelSize::new( + image_size.width(), + image_size.height(), + 512, + ), + tile_sizes: F::tile_sizes_3d(), + view: View3::from_center_and_scale( + Vector3::new(camera.offset.x, camera.offset.y, 0.0), + camera.scale, + ), + ..Default::default() }; - let (depth, color) = fidget::render::render3d(shape, &config); + let (depth, color) = config.run(shape); match mode { ThreeDMode::Color => { for (p, (&d, &c)) in @@ -381,31 +381,38 @@ enum ThreeDMode { #[derive(Copy, Clone)] enum RenderMode { - TwoD(camera::Camera2D, Mode2D), + TwoD { + view: View2, + + /// Drag start position (in model coordinates) + drag_start: Option>, + mode: Mode2D, + }, ThreeD(ThreeDCamera, ThreeDMode), } impl RenderMode { - fn set_2d_mode(&mut self, mode: Mode2D) -> bool { + fn set_2d_mode(&mut self, new_mode: Mode2D) -> bool { match self { - RenderMode::TwoD(.., m) => { - let changed = *m != mode; - *m = mode; + RenderMode::TwoD { mode, .. } => { + let changed = *mode != new_mode; + *mode = new_mode; changed } RenderMode::ThreeD(..) => { - *self = RenderMode::TwoD( + *self = RenderMode::TwoD { // TODO get parameters from 3D camera here? - camera::Camera2D::new(), - mode, - ); + view: Default::default(), + drag_start: None, + mode: new_mode, + }; true } } } fn set_3d_mode(&mut self, mode: ThreeDMode) -> bool { match self { - RenderMode::TwoD(..) => { + RenderMode::TwoD { .. } => { *self = RenderMode::ThreeD(ThreeDCamera::default(), mode); true } @@ -421,7 +428,7 @@ impl RenderMode { struct ViewerApp { // Current image texture: Option, - stats: Option<(std::time::Duration, usize)>, + stats: Option<(std::time::Duration, fidget::render::ImageSize)>, // Most recent result, or an error string // TODO: this could be combined with stats as a Result @@ -429,7 +436,7 @@ struct ViewerApp { /// Current render mode mode: RenderMode, - image_size: usize, + image_size: fidget::render::ImageSize, config_tx: Sender, image_rx: Receiver>, @@ -447,12 +454,16 @@ impl ViewerApp { stats: None, err: None, - image_size: 0, + image_size: fidget::render::ImageSize::from(256), config_tx, image_rx, - mode: RenderMode::TwoD(camera::Camera2D::new(), Mode2D::Color), + mode: RenderMode::TwoD { + view: Default::default(), + drag_start: None, + mode: Mode2D::Color, + }, } } @@ -462,7 +473,7 @@ impl ViewerApp { egui::menu::bar(ui, |ui| { ui.menu_button("Config", |ui| { let mut mode_3d = match &self.mode { - RenderMode::TwoD(..) => None, + RenderMode::TwoD { .. } => None, RenderMode::ThreeD(_camera, mode) => Some(*mode), }; ui.radio_value( @@ -480,7 +491,7 @@ impl ViewerApp { } ui.separator(); let mut mode_2d = match &self.mode { - RenderMode::TwoD(_camera, mode) => Some(*mode), + RenderMode::TwoD { mode, .. } => Some(*mode), RenderMode::ThreeD(..) => None, }; ui.radio_value( @@ -551,18 +562,13 @@ impl ViewerApp { }); const PADDING: egui::Vec2 = egui::Vec2 { x: 10.0, y: 10.0 }; - let RenderMode::TwoD(camera, ..) = &self.mode else { + if !matches!(self.mode, RenderMode::TwoD { .. }) { panic!("can't render in 3D"); - }; - let rect = camera.viewport(); - let rect = egui::Rect { - min: egui::Pos2::new(rect.min.x, rect.min.y), - max: egui::Pos2::new(rect.max.x, rect.max.y), - }; - let uv = camera.uv(); + } + let rect = ui.ctx().available_rect(); let uv = egui::Rect { - min: egui::Pos2::new(uv.min.x, uv.min.y), - max: egui::Pos2::new(uv.max.x, uv.max.y), + min: egui::Pos2::new(0.0, 0.0), + max: egui::Pos2::new(1.0, 1.0), }; if let Some((dt, image_size)) = self.stats { @@ -576,8 +582,9 @@ impl ViewerApp { let layout = painter.layout( format!( - "Image size: {0}x{0}\nRender time: {dt:.2?}", - image_size, + "Image size: {}×{}\nRender time: {dt:.2?}", + image_size.width(), + image_size.height(), ), egui::FontId::proportional(14.0), egui::Color32::WHITE, @@ -631,8 +638,10 @@ impl eframe::App for ViewerApp { let rect = ctx.available_rect(); let size = rect.max - rect.min; - let max_size = size.x.max(size.y); - let image_size = (max_size * ctx.pixels_per_point()) as usize; + let image_size = fidget::render::ImageSize::new( + (size.x * ctx.pixels_per_point()) as u32, + (size.y * ctx.pixels_per_point()) as u32, + ); if image_size != self.image_size { self.image_size = image_size; @@ -647,28 +656,36 @@ impl eframe::App for ViewerApp { // Handle pan and zoom match &mut self.mode { - RenderMode::TwoD(camera, ..) => { - let rect = camera::Rect { - min: Vector2::new(rect.min.x, rect.min.y), - max: Vector2::new(rect.max.x, rect.max.y), - }; - - camera.set_viewport(rect); + RenderMode::TwoD { + view, drag_start, .. + } => { + let image_size = fidget::render::ImageSize::new( + rect.width() as u32, + rect.height() as u32, + ); + let mat = view.world_to_model() * image_size.screen_to_world(); if let Some(pos) = r.interact_pointer_pos() { - let pos = Vector2::new(pos.x, pos.y); - render_changed |= camera.drag(pos); + let pos = mat.transform_point(&Point2::new(pos.x, pos.y)); + if let Some(prev) = *drag_start { + view.translate(prev - pos); + render_changed |= prev != pos; + } else { + *drag_start = Some(pos); + } } else { - camera.release(); + *drag_start = None; } if r.hovered() { let scroll = ctx.input(|i| i.smooth_scroll_delta.y); - let mouse_pos = ctx.input(|i| i.pointer.hover_pos()); - render_changed |= camera.scroll( - mouse_pos.map(|p| Vector2::new(p.x, p.y)), - scroll, - ); + let mouse_pos = ctx + .input(|i| i.pointer.hover_pos()) + .map(|p| mat.transform_point(&Point2::new(p.x, p.y))); + if scroll != 0.0 { + view.zoom((scroll / 100.0).exp2(), mouse_pos); + render_changed = true; + } } } RenderMode::ThreeD(..) => { diff --git a/demos/web-editor/crate/Cargo.lock b/demos/web-editor/crate/Cargo.lock index 47004a32..3ea34b00 100644 --- a/demos/web-editor/crate/Cargo.lock +++ b/demos/web-editor/crate/Cargo.lock @@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "fidget" -version = "0.3.3" +version = "0.3.4" dependencies = [ "arrayvec", "bimap", diff --git a/demos/web-editor/crate/src/lib.rs b/demos/web-editor/crate/src/lib.rs index f905265a..8ec62175 100644 --- a/demos/web-editor/crate/src/lib.rs +++ b/demos/web-editor/crate/src/lib.rs @@ -1,7 +1,9 @@ use fidget::{ context::{Context, Tree}, - render::{BitRenderMode, RenderConfig}, - shape::Bounds, + render::{ + BitRenderMode, ImageRenderConfig, ImageSize, View2, View3, + VoxelRenderConfig, VoxelSize, + }, var::Var, vm::{VmData, VmShape}, Error, @@ -83,16 +85,13 @@ pub fn render_region_2d( // Tile center let center = corner.add_scalar(scale / 2.0); - let cfg = RenderConfig::<2> { - image_size: image_size / workers_per_side, - bounds: Bounds { - center, - size: scale / 2.0, - }, - ..RenderConfig::default() + let cfg = ImageRenderConfig { + image_size: ImageSize::from((image_size / workers_per_side) as u32), + view: View2::from_center_and_scale(center, scale / 2.0), + ..Default::default() }; - let out = cfg.run::<_, BitRenderMode>(shape)?; + let out = cfg.run::<_, BitRenderMode>(shape); Ok(out .into_iter() .flat_map(|b| { @@ -195,17 +194,14 @@ fn render_3d_inner( // Tile center let center = corner.add_scalar(scale / 2.0); - let cfg = RenderConfig::<3> { - image_size: image_size / workers_per_side, - bounds: Bounds { - center, - size: scale / 2.0, - }, - ..RenderConfig::default() + let cfg = VoxelRenderConfig { + image_size: VoxelSize::from((image_size / workers_per_side) as u32), + view: View3::from_center_and_scale(center, scale / 2.0), + ..Default::default() }; // Special case for the first tile, which can be copied over - let (mut depth, norm) = cfg.run(shape.clone())?; + let (mut depth, norm) = cfg.run(shape.clone()); for d in &mut depth { if *d > 0 { *d += (z * image_size / workers_per_side) as u32; diff --git a/fidget/benches/mesh.rs b/fidget/benches/mesh.rs index 098a25aa..45eb63dc 100644 --- a/fidget/benches/mesh.rs +++ b/fidget/benches/mesh.rs @@ -1,6 +1,7 @@ use criterion::{ black_box, criterion_group, criterion_main, BenchmarkId, Criterion, }; +use fidget::render::ThreadCount; const COLONNADE: &str = include_str!("../../models/colonnade.vm"); @@ -12,12 +13,15 @@ pub fn colonnade_octree_thread_sweep(c: &mut Criterion) { let mut group = c.benchmark_group("speed vs threads (colonnade, octree) (depth 6)"); - for threads in [1, 4, 8] { + for threads in std::iter::once(ThreadCount::One) + .chain([1, 4, 8].map(|i| ThreadCount::Many(i.try_into().unwrap()))) + { let cfg = &fidget::mesh::Settings { depth: 6, - threads: threads.try_into().unwrap(), + threads, ..Default::default() }; + #[cfg(feature = "jit")] group.bench_function(BenchmarkId::new("jit", threads), move |b| { b.iter(|| { @@ -45,11 +49,10 @@ pub fn colonnade_mesh(c: &mut Criterion) { let mut group = c.benchmark_group("speed vs threads (colonnade, meshing) (depth 8)"); - for threads in [1, 4, 8] { - let cfg = &fidget::mesh::Settings { - threads: threads.try_into().unwrap(), - ..cfg - }; + for threads in std::iter::once(ThreadCount::One) + .chain([1, 4, 8].map(|i| ThreadCount::Many(i.try_into().unwrap()))) + { + let cfg = &fidget::mesh::Settings { threads, ..cfg }; group.bench_function( BenchmarkId::new("walk_dual", threads), move |b| { diff --git a/fidget/benches/render.rs b/fidget/benches/render.rs index 951c2765..d5adccc3 100644 --- a/fidget/benches/render.rs +++ b/fidget/benches/render.rs @@ -1,7 +1,10 @@ use criterion::{ black_box, criterion_group, criterion_main, BenchmarkId, Criterion, }; -use fidget::shape::RenderHints; +use fidget::{ + render::{ImageSize, ThreadCount}, + shape::RenderHints, +}; const PROSPERO: &str = include_str!("../../models/prospero.vm"); @@ -15,35 +18,29 @@ pub fn prospero_size_sweep(c: &mut Criterion) { let mut group = c.benchmark_group("speed vs image size (prospero, 2d) (8 threads)"); for size in [256, 512, 768, 1024, 1280, 1546, 1792, 2048] { - let cfg = &fidget::render::RenderConfig { - image_size: size, + let cfg = &fidget::render::ImageRenderConfig { + image_size: fidget::render::ImageSize::from(size), tile_sizes: fidget::vm::VmFunction::tile_sizes_2d(), ..Default::default() }; group.bench_function(BenchmarkId::new("vm", size), move |b| { b.iter(|| { let tape = shape_vm.clone(); - black_box(fidget::render::render2d::< - _, - fidget::render::BitRenderMode, - >(tape, cfg)) + black_box(cfg.run::<_, fidget::render::BitRenderMode>(tape)) }) }); #[cfg(feature = "jit")] { - let cfg = &fidget::render::RenderConfig { - image_size: size, + let cfg = &fidget::render::ImageRenderConfig { + image_size: fidget::render::ImageSize::from(size), tile_sizes: fidget::jit::JitFunction::tile_sizes_2d(), ..Default::default() }; group.bench_function(BenchmarkId::new("jit", size), move |b| { b.iter(|| { let tape = shape_jit.clone(); - black_box(fidget::render::render2d::< - _, - fidget::render::BitRenderMode, - >(tape, cfg)) + black_box(cfg.run::<_, fidget::render::BitRenderMode>(tape)) }) }); } @@ -59,37 +56,33 @@ pub fn prospero_thread_sweep(c: &mut Criterion) { let mut group = c.benchmark_group("speed vs threads (prospero, 2d) (1024 x 1024)"); - for threads in [1, 2, 4, 8, 16] { - let cfg = &fidget::render::RenderConfig { - image_size: 1024, + for threads in std::iter::once(ThreadCount::One).chain( + [1, 2, 4, 8, 16].map(|i| ThreadCount::Many(i.try_into().unwrap())), + ) { + let cfg = &fidget::render::ImageRenderConfig { + image_size: ImageSize::from(1024), tile_sizes: fidget::vm::VmFunction::tile_sizes_2d(), - threads: threads.try_into().unwrap(), + threads, ..Default::default() }; group.bench_function(BenchmarkId::new("vm", threads), move |b| { b.iter(|| { let tape = shape_vm.clone(); - black_box(fidget::render::render2d::< - _, - fidget::render::BitRenderMode, - >(tape, cfg)) + black_box(cfg.run::<_, fidget::render::BitRenderMode>(tape)) }) }); #[cfg(feature = "jit")] { - let cfg = &fidget::render::RenderConfig { - image_size: 1024, + let cfg = &fidget::render::ImageRenderConfig { + image_size: ImageSize::from(1024), tile_sizes: fidget::jit::JitFunction::tile_sizes_2d(), - threads: threads.try_into().unwrap(), + threads, ..Default::default() }; group.bench_function(BenchmarkId::new("jit", threads), move |b| { b.iter(|| { let tape = shape_jit.clone(); - black_box(fidget::render::render2d::< - _, - fidget::render::BitRenderMode, - >(tape, cfg)) + black_box(cfg.run::<_, fidget::render::BitRenderMode>(tape)) }) }); } diff --git a/fidget/src/core/shape/bounds.rs b/fidget/src/core/shape/bounds.rs deleted file mode 100644 index f9bbeb57..00000000 --- a/fidget/src/core/shape/bounds.rs +++ /dev/null @@ -1,126 +0,0 @@ -use nalgebra::{ - allocator::Allocator, Const, DefaultAllocator, DimNameAdd, DimNameSub, - DimNameSum, OVector, Transform, U1, -}; - -/// A bounded region in space, typically used as a render region -/// -/// Right now, all spatial operations take place in a cubical region, so we -/// specify bounds as a center point and region size. -#[derive(Copy, Clone, Debug)] -pub struct Bounds { - /// Center of the bounds - pub center: OVector>, - - /// Size of the bounds in each direction - /// - /// The full bounds are given by `[center - size, center + size]` on each - /// axis. - pub size: f32, -} - -impl Default for Bounds { - /// By default, the bounds are the `[-1, +1]` region - fn default() -> Self { - let center = OVector::>::zeros(); - Self { center, size: 1.0 } - } -} - -impl Bounds -where - Const: DimNameAdd, - DefaultAllocator: - Allocator, U1>, DimNameSum, U1>>, - as DimNameAdd>>::Output: DimNameSub, -{ - /// Returns a homogeneous transform matrix for these bounds - /// - /// When this matrix is applied, the `[-1, +1]` region (used for all - /// rendering operations) will be remapped to the original bounds. - pub fn transform(&self) -> Transform { - let mut t = nalgebra::Translation::::identity(); - t.vector = self.center / self.size; - - let mut out = Transform::::default(); - out.matrix_mut().append_scaling_mut(self.size); - out *= t; - - out - } -} - -#[cfg(test)] -mod test { - use super::*; - use nalgebra::{Point2, Vector2}; - - #[test] - fn bounds_default() { - let b = Bounds::default(); - let t = b.transform(); - assert_eq!( - t.transform_point(&Point2::new(-1.0, -1.0)), - Point2::new(-1.0, -1.0) - ); - assert_eq!( - t.transform_point(&Point2::new(0.5, 0.0)), - Point2::new(0.5, 0.0) - ); - } - - #[test] - fn bounds_scale() { - let b = Bounds { - center: Vector2::zeros(), - size: 0.5, - }; - let t = b.transform(); - assert_eq!( - t.transform_point(&Point2::new(-1.0, -1.0)), - Point2::new(-0.5, -0.5) - ); - assert_eq!( - t.transform_point(&Point2::new(1.0, 0.0)), - Point2::new(0.5, 0.0) - ); - } - - #[test] - fn bounds_translate() { - let b = Bounds { - center: Vector2::new(1.0, 2.0), - size: 1.0, - }; - let t = b.transform(); - assert_eq!( - t.transform_point(&Point2::new(-1.0, -1.0)), - Point2::new(0.0, 1.0) - ); - assert_eq!( - t.transform_point(&Point2::new(1.0, 0.0)), - Point2::new(2.0, 2.0) - ); - } - - #[test] - fn bounds_translate_scale() { - let b = Bounds { - center: Vector2::new(0.5, 0.5), - size: 0.5, - }; - let t = b.transform(); - assert_eq!( - t.transform_point(&Point2::new(0.0, 0.0)), - Point2::new(0.5, 0.5) - ); - assert_eq!( - t.transform_point(&Point2::new(-1.0, -1.0)), - Point2::new(0.0, 0.0) - ); - assert_eq!( - t.transform_point(&Point2::new(1.0, 1.0)), - Point2::new(1.0, 1.0) - ); - } -} diff --git a/fidget/src/core/shape/mod.rs b/fidget/src/core/shape/mod.rs index eb32318c..b70da20e 100644 --- a/fidget/src/core/shape/mod.rs +++ b/fidget/src/core/shape/mod.rs @@ -33,12 +33,9 @@ use crate::{ var::{Var, VarIndex, VarMap}, Error, }; -use nalgebra::{Matrix4, Point3}; +use nalgebra::{Matrix4, Point2, Point3}; use std::collections::HashMap; -mod bounds; -pub use bounds::Bounds; - /// A shape represents an implicit surface /// /// It is mostly agnostic to _how_ that surface is represented, wrapping a @@ -333,8 +330,22 @@ impl TileSizes { } /// Gets a tile size by index - pub fn get(&self, i: usize) -> Option<&usize> { - self.0.get(i) + pub fn get(&self, i: usize) -> Option { + self.0.get(i).copied() + } + + /// Returns the data offset of a global pixel position within a root tile + /// + /// The root tile is implicit: it's set by the largest tile size and aligned + /// to multiples of that size. + #[inline] + pub(crate) fn pixel_offset(&self, pos: Point2) -> usize { + // Find the relative position within the root tile + let x = pos.x % self.0[0]; + let y = pos.y % self.0[0]; + + // Apply the relative offset and find the data index + x + y * self.0[0] } } diff --git a/fidget/src/lib.rs b/fidget/src/lib.rs index c078cedb..4770b9b1 100644 --- a/fidget/src/lib.rs +++ b/fidget/src/lib.rs @@ -209,22 +209,22 @@ //! ``` //! use fidget::{ //! context::{Tree, Context}, -//! render::{BitRenderMode, RenderConfig}, +//! render::{BitRenderMode, ImageSize, ImageRenderConfig}, //! vm::VmShape, //! }; //! //! let x = Tree::x(); //! let y = Tree::y(); //! let tree = (x.square() + y.square()).sqrt() - 1.0; -//! let cfg = RenderConfig::<2> { -//! image_size: 32, -//! ..RenderConfig::default() +//! let cfg = ImageRenderConfig { +//! image_size: ImageSize::from(32), +//! ..Default::default() //! }; //! let shape = VmShape::from(tree); -//! let out = cfg.run::<_, BitRenderMode>(shape)?; +//! let out = cfg.run::<_, BitRenderMode>(shape); //! let mut iter = out.iter(); -//! for y in 0..cfg.image_size { -//! for x in 0..cfg.image_size { +//! for y in 0..cfg.image_size.height() { +//! for x in 0..cfg.image_size.width() { //! if *iter.next().unwrap() { //! print!("XX"); //! } else { diff --git a/fidget/src/mesh/mod.rs b/fidget/src/mesh/mod.rs index d77fd467..456628e8 100644 --- a/fidget/src/mesh/mod.rs +++ b/fidget/src/mesh/mod.rs @@ -39,8 +39,6 @@ //! # Ok::<(), fidget::Error>(()) //! ``` -use crate::shape::Bounds; - mod builder; mod cell; mod dc; @@ -50,6 +48,8 @@ mod octree; mod output; mod qef; +use crate::render::{ThreadCount, View3}; + #[cfg(not(target_arch = "wasm32"))] mod mt; @@ -83,37 +83,22 @@ pub struct Settings { /// Depth to recurse in the octree pub depth: u8, - /// Bounds for meshing - pub bounds: Bounds<3>, + /// Viewport to provide a world-to-model transform + pub view: View3, /// Number of threads to use /// /// 1 indicates to use the single-threaded evaluator; other values will /// spin up _N_ threads to perform octree construction in parallel. - #[cfg(not(target_arch = "wasm32"))] - pub threads: std::num::NonZeroUsize, + pub threads: ThreadCount, } impl Default for Settings { fn default() -> Self { Self { depth: 3, - bounds: Default::default(), - - #[cfg(not(target_arch = "wasm32"))] - threads: std::num::NonZeroUsize::new(8).unwrap(), + view: Default::default(), + threads: ThreadCount::default(), } } } - -impl Settings { - #[cfg(not(target_arch = "wasm32"))] - fn threads(&self) -> usize { - self.threads.get() - } - - #[cfg(target_arch = "wasm32")] - fn threads(&self) -> usize { - 1 - } -} diff --git a/fidget/src/mesh/mt/dc.rs b/fidget/src/mesh/mt/dc.rs index 021f742d..13cffec6 100644 --- a/fidget/src/mesh/mt/dc.rs +++ b/fidget/src/mesh/mt/dc.rs @@ -7,7 +7,10 @@ use crate::mesh::{ types::{X, Y, Z}, Mesh, Octree, }; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::{ + num::NonZeroUsize, + sync::atomic::{AtomicU64, Ordering}, +}; #[derive(Debug)] enum Task { @@ -52,7 +55,7 @@ pub struct DcWorker<'a> { } impl<'a> DcWorker<'a> { - pub fn scheduler(octree: &Octree, threads: usize) -> Mesh { + pub fn scheduler(octree: &Octree, threads: NonZeroUsize) -> Mesh { let queues = QueuePool::new(threads); let map = octree diff --git a/fidget/src/mesh/mt/mod.rs b/fidget/src/mesh/mt/mod.rs index f496b254..7fffaa78 100644 --- a/fidget/src/mesh/mt/mod.rs +++ b/fidget/src/mesh/mt/mod.rs @@ -5,3 +5,9 @@ mod pool; pub use dc::DcWorker; pub use octree::OctreeWorker; + +/// Strong type for multithreaded settings +pub(crate) struct MultithreadedSettings { + pub depth: u8, + pub threads: std::num::NonZeroUsize, +} diff --git a/fidget/src/mesh/mt/octree.rs b/fidget/src/mesh/mt/octree.rs index c9331ab7..e2b22232 100644 --- a/fidget/src/mesh/mt/octree.rs +++ b/fidget/src/mesh/mt/octree.rs @@ -1,12 +1,15 @@ //! Multithreaded octree construction -use super::pool::{QueuePool, ThreadContext, ThreadPool}; +use super::{ + pool::{QueuePool, ThreadContext, ThreadPool}, + MultithreadedSettings, +}; use crate::{ eval::Function, mesh::{ cell::{Cell, CellData, CellIndex}, octree::{BranchResult, CellResult, EvalGroup, OctreeBuilder}, types::Corner, - Octree, Settings, + Octree, }, shape::RenderHints, }; @@ -118,10 +121,14 @@ pub struct OctreeWorker { } impl OctreeWorker { - pub fn scheduler(eval: Arc>, settings: Settings) -> Octree { - let task_queues = QueuePool::new(settings.threads()); + pub fn scheduler( + eval: Arc>, + settings: MultithreadedSettings, + ) -> Octree { + let thread_count = settings.threads.get(); + let task_queues = QueuePool::new(settings.threads); let done_queues = std::iter::repeat_with(std::sync::mpsc::channel) - .take(settings.threads()) + .take(thread_count) .collect::>(); let friend_done = done_queues.iter().map(|t| t.0.clone()).collect::>(); @@ -144,7 +151,7 @@ impl OctreeWorker { .collect::>(); let root = CellIndex::default(); - let r = workers[0].octree.eval_cell(&eval, root, settings); + let r = workers[0].octree.eval_cell(&eval, root, settings.depth); let c = match r { CellResult::Done(cell) => Some(cell), CellResult::Recurse(eval) => { @@ -157,11 +164,11 @@ impl OctreeWorker { workers[0].octree.record(0, c.into()); workers.into_iter().next().unwrap().octree.into() } else { - let pool = &ThreadPool::new(settings.threads()); + let pool = &ThreadPool::new(settings.threads); let out: Vec = std::thread::scope(|s| { let mut handles = vec![]; for w in workers { - handles.push(s.spawn(move || w.run(pool, settings))); + handles.push(s.spawn(move || w.run(pool, settings.depth))); } handles.into_iter().map(|h| h.join().unwrap()).collect() }); @@ -170,7 +177,7 @@ impl OctreeWorker { } /// Runs a single worker to completion as part of a worker group - pub fn run(mut self, threads: &ThreadPool, settings: Settings) -> Octree { + pub fn run(mut self, threads: &ThreadPool, max_depth: u8) -> Octree { let mut ctx = threads.start(self.thread_index); loop { // First, check to see if anyone has finished a task and sent us @@ -205,7 +212,7 @@ impl OctreeWorker { for i in Corner::iter() { let sub_cell = task.target_cell.child(index, i); - match self.octree.eval_cell(&task.eval, sub_cell, settings) + match self.octree.eval_cell(&task.eval, sub_cell, max_depth) { // If this child is finished, then record it locally. // If it's a branching cell, then we'll let a caller diff --git a/fidget/src/mesh/mt/pool.rs b/fidget/src/mesh/mt/pool.rs index 89a2331c..fe4fc5ff 100644 --- a/fidget/src/mesh/mt/pool.rs +++ b/fidget/src/mesh/mt/pool.rs @@ -1,5 +1,8 @@ //! Minimal utilities for thread pooling -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::{ + num::NonZeroUsize, + sync::atomic::{AtomicUsize, Ordering}, +}; /// Stores data used to synchronize a thread pool pub struct ThreadPool { @@ -9,9 +12,12 @@ pub struct ThreadPool { impl ThreadPool { /// Builds thread pool storage for `n` threads - pub fn new(n: usize) -> Self { + pub fn new(n: NonZeroUsize) -> Self { Self { - threads: std::sync::RwLock::new(vec![std::thread::current(); n]), + threads: std::sync::RwLock::new(vec![ + std::thread::current(); + n.get() + ]), counter: AtomicUsize::new(0), } } @@ -196,8 +202,8 @@ pub struct QueuePool { impl QueuePool { /// Builds a new set of queues for `n` threads - pub fn new(n: usize) -> Vec { - let task_queues = (0..n) + pub fn new(n: NonZeroUsize) -> Vec { + let task_queues = (0..n.get()) .map(|_| crossbeam_deque::Worker::::new_lifo()) .collect::>(); @@ -260,7 +266,7 @@ mod test { #[test] fn queue_pool() { - let mut queues = QueuePool::new(2); + let mut queues = QueuePool::new(2.try_into().unwrap()); let mut counters = [0i32; 2]; const DEPTH: usize = 6; queues[0].push(DEPTH); @@ -298,7 +304,7 @@ mod test { #[test] fn thread_ctx() { const N: usize = 8; - let pool = &ThreadPool::new(N); + let pool = &ThreadPool::new(N.try_into().unwrap()); let done = &AtomicUsize::new(0); std::thread::scope(|s| { @@ -329,8 +335,8 @@ mod test { #[test] fn queue_and_thread_pool() { const N: usize = 8; - let mut queues = QueuePool::new(N); - let pool = &ThreadPool::new(N); + let mut queues = QueuePool::new(N.try_into().unwrap()); + let pool = &ThreadPool::new(N.try_into().unwrap()); let mut counters = [0i32; N]; const DEPTH: usize = 16; queues[0].push(DEPTH); diff --git a/fidget/src/mesh/octree.rs b/fidget/src/mesh/octree.rs index 0ecd07d4..a21a3ac7 100644 --- a/fidget/src/mesh/octree.rs +++ b/fidget/src/mesh/octree.rs @@ -12,13 +12,14 @@ use super::{ }; use crate::{ eval::{BulkEvaluator, Function, TracingEvaluator}, + render::ThreadCount, shape::{RenderHints, Shape, ShapeBulkEval, ShapeTape, ShapeTracingEval}, types::Grad, }; use std::{num::NonZeroUsize, sync::Arc, sync::OnceLock}; #[cfg(not(target_arch = "wasm32"))] -use super::mt::{DcWorker, OctreeWorker}; +use super::mt::{DcWorker, MultithreadedSettings, OctreeWorker}; // TODO use fidget::render::RenderHandle here instead? /// Helper struct to contain a set of matched evaluators @@ -99,12 +100,12 @@ impl Octree { shape: &Shape, settings: Settings, ) -> Self { - // Transform the shape given our bounds - let t = settings.bounds.transform(); - if t == nalgebra::Transform::identity() { + // Transform the shape given our world-to-model matrix + let t = settings.view.world_to_model(); + if t == nalgebra::Matrix4::identity() { Self::build_inner(shape, settings) } else { - let shape = shape.clone().apply_transform(t.into()); + let shape = shape.clone().apply_transform(t); let mut out = Self::build_inner(&shape, settings); // Apply the transform from [-1, +1] back to model space @@ -123,16 +124,21 @@ impl Octree { ) -> Self { let eval = Arc::new(EvalGroup::new(shape.clone())); - if settings.threads() == 1 { - let mut out = OctreeBuilder::new(); - out.recurse(&eval, CellIndex::default(), settings); - out.into() - } else { - #[cfg(target_arch = "wasm32")] - unreachable!("cannot use multithreaded evaluator on wasm32"); + match settings.threads { + ThreadCount::One => { + let mut out = OctreeBuilder::new(); + out.recurse(&eval, CellIndex::default(), settings.depth); + out.into() + } #[cfg(not(target_arch = "wasm32"))] - OctreeWorker::scheduler(eval.clone(), settings) + ThreadCount::Many(threads) => OctreeWorker::scheduler( + eval.clone(), + MultithreadedSettings { + depth: settings.depth, + threads, + }, + ), } } @@ -140,15 +146,13 @@ impl Octree { pub fn walk_dual(&self, settings: Settings) -> Mesh { let mut mesh = MeshBuilder::default(); - if settings.threads() == 1 { - mesh.cell(self, CellIndex::default()); - mesh.take() - } else { - #[cfg(target_arch = "wasm32")] - unreachable!("cannot use multithreaded evaluator on wasm32"); - + match settings.threads { + ThreadCount::One => { + mesh.cell(self, CellIndex::default()); + mesh.take() + } #[cfg(not(target_arch = "wasm32"))] - DcWorker::scheduler(self, settings.threads()) + ThreadCount::Many(threads) => DcWorker::scheduler(self, threads), } } @@ -350,7 +354,7 @@ impl OctreeBuilder { &mut self, eval: &Arc>, cell: CellIndex, - settings: Settings, + max_depth: u8, ) -> CellResult { let (i, r) = self .eval_interval @@ -376,7 +380,7 @@ impl OctreeBuilder { } else { None }; - if cell.depth == settings.depth as usize { + if cell.depth == max_depth as usize { let eval = sub_tape.unwrap_or_else(|| eval.clone()); let out = CellResult::Done(self.leaf(&eval, cell)); if let Ok(t) = Arc::try_unwrap(eval) { @@ -429,9 +433,9 @@ impl OctreeBuilder { &mut self, eval: &Arc>, cell: CellIndex, - settings: Settings, + max_depth: u8, ) { - match self.eval_cell(eval, cell, settings) { + match self.eval_cell(eval, cell, max_depth) { CellResult::Done(c) => self.o[cell] = c.into(), CellResult::Recurse(sub_eval) => { let index = self.o.cells.len(); @@ -440,7 +444,7 @@ impl OctreeBuilder { } for i in Corner::iter() { let cell = cell.child(index, i); - self.recurse(&sub_eval, cell, settings); + self.recurse(&sub_eval, cell, max_depth); } if let Ok(t) = Arc::try_unwrap(sub_eval) { @@ -1171,28 +1175,28 @@ mod test { use crate::{ context::Tree, mesh::types::{Edge, X, Y, Z}, - shape::{Bounds, EzShape}, + render::{ThreadCount, View3}, + shape::EzShape, vm::{VmFunction, VmShape}, }; use nalgebra::Vector3; use std::collections::BTreeMap; - const DEPTH0_SINGLE_THREAD: Settings = Settings { - depth: 0, - bounds: Bounds { - center: Vector3::new(0.0, 0.0, 0.0), - size: 1.0, - }, - threads: unsafe { std::num::NonZeroUsize::new_unchecked(1) }, - }; - const DEPTH1_SINGLE_THREAD: Settings = Settings { - depth: 1, - bounds: Bounds { - center: Vector3::new(0.0, 0.0, 0.0), - size: 1.0, - }, - threads: unsafe { std::num::NonZeroUsize::new_unchecked(1) }, - }; + fn depth0_single_thread() -> Settings { + Settings { + depth: 0, + threads: ThreadCount::One, + ..Default::default() + } + } + + fn depth1_single_thread() -> Settings { + Settings { + depth: 1, + threads: ThreadCount::One, + ..Default::default() + } + } fn sphere(center: [f32; 3], radius: f32) -> Tree { let (x, y, z) = Tree::axes(); @@ -1218,7 +1222,7 @@ mod test { let shape = VmShape::from(cube([-f, f], [-f, 0.3], [-f, 0.6])); // This should be a cube with a single edge running through the root // node of the octree, with an edge vertex at [0, 0.3, 0.6] - let octree = Octree::build(&shape, DEPTH0_SINGLE_THREAD); + let octree = Octree::build(&shape, depth0_single_thread()); assert_eq!(octree.verts.len(), 5); let v = octree.verts[0].pos; let expected = nalgebra::Vector3::new(0.0, 0.3, 0.6); @@ -1268,17 +1272,17 @@ mod test { // If we only build a depth-0 octree, then it's a leaf without any // vertices (since all the corners are empty) - let octree = Octree::build(&shape, DEPTH0_SINGLE_THREAD); + let octree = Octree::build(&shape, depth0_single_thread()); assert_eq!(octree.cells.len(), 8); // we always build at least 8 cells assert_eq!(Cell::Empty, octree.cells[0].into(),); assert_eq!(octree.verts.len(), 0); - let empty_mesh = octree.walk_dual(DEPTH0_SINGLE_THREAD); + let empty_mesh = octree.walk_dual(depth0_single_thread()); assert!(empty_mesh.vertices.is_empty()); assert!(empty_mesh.triangles.is_empty()); // Now, at depth-1, each cell should be a Leaf with one vertex - let octree = Octree::build(&shape, DEPTH1_SINGLE_THREAD); + let octree = Octree::build(&shape, depth1_single_thread()); assert_eq!(octree.cells.len(), 16); // we always build at least 8 cells assert_eq!( Cell::Branch { @@ -1300,7 +1304,7 @@ mod test { assert_eq!(index % 4, 0); } - let sphere_mesh = octree.walk_dual(DEPTH1_SINGLE_THREAD); + let sphere_mesh = octree.walk_dual(depth1_single_thread()); assert!(sphere_mesh.vertices.len() > 1); assert!(!sphere_mesh.triangles.is_empty()); } @@ -1309,8 +1313,8 @@ mod test { fn test_sphere_verts() { let shape = VmShape::from(sphere([0.0; 3], 0.2)); - let octree = Octree::build(&shape, DEPTH1_SINGLE_THREAD); - let sphere_mesh = octree.walk_dual(DEPTH1_SINGLE_THREAD); + let octree = Octree::build(&shape, depth1_single_thread()); + let sphere_mesh = octree.walk_dual(depth1_single_thread()); let mut edge_count = 0; for v in &sphere_mesh.vertices { @@ -1345,20 +1349,16 @@ mod test { fn test_sphere_manifold() { let shape = VmShape::from(sphere([0.0; 3], 0.85)); - for threads in [1, 8] { + for threads in + [ThreadCount::One, ThreadCount::Many(8.try_into().unwrap())] + { let settings = Settings { depth: 5, - threads: threads.try_into().unwrap(), + threads, ..Default::default() }; let octree = Octree::build(&shape, settings); let sphere_mesh = octree.walk_dual(settings); - sphere_mesh - .write_stl( - &mut std::fs::File::create(format!("sphere{threads}.stl")) - .unwrap(), - ) - .unwrap(); if let Err(e) = check_for_vertex_dupes(&sphere_mesh) { panic!("{e} (with {threads} threads)"); @@ -1373,8 +1373,8 @@ mod test { fn test_cube_verts() { let shape = VmShape::from(cube([-0.1, 0.6], [-0.2, 0.75], [-0.3, 0.4])); - let octree = Octree::build(&shape, DEPTH1_SINGLE_THREAD); - let mesh = octree.walk_dual(DEPTH1_SINGLE_THREAD); + let octree = Octree::build(&shape, depth1_single_thread()); + let mesh = octree.walk_dual(depth1_single_thread()); const EPSILON: f32 = 2.0 / u16::MAX as f32; assert!(!mesh.vertices.is_empty()); for v in &mesh.vertices { @@ -1422,7 +1422,7 @@ mod test { let (x, y, z) = Tree::axes(); let f = x * dx + y * dy + z + offset; let shape = VmShape::from(f); - let octree = Octree::build(&shape, DEPTH0_SINGLE_THREAD); + let octree = Octree::build(&shape, depth0_single_thread()); assert_eq!(octree.cells.len(), 8); let pos = octree.verts[0].pos; @@ -1466,7 +1466,7 @@ mod test { eval.eval(&tape, corner.x, corner.y, corner.z).unwrap(); assert!(v < 0.0, "bad corner value: {v}"); - let octree = Octree::build(&shape, DEPTH0_SINGLE_THREAD); + let octree = Octree::build(&shape, depth0_single_thread()); assert_eq!(octree.cells.len(), 8); assert_eq!(octree.verts.len(), 4); @@ -1478,7 +1478,7 @@ mod test { } } - fn test_mesh_manifold_inner(threads: usize, mask: u8) { + fn test_mesh_manifold_inner(threads: ThreadCount, mask: u8) { let mut shape = vec![]; for j in Corner::iter() { if mask & (1 << j.index()) != 0 { @@ -1500,7 +1500,7 @@ mod test { let shape = VmShape::from(shape); let settings = Settings { depth: 2, - threads: threads.try_into().unwrap(), + threads, ..Default::default() }; let octree = Octree::build(&shape, settings); @@ -1522,14 +1522,17 @@ mod test { #[test] fn test_mesh_manifold_single_thread() { for mask in 0..=255 { - test_mesh_manifold_inner(1, mask) + test_mesh_manifold_inner(ThreadCount::One, mask) } } #[test] fn test_mesh_manifold_multi_thread() { for mask in 0..=255 { - test_mesh_manifold_inner(8, mask) + test_mesh_manifold_inner( + ThreadCount::Many(8.try_into().unwrap()), + mask, + ) } } @@ -1542,26 +1545,26 @@ mod test { let shape = VmShape::from(shape); let eval = Arc::new(EvalGroup::new(shape)); let mut out = OctreeBuilder::new(); - out.recurse(&eval, CellIndex::default(), settings); + out.recurse(&eval, CellIndex::default(), settings.depth); out } let shape = sphere([0.0; 3], 0.1); - let octree = builder(shape, DEPTH1_SINGLE_THREAD); + let octree = builder(shape, depth1_single_thread()); assert!(!octree.collapsible(8)); let shape = sphere([-1.0; 3], 0.1); - let octree = builder(shape, DEPTH1_SINGLE_THREAD); + let octree = builder(shape, depth1_single_thread()); assert!(octree.collapsible(8)); let shape = sphere([-1.0, 0.0, 1.0], 0.1); - let octree = builder(shape, DEPTH1_SINGLE_THREAD); + let octree = builder(shape, depth1_single_thread()); assert!(!octree.collapsible(8)); let a = sphere([-1.0; 3], 0.1); let b = sphere([1.0; 3], 0.1); let shape = a.min(b); - let octree = builder(shape, DEPTH1_SINGLE_THREAD); + let octree = builder(shape, depth1_single_thread()); assert!(!octree.collapsible(8)); } @@ -1569,10 +1572,12 @@ mod test { fn test_empty_collapse() { // Make a very smol sphere that won't be sampled let shape = VmShape::from(sphere([0.1; 3], 0.05)); - for threads in [1, 4] { + for threads in + [ThreadCount::One, ThreadCount::Many(4.try_into().unwrap())] + { let settings = Settings { depth: 1, - threads: threads.try_into().unwrap(), + threads, ..Default::default() }; let octree = Octree::build(&shape, settings); @@ -1590,10 +1595,12 @@ mod test { let (ctx, root) = crate::Context::from_text(COLONNADE.as_bytes()).unwrap(); let tape = VmShape::new(&ctx, root).unwrap(); - for threads in [1, 8] { + for threads in + [ThreadCount::One, ThreadCount::Many(8.try_into().unwrap())] + { let settings = Settings { depth: 5, - threads: threads.try_into().unwrap(), + threads, ..Default::default() }; let octree = Octree::build(&tape, settings); @@ -1688,7 +1695,7 @@ mod test { let settings = Settings { depth: 4, - threads: 1.try_into().unwrap(), + threads: ThreadCount::One, ..Default::default() }; @@ -1700,14 +1707,14 @@ mod test { } #[test] - fn test_octree_bounds() { + fn test_octree_camera() { let shape = VmShape::from(sphere([1.0; 3], 0.25)); let center = Vector3::new(1.0, 1.0, 1.0); let settings = Settings { depth: 4, - threads: 1.try_into().unwrap(), - bounds: Bounds { size: 0.5, center }, + threads: ThreadCount::One, + view: View3::from_center_and_scale(center, 0.5), }; let octree = Octree::build(&shape, settings).walk_dual(settings); diff --git a/fidget/src/render/config.rs b/fidget/src/render/config.rs index 092c5bbf..12b5b9a5 100644 --- a/fidget/src/render/config.rs +++ b/fidget/src/render/config.rs @@ -1,184 +1,199 @@ use crate::{ eval::Function, - render::RenderMode, - shape::{Bounds, Shape, TileSizes}, - Error, -}; -use nalgebra::{ - allocator::Allocator, Const, DefaultAllocator, DimNameAdd, DimNameSub, - DimNameSum, U1, + render::{ImageSize, RenderMode, View2, View3, VoxelSize}, + shape::{Shape, TileSizes}, }; +use nalgebra::{Const, Matrix3, Matrix4, OPoint, Point2, Vector2}; use std::sync::atomic::{AtomicUsize, Ordering}; -/// Container to store render configuration (resolution, etc) -pub struct RenderConfig { - /// Image size (for a square output image) - pub image_size: usize, +/// Number of threads to use during evaluation +/// +/// In a WebAssembly build, only the [`ThreadCount::One`] variant is available. +#[derive(Copy, Clone, Debug)] +pub enum ThreadCount { + /// Perform all evaluation in the main thread, not spawning any workers + One, + + /// Spawn some number of worker threads for evaluation + /// + /// This can be set to `1`, in which case a single worker thread will be + /// spawned; this is different from doing work in the main thread, but not + /// particularly useful! + #[cfg(not(target_arch = "wasm32"))] + Many(std::num::NonZeroUsize), +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for ThreadCount { + fn from(v: std::num::NonZeroUsize) -> Self { + match v.get() { + 0 => unreachable!(), + 1 => ThreadCount::One, + _ => ThreadCount::Many(v), + } + } +} + +/// Single-threaded mode is shown as `-`; otherwise, an integer +impl std::fmt::Display for ThreadCount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ThreadCount::One => write!(f, "-"), + #[cfg(not(target_arch = "wasm32"))] + ThreadCount::Many(n) => write!(f, "{n}"), + } + } +} + +impl ThreadCount { + /// Gets the thread count + /// + /// Returns `None` if we are required to be single-threaded + pub fn get(&self) -> Option { + match self { + ThreadCount::One => None, + #[cfg(not(target_arch = "wasm32"))] + ThreadCount::Many(v) => Some(v.get()), + } + } +} + +impl Default for ThreadCount { + #[cfg(target_arch = "wasm32")] + fn default() -> Self { + Self::One + } + + #[cfg(not(target_arch = "wasm32"))] + fn default() -> Self { + Self::Many(std::num::NonZeroUsize::new(8).unwrap()) + } +} + +/// Settings for 2D rendering +pub struct ImageRenderConfig { + /// Render size + pub image_size: ImageSize, + + /// World-to-model transform + pub view: View2, /// Tile sizes to use during evaluation. /// /// You'll likely want to use /// [`RenderHints::tile_sizes_2d`](crate::shape::RenderHints::tile_sizes_2d) - /// or - /// [`RenderHints::tile_sizes_3d`](crate::shape::RenderHints::tile_sizes_3d) /// to select this based on evaluator type. pub tile_sizes: TileSizes, - /// Bounds of the rendered image, in shape coordinates - pub bounds: Bounds, - - /// Number of threads to use; 8 by default - #[cfg(not(target_arch = "wasm32"))] - pub threads: std::num::NonZeroUsize, + /// Number of worker threads + pub threads: ThreadCount, } -impl Default for RenderConfig { +impl Default for ImageRenderConfig { fn default() -> Self { Self { - image_size: 512, - tile_sizes: match N { - 2 => TileSizes::new(&[128, 32, 8]).unwrap(), - _ => TileSizes::new(&[128, 64, 32, 16, 8]).unwrap(), - }, - bounds: Default::default(), - - #[cfg(not(target_arch = "wasm32"))] - threads: std::num::NonZeroUsize::new(8).unwrap(), + image_size: ImageSize::from(512), + tile_sizes: TileSizes::new(&[128, 32, 8]).unwrap(), + view: View2::default(), + threads: ThreadCount::default(), } } } -impl RenderConfig -where - nalgebra::Const: nalgebra::DimNameAdd, - DefaultAllocator: - Allocator, U1>, DimNameSum, U1>>, - DefaultAllocator: - nalgebra::allocator::Allocator< - < as DimNameAdd>>::Output as DimNameSub< - Const<1>, - >>::Output, - >, - as DimNameAdd>>::Output: - DimNameSub>, -{ - /// Returns a `RenderConfig` where the image size is padded to an even - /// multiple of `tile_size`, and `mat` is populated based on image size. - pub(crate) fn align(&self) -> (AlignedRenderConfig, NPlusOneMatrix) { - // Filter out tile sizes that are larger than our image size - let mut tile_sizes: Vec = self - .tile_sizes - .iter() - .skip_while(|t| **t > self.image_size) - .cloned() - .collect(); - if tile_sizes.is_empty() { - tile_sizes.push(8); - } - let tile_sizes = TileSizes::new(&tile_sizes).unwrap(); - - // Pad image size to an even multiple of tile size. - let image_size = (self.image_size + tile_sizes[0] - 1) / tile_sizes[0] - * tile_sizes[0]; - - // Compensate for the image size change - let scale = image_size as f32 / self.image_size as f32; - - // Look, I'm not any happier about this than you are. - let v = nalgebra::OVector::< - f32, - < as DimNameAdd>>::Output as DimNameSub< - Const<1>, - >>::Output, - >::from_element(-1.0); - - // Build a matrix which transforms from pixel coordinates to [-1, +1] - let mut mat = - nalgebra::Transform::::identity() - .matrix() - .append_scaling(2.0 / image_size as f32) - .append_scaling(scale) - .append_translation(&v); - - // The bounds transform matrix goes from [-1, +1] to model coordinates - mat = self.bounds.transform().matrix() * mat; - - ( - AlignedRenderConfig { - image_size, - orig_image_size: self.image_size, - tile_sizes, - - #[cfg(not(target_arch = "wasm32"))] - threads: self.threads, - }, - mat, - ) +impl ImageRenderConfig { + /// Render a shape in 2D using this configuration + pub fn run( + &self, + shape: Shape, + ) -> Vec<::Output> { + crate::render::render2d::(shape, self) + } + + /// Returns the combined screen-to-model transform matrix + pub fn mat(&self) -> Matrix3 { + self.view.world_to_model() * self.image_size.screen_to_world() } } -//////////////////////////////////////////////////////////////////////////////// +/// Settings for 3D rendering +pub struct VoxelRenderConfig { + /// Render size + /// + /// The resulting image will have the given width and height; depth sets the + /// number of voxels to evaluate within each pixel of the image (stacked + /// into a column going into the screen). + pub image_size: VoxelSize, -#[derive(Debug)] -pub(crate) struct AlignedRenderConfig -where - nalgebra::Const: nalgebra::DimNameAdd, - DefaultAllocator: - Allocator, U1>, DimNameSum, U1>>, -{ - pub image_size: usize, - pub orig_image_size: usize, + /// World-to-model transform + pub view: View3, + /// Tile sizes to use during evaluation. + /// + /// You'll likely want to use + /// [`RenderHints::tile_sizes_3d`](crate::shape::RenderHints::tile_sizes_3d) + /// to select this based on evaluator type. pub tile_sizes: TileSizes, - #[cfg(not(target_arch = "wasm32"))] - pub threads: std::num::NonZeroUsize, + /// Number of worker threads + pub threads: ThreadCount, } -/// Type for a static `f32` matrix of size `N + 1` -type NPlusOneMatrix = nalgebra::OMatrix< - f32, - as DimNameAdd>>::Output, - as DimNameAdd>>::Output, ->; - -impl AlignedRenderConfig -where - nalgebra::Const: nalgebra::DimNameAdd, - DefaultAllocator: - Allocator, U1>, DimNameSum, U1>>, - as DimNameAdd>>::Output: DimNameSub>, -{ - #[inline] - pub fn tile_to_offset(&self, tile: Tile, x: usize, y: usize) -> usize { - tile.offset + x + y * self.tile_sizes[0] - } +impl Default for VoxelRenderConfig { + fn default() -> Self { + Self { + image_size: VoxelSize::from(512), + tile_sizes: TileSizes::new(&[128, 64, 32, 16, 8]).unwrap(), + view: View3::default(), - #[inline] - pub fn new_tile(&self, corner: [usize; N]) -> Tile { - let x = corner[0] % self.tile_sizes[0]; - let y = corner[1] % self.tile_sizes[0]; - Tile { - corner, - offset: x + y * self.tile_sizes[0], + threads: ThreadCount::default(), } } +} - #[cfg(target_arch = "wasm32")] - pub fn threads(&self) -> usize { - 1 +impl VoxelRenderConfig { + /// Render a shape in 3D using this configuration + /// + /// Returns a tuple of heightmap, RGB image. + pub fn run( + &self, + shape: Shape, + ) -> (Vec, Vec<[u8; 3]>) { + crate::render::render3d::(shape, self) } - #[cfg(not(target_arch = "wasm32"))] - pub fn threads(&self) -> usize { - self.threads.get() + /// Returns the combined screen-to-model transform matrix + pub fn mat(&self) -> Matrix4 { + self.view.world_to_model() * self.image_size.screen_to_world() + } + + /// Returns the data offset of a row within a subtile + pub(crate) fn tile_row_offset(&self, tile: Tile<3>, row: usize) -> usize { + self.tile_sizes.pixel_offset(tile.add(Vector2::new(0, row))) } } +//////////////////////////////////////////////////////////////////////////////// + #[derive(Copy, Clone, Debug)] pub(crate) struct Tile { - pub corner: [usize; N], - offset: usize, + /// Corner of this tile, in global screen (pixel) coordinates + pub corner: OPoint>, +} + +impl Tile { + /// Build a new tile from its global coordinates + #[inline] + pub(crate) fn new(corner: OPoint>) -> Tile { + Tile { corner } + } + + /// Converts a relative position within the tile into a global position + /// + /// This function operates in pixel space, using the `.xy` coordinates + pub(crate) fn add(&self, pos: Vector2) -> Point2 { + let corner = Point2::new(self.corner[0], self.corner[1]); + corner + pos + } } /// Worker queue @@ -200,150 +215,103 @@ impl Queue { } } -impl RenderConfig<2> { - /// High-level API for rendering shapes in 2D - /// - /// Under the hood, this delegates to - /// [`fidget::render::render2d`](crate::render::render2d()) - pub fn run( - &self, - shape: Shape, - ) -> Result::Output>, Error> { - Ok(crate::render::render2d::(shape, self)) - } -} - -impl RenderConfig<3> { - /// High-level API for rendering shapes in 2D - /// - /// Under the hood, this delegates to - /// [`fidget::render::render3d`](crate::render::render3d()) - /// - /// Returns a tuple of heightmap, RGB image. - pub fn run( - &self, - shape: Shape, - ) -> Result<(Vec, Vec<[u8; 3]>), Error> { - Ok(crate::render::render3d::(shape, self)) - } -} - //////////////////////////////////////////////////////////////////////////////// #[cfg(test)] mod test { use super::*; + use crate::render::ImageSize; use nalgebra::Point2; #[test] - fn test_aligned_config() { - // Simple alignment - let config: RenderConfig<2> = RenderConfig { - image_size: 512, - tile_sizes: TileSizes::new(&[64, 32]).unwrap(), + fn test_default_render_config() { + let config = ImageRenderConfig { + image_size: ImageSize::from(512), ..Default::default() }; - let (aligned, mat) = config.align(); - assert_eq!(aligned.image_size, config.image_size); - assert_eq!(aligned.tile_sizes, config.tile_sizes); - assert_eq!(aligned.threads, config.threads); + let mat = config.mat(); assert_eq!( - mat.transform_point(&Point2::new(0.0, 0.0)), - Point2::new(-1.0, -1.0) + mat.transform_point(&Point2::new(0.0, -1.0)), + Point2::new(-1.0, 1.0) ); assert_eq!( - mat.transform_point(&Point2::new(512.0, 0.0)), - Point2::new(1.0, -1.0) + mat.transform_point(&Point2::new(512.0, -1.0)), + Point2::new(1.0, 1.0) ); assert_eq!( - mat.transform_point(&Point2::new(512.0, 512.0)), - Point2::new(1.0, 1.0) + mat.transform_point(&Point2::new(512.0, 511.0)), + Point2::new(1.0, -1.0) ); - let config: RenderConfig<2> = RenderConfig { - image_size: 575, - tile_sizes: TileSizes::new(&[64, 32]).unwrap(), + let config = ImageRenderConfig { + image_size: ImageSize::from(575), ..Default::default() }; - let (aligned, mat) = config.align(); - assert_eq!(aligned.orig_image_size, 575); - assert_eq!(aligned.image_size, 576); - assert_eq!(aligned.tile_sizes, config.tile_sizes); - assert_eq!(aligned.threads, config.threads); + let mat = config.mat(); assert_eq!( - mat.transform_point(&Point2::new(0.0, 0.0)), - Point2::new(-1.0, -1.0) + mat.transform_point(&Point2::new(0.0, -1.0)), + Point2::new(-1.0, 1.0) ); assert_eq!( - mat.transform_point(&Point2::new(config.image_size as f32, 0.0)), - Point2::new(1.0, -1.0) + mat.transform_point(&Point2::new( + config.image_size.width() as f32, + -1.0 + )), + Point2::new(1.0, 1.0) ); assert_eq!( mat.transform_point(&Point2::new( - config.image_size as f32, - config.image_size as f32 + config.image_size.width() as f32, + config.image_size.height() as f32 - 1.0, )), - Point2::new(1.0, 1.0) + Point2::new(1.0, -1.0) ); } #[test] - fn test_bounded_config() { - // Simple alignment - let config: RenderConfig<2> = RenderConfig { - image_size: 512, - tile_sizes: TileSizes::new(&[64, 32]).unwrap(), - bounds: Bounds { - center: nalgebra::Vector2::new(0.5, 0.5), - size: 0.5, - }, - ..RenderConfig::default() + fn test_camera_render_config() { + let config = ImageRenderConfig { + image_size: ImageSize::from(512), + view: View2::from_center_and_scale( + nalgebra::Vector2::new(0.5, 0.5), + 0.5, + ), + ..Default::default() }; - let (aligned, mat) = config.align(); - assert_eq!(aligned.image_size, config.image_size); - assert_eq!(aligned.tile_sizes, config.tile_sizes); - assert_eq!(aligned.threads, config.threads); + let mat = config.mat(); assert_eq!( - mat.transform_point(&Point2::new(0.0, 0.0)), - Point2::new(0.0, 0.0) + mat.transform_point(&Point2::new(0.0, -1.0)), + Point2::new(0.0, 1.0) ); assert_eq!( - mat.transform_point(&Point2::new(512.0, 0.0)), - Point2::new(1.0, 0.0) + mat.transform_point(&Point2::new(512.0, -1.0)), + Point2::new(1.0, 1.0) ); assert_eq!( - mat.transform_point(&Point2::new(512.0, 512.0)), - Point2::new(1.0, 1.0) + mat.transform_point(&Point2::new(512.0, 511.0)), + Point2::new(1.0, 0.0) ); - let config: RenderConfig<2> = RenderConfig { - image_size: 575, - tile_sizes: TileSizes::new(&[64, 32]).unwrap(), - bounds: Bounds { - center: nalgebra::Vector2::new(0.5, 0.5), - size: 0.5, - }, - ..RenderConfig::default() + let config = ImageRenderConfig { + image_size: ImageSize::from(512), + view: View2::from_center_and_scale( + nalgebra::Vector2::new(0.5, 0.5), + 0.25, + ), + ..Default::default() }; - let (aligned, mat) = config.align(); - assert_eq!(aligned.orig_image_size, 575); - assert_eq!(aligned.image_size, 576); - assert_eq!(aligned.tile_sizes, config.tile_sizes); - assert_eq!(aligned.threads, config.threads); + let mat = config.mat(); assert_eq!( - mat.transform_point(&Point2::new(0.0, 0.0)), - Point2::new(0.0, 0.0) + mat.transform_point(&Point2::new(0.0, -1.0)), + Point2::new(0.25, 0.75) ); assert_eq!( - mat.transform_point(&Point2::new(config.image_size as f32, 0.0)), - Point2::new(1.0, 0.0) + mat.transform_point(&Point2::new(512.0, -1.0)), + Point2::new(0.75, 0.75) ); assert_eq!( - mat.transform_point(&Point2::new( - config.image_size as f32, - config.image_size as f32 - )), - Point2::new(1.0, 1.0) + mat.transform_point(&Point2::new(512.0, 511.0)), + Point2::new(0.75, 0.25) ); } } diff --git a/fidget/src/render/mod.rs b/fidget/src/render/mod.rs index e416fd33..938cc00f 100644 --- a/fidget/src/render/mod.rs +++ b/fidget/src/render/mod.rs @@ -1,9 +1,7 @@ //! 2D and 3D rendering //! -//! The easiest way to render something is with -//! [`RenderConfig::run`](RenderConfig::run); you can also use the lower-level -//! functions ([`render2d`](render2d()) and [`render3d`](render3d())) for manual -//! control over the input tape. +//! To render something, build a configuration object then call its `run` +//! function, e.g. [`ImageRenderConfig::run`] and [`VoxelRenderConfig::run`]. use crate::{ eval::{BulkEvaluator, Function, Trace, TracingEvaluator}, shape::{Shape, ShapeTape}, @@ -11,12 +9,17 @@ use crate::{ use std::sync::Arc; mod config; +mod region; mod render2d; mod render3d; +mod view; -pub use config::RenderConfig; -pub use render2d::render as render2d; -pub use render3d::render as render3d; +pub use config::{ImageRenderConfig, ThreadCount, VoxelRenderConfig}; +pub use region::{ImageSize, RegionSize, VoxelSize}; +pub use view::{View2, View3}; + +use render2d::render as render2d; +use render3d::render as render3d; pub use render2d::{ BitRenderMode, DebugRenderMode, RenderMode, SdfPixelRenderMode, diff --git a/fidget/src/render/region.rs b/fidget/src/render/region.rs new file mode 100644 index 00000000..66dcbc70 --- /dev/null +++ b/fidget/src/render/region.rs @@ -0,0 +1,198 @@ +use nalgebra::{ + allocator::Allocator, Const, DefaultAllocator, DimNameAdd, DimNameSub, + DimNameSum, OMatrix, OVector, Vector2, Vector3, U1, +}; + +/// Image size in pixels, used to generate a screen-to-world matrix +/// +/// The screen coordinate space is the following: +/// +/// ```text +/// 0 ------------> width +/// | | +/// | | +/// | | +/// V-------------- +/// height +/// ``` +/// +/// +/// The map from screen to world coordinates (generated by +/// [`screen_to_world`](RegionSize::screen_to_world)) is as following: +/// +/// ```text +/// -1 y = +1 +/// 0-------------^-------------> width +/// | | | +/// | | | +/// | | | +/// x = -1 <-----------0-------------> x = +1 +/// | | | +/// | | | +/// | V | +/// V---------- y = -1 --------- +/// height +/// ``` +/// +/// (with `+z` pointing out of the screen) +/// +/// Note that the Y axis is reversed between screen and world coordinates: +/// screen coordinates have `+y` pointing down, but world coordinates have it +/// pointing up. For both X and Y coordinates, the `+1` value is located one +/// pixel beyond the edge of the screen region (off the right edge for X, and +/// off the top edge for Y). +/// +/// If the render region is not square, then the shorter axis is clamped to ±1 +/// and the longer axis will exceed that value. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct RegionSize +where + Const: DimNameAdd, + DefaultAllocator: Allocator, U1>, DimNameSum, U1>>, + DefaultAllocator: Allocator<< as DimNameAdd>>::Output as DimNameSub>>::Output>, + as DimNameAdd>>::Output: DimNameSub>, + OVector as DimNameAdd>>::Output as DimNameSub>>::Output>: Copy, +{ + size: OVector as DimNameAdd>>::Output as DimNameSub>>::Output>, +} + +/// Apologies for the terrible trait bounds; they're necessary to persuade the +/// internals to type-check, but shouldn't be noticeable to library users. +impl RegionSize +where + Const: DimNameAdd, + DefaultAllocator: Allocator, U1>, DimNameSum, U1>>, + DefaultAllocator: Allocator<< as DimNameAdd>>::Output as DimNameSub>>::Output>, + as DimNameAdd>>::Output: DimNameSub>, + as DimNameAdd>>::Output as DimNameSub>>::Output>>::Buffer: std::marker::Copy, +{ + /// Builds a matrix that converts from screen to world coordinates + /// + /// See the [`struct` docstring](RegionSize) for a diagram of this mapping. + pub fn screen_to_world( + &self, + ) -> OMatrix< + f32, + as DimNameAdd>>::Output, + as DimNameAdd>>::Output, + > { + let mut center = self.size.cast::() / 2.0; + center[1] -= 1.0; + let scale = 2.0 / self.size.min() as f32; + + let mut out = OMatrix::< + f32, + as DimNameAdd>>::Output, + as DimNameAdd>>::Output, + >::identity(); + out.append_translation_mut(&(-center)); + let mut scale = OVector::::from_element(scale); + scale[1] *= -1.0; + out.append_nonuniform_scaling_mut(&scale); + out + } + + /// Returns the width of the image (in pixels or voxels) + pub fn width(&self) -> u32 { + self.size[0] + } + + /// Returns the height of the image (in pixels or voxels) + pub fn height(&self) -> u32 { + self.size[1] + } +} + +/// Builds a `RegionSize` with the same dimension on all axes +impl From for RegionSize +where + Const: DimNameAdd, + DefaultAllocator: Allocator, U1>, DimNameSum, U1>>, + DefaultAllocator: Allocator<< as DimNameAdd>>::Output as DimNameSub>>::Output>, + as DimNameAdd>>::Output: DimNameSub>, + as DimNameAdd>>::Output as DimNameSub>>::Output>>::Buffer: std::marker::Copy, +{ + fn from(v: u32) -> Self { + Self { + size: OVector::< + u32, + < as DimNameAdd>>::Output as DimNameSub< + Const<1>, + >>::Output, + >::from_element(v) + } + } +} + +/// Size for 2D rendering of an image +pub type ImageSize = RegionSize<2>; +impl ImageSize { + /// Builds a new `ImageSize` object from width and height in pixels + pub fn new(width: u32, height: u32) -> Self { + Self { + size: Vector2::new(width, height), + } + } +} + +/// Size for 3D rendering of an image +pub type VoxelSize = RegionSize<3>; +impl VoxelSize { + /// Builds a new `VoxelSize` object from width, height, and depth in voxels + pub fn new(width: u32, height: u32, depth: u32) -> Self { + Self { + size: Vector3::new(width, height, depth), + } + } + + /// Returns the depth of the image (in voxels) + pub fn depth(&self) -> u32 { + self.size.z + } +} + +impl std::ops::Index for RegionSize +where + Const: DimNameAdd, + DefaultAllocator: Allocator, U1>, DimNameSum, U1>>, + DefaultAllocator: Allocator<< as DimNameAdd>>::Output as DimNameSub>>::Output>, + as DimNameAdd>>::Output: DimNameSub>, + OVector as DimNameAdd>>::Output as DimNameSub>>::Output>: Copy, +{ + type Output = u32; + fn index(&self, i: usize) -> &Self::Output { + &self.size[i] + } +} + +#[cfg(test)] +mod test { + use super::*; + use nalgebra::Point2; + + #[test] + fn test_screen_size() { + let image_size = ImageSize::new(1000, 500); + let mat = image_size.screen_to_world(); + + let pt = mat.transform_point(&Point2::new(500.0, 249.0)); + assert_eq!(pt.x, 0.0); + assert_eq!(pt.y, 0.0); + + let pt = mat.transform_point(&Point2::new(500.0, -1.0)); + assert_eq!(pt.x, 0.0); + assert_eq!(pt.y, 1.0); + + let pt = mat.transform_point(&Point2::new(500.0, 499.0)); + assert_eq!(pt.x, 0.0); + assert_eq!(pt.y, -1.0); + + let pt = mat.transform_point(&Point2::new(0.0, 249.0)); + assert_eq!(pt.x, -2.0); + assert_eq!(pt.y, 0.0); + + let pt = mat.transform_point(&Point2::new(1000.0, 249.0)); + assert_eq!(pt.x, 2.0); + assert_eq!(pt.y, 0.0); + } +} diff --git a/fidget/src/render/render2d.rs b/fidget/src/render/render2d.rs index 706bb58f..dbbf938e 100644 --- a/fidget/src/render/render2d.rs +++ b/fidget/src/render/render2d.rs @@ -2,11 +2,12 @@ use super::RenderHandle; use crate::{ eval::Function, - render::config::{AlignedRenderConfig, Queue, RenderConfig, Tile}, + render::config::{ImageRenderConfig, Queue, Tile}, + render::ThreadCount, shape::{Shape, ShapeBulkEval, ShapeTracingEval}, types::Interval, }; -use nalgebra::Point2; +use nalgebra::{Point2, Vector2}; //////////////////////////////////////////////////////////////////////////////// @@ -202,7 +203,7 @@ impl Scratch { /// Per-thread worker struct Worker<'a, F: Function, M: RenderMode> { - config: &'a AlignedRenderConfig<2>, + config: &'a ImageRenderConfig, scratch: Scratch, eval_float_slice: ShapeBulkEval, @@ -217,6 +218,9 @@ struct Worker<'a, F: Function, M: RenderMode> { /// Workspace for shape simplification workspace: F::Workspace, + /// Tile being rendered + /// + /// This is a root tile, i.e. width and height of `config.tile_sizes[0]` image: Vec, } @@ -229,12 +233,13 @@ impl Worker<'_, F, M> { ) { let tile_size = self.config.tile_sizes[depth]; - // Brute-force way to find the (interval) bounding box of the region + // Find the interval bounds of the region, in screen coordinates let base = Point2::from(tile.corner).cast::(); let x = Interval::new(base.x, base.x + tile_size as f32); let y = Interval::new(base.y, base.y + tile_size as f32); let z = Interval::new(0.0, 0.0); + // The shape applies the screen-to-model transform let (i, simplify) = self .eval_interval .eval(shape.i_tape(&mut self.tape_storage), x, y, z) @@ -243,7 +248,10 @@ impl Worker<'_, F, M> { match M::interval(i, depth) { IntervalAction::Fill(fill) => { for y in 0..tile_size { - let start = self.config.tile_to_offset(tile, 0, y); + let start = self + .config + .tile_sizes + .pixel_offset(tile.add(Vector2::new(0, y))); self.image[start..][..tile_size].fill(fill); } return; @@ -256,6 +264,7 @@ impl Worker<'_, F, M> { .eval_float_slice .eval(shape.f_tape(&mut self.tape_storage), &xs, &ys, &zs) .unwrap(); + // Bilinear interpolation on a per-pixel basis for y in 0..tile_size { // Y interpolation @@ -263,7 +272,10 @@ impl Worker<'_, F, M> { let v0 = vs[0] * (1.0 - y_frac) + vs[1] * y_frac; let v1 = vs[2] * (1.0 - y_frac) + vs[3] * y_frac; - let mut i = self.config.tile_to_offset(tile, 0, y); + let mut i = self + .config + .tile_sizes + .pixel_offset(tile.add(Vector2::new(0, y))); for x in 0..tile_size { // X interpolation let x_frac = (x as f32 - 1.0) / (tile_size as f32); @@ -297,10 +309,9 @@ impl Worker<'_, F, M> { self.render_tile_recurse( sub_tape, depth + 1, - self.config.new_tile([ - tile.corner[0] + i * next_tile_size, - tile.corner[1] + j * next_tile_size, - ]), + Tile::new( + tile.corner + Vector2::new(i, j) * next_tile_size, + ), ); } } @@ -336,7 +347,10 @@ impl Worker<'_, F, M> { let mut index = 0; for j in 0..tile_size { - let o = self.config.tile_to_offset(tile, 0, j); + let o = self + .config + .tile_sizes + .pixel_offset(tile.add(Vector2::new(0, j))); for i in 0..tile_size { self.image[o + i] = M::pixel(out[index]); index += 1; @@ -350,7 +364,7 @@ impl Worker<'_, F, M> { fn worker( mut shape: RenderHandle, queue: &Queue<2>, - config: &AlignedRenderConfig<2>, + config: &ImageRenderConfig, ) -> Vec<(Tile<2>, Vec)> { let mut out = vec![]; let scratch = Scratch::new(config.tile_sizes.last().pow(2)); @@ -387,12 +401,10 @@ fn worker( /// resulting pixels). pub fn render( shape: Shape, - config: &RenderConfig<2>, + config: &ImageRenderConfig, ) -> Vec { - let (config, mat) = config.align(); - assert!(config.image_size % config.tile_sizes[0] == 0); - // Convert to a 4x4 matrix and apply to the shape + let mat = config.mat(); let mat = mat.insert_row(2, 0.0); let mat = mat.insert_column(2, 0.0); let shape = shape.apply_transform(mat); @@ -402,57 +414,55 @@ pub fn render( fn render_inner( shape: Shape, - config: AlignedRenderConfig<2>, + config: &ImageRenderConfig, ) -> Vec { let mut tiles = vec![]; - for i in 0..config.image_size / config.tile_sizes[0] { - for j in 0..config.image_size / config.tile_sizes[0] { - tiles.push(config.new_tile([ + let t = config.tile_sizes[0]; + let width = config.image_size.width() as usize; + let height = config.image_size.height() as usize; + for i in 0..width.div_ceil(t) { + for j in 0..height.div_ceil(t) { + tiles.push(Tile::new(Point2::new( i * config.tile_sizes[0], j * config.tile_sizes[0], - ])); + ))); } } let queue = Queue::new(tiles); - let threads = config.threads(); let mut rh = RenderHandle::new(shape); let _ = rh.i_tape(&mut vec![]); // populate i_tape before cloning - let out: Vec<_> = if threads == 1 { - worker::(rh, &queue, &config).into_iter().collect() - } else { - #[cfg(target_arch = "wasm32")] - unreachable!("multithreaded rendering is not supported on wasm32"); + let out: Vec<_> = match config.threads { + ThreadCount::One => { + worker::(rh, &queue, config).into_iter().collect() + } #[cfg(not(target_arch = "wasm32"))] - std::thread::scope(|s| { + ThreadCount::Many(v) => std::thread::scope(|s| { let mut handles = vec![]; - for _ in 0..threads { + for _ in 0..v.get() { let rh = rh.clone(); - handles.push(s.spawn(|| worker::(rh, &queue, &config))); + handles.push(s.spawn(|| worker::(rh, &queue, config))); } let mut out = vec![]; for h in handles { out.extend(h.join().unwrap().into_iter()); } out - }) + }), }; - let mut image = vec![M::Output::default(); config.orig_image_size.pow(2)]; + let mut image = vec![M::Output::default(); width * height]; for (tile, data) in out.iter() { let mut index = 0; for j in 0..config.tile_sizes[0] { - let y = j + tile.corner[1]; + let y = j + tile.corner.y; for i in 0..config.tile_sizes[0] { - let x = i + tile.corner[0]; - if y < config.orig_image_size && x < config.orig_image_size { - let o = (config.orig_image_size - y - 1) - * config.orig_image_size - + x; - image[o] = data[index]; + let x = i + tile.corner.x; + if y < height && x < width { + image[y * width + x] = data[index]; } index += 1; } @@ -466,7 +476,8 @@ mod test { use super::*; use crate::{ eval::{Function, MathFunction}, - shape::{Bounds, Shape}, + render::{ImageSize, View2}, + shape::Shape, vm::{GenericVmFunction, VmFunction}, Context, }; @@ -478,20 +489,22 @@ mod test { "/../models/quarter.vm" )); - fn render_and_compare_with_bounds( + fn render_and_compare_with_view( shape: Shape, expected: &'static str, - bounds: Bounds<2>, + view: View2, + wide: bool, ) { - let cfg = RenderConfig::<2> { - image_size: 32, - bounds, - ..RenderConfig::default() + let width = if wide { 64 } else { 32 }; + let cfg = ImageRenderConfig { + image_size: ImageSize::new(width, 32), + view, + ..Default::default() }; - let out = cfg.run::<_, BitRenderMode>(shape).unwrap(); + let out = cfg.run::<_, BitRenderMode>(shape); let mut img_str = String::new(); for (i, b) in out.iter().enumerate() { - if i % 32 == 0 { + if i % width as usize == 0 { img_str += "\n "; } img_str.push(if *b { 'X' } else { '.' }); @@ -511,7 +524,14 @@ mod test { shape: Shape, expected: &'static str, ) { - render_and_compare_with_bounds(shape, expected, Bounds::default()) + render_and_compare_with_view(shape, expected, View2::default(), false) + } + + fn render_and_compare_wide( + shape: Shape, + expected: &'static str, + ) { + render_and_compare_with_view(shape, expected, View2::default(), true) } fn check_hi() { @@ -553,6 +573,45 @@ mod test { render_and_compare(shape, EXPECTED); } + fn check_hi_wide() { + let (ctx, root) = Context::from_text(HI.as_bytes()).unwrap(); + let shape = Shape::::new(&ctx, root).unwrap(); + const EXPECTED: &str = " + .................................X.............................. + .................................X.............................. + .................................X.............................. + .................................X..........XX.................. + .................................X..........XX.................. + .................................X.............................. + .................................X.............................. + .................................XXXXXX.....XX.................. + .................................XXX..XX....XX.................. + .................................XX....XX...XX.................. + .................................X......X...XX.................. + .................................X......X...XX.................. + .................................X......X...XX.................. + .................................X......X...XX.................. + .................................X......X...XX.................. + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................ + ................................................................"; + render_and_compare_wide(shape, EXPECTED); + } + fn check_hi_transformed() { let (ctx, root) = Context::from_text(HI.as_bytes()).unwrap(); let shape = Shape::::new(&ctx, root).unwrap(); @@ -632,13 +691,11 @@ mod test { .XXX...........XXX......XXX..... .XXX...........XXX......XXX..... ................................"; - render_and_compare_with_bounds( + render_and_compare_with_view( shape, EXPECTED, - Bounds { - center: nalgebra::Vector2::new(0.5, 0.5), - size: 0.5, - }, + View2::from_center_and_scale(nalgebra::Vector2::new(0.5, 0.5), 0.5), + false, ); } @@ -697,6 +754,22 @@ mod test { check_hi::(); } + #[test] + fn render_hi_wide_vm() { + check_hi_wide::(); + } + + #[test] + fn render_hi_wide_vm3() { + check_hi_wide::>(); + } + + #[cfg(feature = "jit")] + #[test] + fn render_hi_wide_jit() { + check_hi_wide::(); + } + #[test] fn render_hi_transformed_vm() { check_hi_transformed::(); diff --git a/fidget/src/render/render3d.rs b/fidget/src/render/render3d.rs index a4bbb245..01160c61 100644 --- a/fidget/src/render/render3d.rs +++ b/fidget/src/render/render3d.rs @@ -2,12 +2,12 @@ use super::RenderHandle; use crate::{ eval::Function, - render::config::{AlignedRenderConfig, Queue, RenderConfig, Tile}, + render::config::{Queue, ThreadCount, Tile, VoxelRenderConfig}, shape::{Shape, ShapeBulkEval, ShapeTracingEval}, types::{Grad, Interval}, }; -use nalgebra::Point3; +use nalgebra::{Point3, Vector2, Vector3}; use std::collections::HashMap; //////////////////////////////////////////////////////////////////////////////// @@ -46,7 +46,7 @@ impl Scratch { //////////////////////////////////////////////////////////////////////////////// struct Worker<'a, F: Function> { - config: &'a AlignedRenderConfig<3>, + config: &'a VoxelRenderConfig, /// Reusable workspace for evaluation, to minimize allocation scratch: Scratch, @@ -75,7 +75,7 @@ impl Worker<'_, F> { let tile_size = self.config.tile_sizes[depth]; let fill_z = (tile.corner[2] + tile_size + 1).try_into().unwrap(); if (0..tile_size).all(|y| { - let i = self.config.tile_to_offset(tile, 0, y); + let i = self.config.tile_row_offset(tile, y); (0..tile_size).all(|x| self.depth[i + x] >= fill_z) }) { return; @@ -95,7 +95,7 @@ impl Worker<'_, F> { // `data_interval` to scratch memory for reuse. if i.upper() < 0.0 { for y in 0..tile_size { - let i = self.config.tile_to_offset(tile, 0, y); + let i = self.config.tile_row_offset(tile, y); for x in 0..tile_size { self.depth[i + x] = self.depth[i + x].max(fill_z); } @@ -127,11 +127,10 @@ impl Worker<'_, F> { self.render_tile_recurse( sub_tape, depth + 1, - self.config.new_tile([ - tile.corner[0] + i * next_tile_size, - tile.corner[1] + j * next_tile_size, - tile.corner[2] + k * next_tile_size, - ]), + Tile::new( + tile.corner + + Vector3::new(i, j, k) * next_tile_size, + ), ); } } @@ -157,7 +156,11 @@ impl Worker<'_, F> { for xy in 0..tile_size.pow(2) { let i = xy % tile_size; let j = xy / tile_size; - let o = self.config.tile_to_offset(tile, i, j); + + let o = self + .config + .tile_sizes + .pixel_offset(tile.add(Vector2::new(i, j))); // Skip pixels which are behind the image let zmax = (tile.corner[2] + tile_size).try_into().unwrap(); @@ -222,7 +225,10 @@ impl Worker<'_, F> { let k = tile_size - 1 - k; // Set the depth of the pixel - let o = self.config.tile_to_offset(tile, i, j); + let o = self + .config + .tile_sizes + .pixel_offset(tile.add(Vector2::new(i, j))); let z = (tile.corner[2] + k + 1).try_into().unwrap(); assert!(self.depth[o] < z); self.depth[o] = z; @@ -286,7 +292,7 @@ fn worker( mut shape: RenderHandle, queues: &[Queue<3>], mut index: usize, - config: &AlignedRenderConfig<3>, + config: &VoxelRenderConfig, ) -> HashMap<[usize; 2], Image> { let mut out = HashMap::new(); @@ -354,34 +360,34 @@ fn worker( /// perform evaluation. pub fn render( shape: Shape, - config: &RenderConfig<3>, + config: &VoxelRenderConfig, ) -> (Vec, Vec<[u8; 3]>) { - let (config, mat) = config.align(); - assert!(config.image_size % config.tile_sizes[0] == 0); - - let shape = shape.apply_transform(mat); + let shape = shape.apply_transform(config.mat()); render_inner(shape, config) } pub fn render_inner( shape: Shape, - config: AlignedRenderConfig<3>, + config: &VoxelRenderConfig, ) -> (Vec, Vec<[u8; 3]>) { let mut tiles = vec![]; - for i in 0..config.image_size / config.tile_sizes[0] { - for j in 0..config.image_size / config.tile_sizes[0] { - for k in (0..config.image_size / config.tile_sizes[0]).rev() { - tiles.push(config.new_tile([ + let t = config.tile_sizes[0]; + let width = config.image_size[0] as usize; + let height = config.image_size[1] as usize; + let depth = config.image_size[2] as usize; + for i in 0..width.div_ceil(t) { + for j in 0..height.div_ceil(t) { + for k in (0..depth.div_ceil(t)).rev() { + tiles.push(Tile::new(Point3::new( i * config.tile_sizes[0], j * config.tile_sizes[0], k * config.tile_sizes[0], - ])); + ))); } } } - let threads = config.threads(); - + let threads = config.threads.get().unwrap_or(1); let tiles_per_thread = (tiles.len() / threads).max(1); let mut tile_queues = vec![]; for ts in tiles.chunks(tiles_per_thread) { @@ -393,20 +399,17 @@ pub fn render_inner( let _ = rh.i_tape(&mut vec![]); // populate i_tape before cloning // Special-case for single-threaded operation, to give simpler backtraces - let out: Vec<_> = if threads == 1 { - worker::(rh, tile_queues.as_slice(), 0, &config) + let out: Vec<_> = match config.threads { + ThreadCount::One => worker::(rh, tile_queues.as_slice(), 0, config) .into_iter() - .collect() - } else { - #[cfg(target_arch = "wasm32")] - unreachable!("multithreaded rendering is not supported on wasm32"); + .collect(), #[cfg(not(target_arch = "wasm32"))] - std::thread::scope(|s| { + ThreadCount::Many(threads) => std::thread::scope(|s| { let config = &config; let mut handles = vec![]; let queues = tile_queues.as_slice(); - for i in 0..threads { + for i in 0..threads.get() { let rh = rh.clone(); handles .push(s.spawn(move || worker::(rh, queues, i, config))); @@ -416,21 +419,19 @@ pub fn render_inner( out.extend(h.join().unwrap().into_iter()); } out - }) + }), }; - let mut image_depth = vec![0; config.orig_image_size.pow(2)]; - let mut image_color = vec![[0; 3]; config.orig_image_size.pow(2)]; + let mut image_depth = vec![0; width * height]; + let mut image_color = vec![[0; 3]; width * height]; for (tile, patch) in out.iter() { let mut index = 0; for j in 0..config.tile_sizes[0] { let y = j + tile[1]; for i in 0..config.tile_sizes[0] { let x = i + tile[0]; - if x < config.orig_image_size && y < config.orig_image_size { - let o = (config.orig_image_size - y - 1) - * config.orig_image_size - + x; + if x < width && y < height { + let o = y * width + x; if patch.depth[index] >= image_depth[o] { image_color[o] = patch.color[index]; image_depth[o] = patch.depth[index]; @@ -446,7 +447,7 @@ pub fn render_inner( #[cfg(test)] mod test { use super::*; - use crate::{vm::VmShape, Context}; + use crate::{render::VoxelSize, vm::VmShape, Context}; /// Make sure we don't crash if there's only a single tile #[test] @@ -455,11 +456,12 @@ mod test { let x = ctx.x(); let shape = VmShape::new(&ctx, x).unwrap(); - let cfg = RenderConfig::<3> { - image_size: 128, // very small! - ..RenderConfig::default() + let cfg = VoxelRenderConfig { + image_size: VoxelSize::from(128), // very small! + ..Default::default() }; - let out = cfg.run(shape); - assert!(out.is_ok()); + let (depth, rgb) = cfg.run(shape); + assert_eq!(depth.len(), 128 * 128); + assert_eq!(rgb.len(), 128 * 128); } } diff --git a/fidget/src/render/view.rs b/fidget/src/render/view.rs new file mode 100644 index 00000000..e34b3681 --- /dev/null +++ b/fidget/src/render/view.rs @@ -0,0 +1,126 @@ +use nalgebra::{ + geometry::{Similarity2, Similarity3}, + Matrix3, Matrix4, Point2, Vector2, Vector3, +}; + +/// Object providing a view-to-model transform in 2D +/// +/// Rendering and meshing happen in the ±1 square or cube; these are referred to +/// as _world_ coordinates. A `Camera` generates a homogeneous transform matrix +/// that maps from positions in world coordinates to _model_ coordinates, which +/// can be whatever you want. +/// +/// Here's an example of using a `View2` to focus on the region `[4, 6]`: +/// +/// ``` +/// # use nalgebra::{Vector2, Point2}; +/// # use fidget::render::{View2}; +/// let view = View2::from_center_and_scale(Vector2::new(5.0, 5.0), 1.0); +/// +/// // -------d------- +/// // | | +/// // | | +/// // c a b +/// // | | +/// // | | +/// // -------e------- +/// let a = view.transform_point(&Point2::new(0.0, 0.0)); +/// assert_eq!(a, Point2::new(5.0, 5.0)); +/// +/// let b = view.transform_point(&Point2::new(1.0, 0.0)); +/// assert_eq!(b, Point2::new(6.0, 5.0)); +/// +/// let c = view.transform_point(&Point2::new(-1.0, 0.0)); +/// assert_eq!(c, Point2::new(4.0, 5.0)); +/// +/// let d = view.transform_point(&Point2::new(0.0, 1.0)); +/// assert_eq!(d, Point2::new(5.0, 6.0)); +/// +/// let e = view.transform_point(&Point2::new(0.0, -1.0)); +/// assert_eq!(e, Point2::new(5.0, 4.0)); +/// ``` +/// +/// See also +/// [`RegionSize::screen_to_world`](crate::render::RegionSize::screen_to_world), +/// which converts from screen to world coordinates. +#[derive(Copy, Clone, Debug)] +pub struct View2 { + mat: Similarity2, +} + +impl Default for View2 { + fn default() -> Self { + Self { + mat: Similarity2::identity(), + } + } +} + +impl View2 { + /// Builds a camera from a center (in world coordinates) and a scale + /// + /// The resulting camera will point at the center, and the viewport will be + /// ± `scale` in size. + pub fn from_center_and_scale(center: Vector2, scale: f32) -> Self { + let mat = + Similarity2::from_parts(center.into(), Default::default(), scale); + Self { mat } + } + /// Returns the world-to-model transform matrix + pub fn world_to_model(&self) -> Matrix3 { + self.mat.into() + } + /// Transform a point from world to model space + pub fn transform_point(&self, p: &Point2) -> Point2 { + self.mat.transform_point(p) + } + + /// Applies a translation (in model units) to the current camera position + pub fn translate(&mut self, dt: Vector2) { + self.mat.append_translation_mut(&dt.into()); + } + + /// Zooms the camera about a particular position (in model space) + pub fn zoom(&mut self, amount: f32, pos: Option>) { + match pos { + Some(before) => { + // Convert to world space before scaling + let p = self.mat.inverse_transform_point(&before); + self.mat.append_scaling_mut(amount); + let pos_after = self.transform_point(&p); + self.mat + .append_translation_mut(&(before - pos_after).into()); + } + None => { + self.mat.append_scaling_mut(amount); + } + } + } +} + +/// Object providing a view-to-model transform in 2D +#[derive(Copy, Clone, Debug)] +pub struct View3 { + mat: Similarity3, +} + +impl Default for View3 { + fn default() -> Self { + Self { + mat: Similarity3::identity(), + } + } +} + +#[allow(missing_docs)] +/// See [`View2`] for docstrings +impl View3 { + pub fn from_center_and_scale(center: Vector3, scale: f32) -> Self { + let mat = + Similarity3::from_parts(center.into(), Default::default(), scale); + Self { mat } + } + pub fn world_to_model(&self) -> Matrix4 { + self.mat.into() + } +}