diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5a9cecd..299ba7e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -199,7 +199,7 @@ is committed and listed here with its exports. | clippy.toml, rustfmt.toml | SolidCraft | MIT OR Apache-2.0 | | crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 | | crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 | -| crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 | +| crates/gpu/src/view.rs (viewport pipelines and shaders), crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 | | crates/ui-egui/src/navigation.rs (navigation presets and bindings), crates/ui-egui/src/viewport.rs (orbit about the point under the cursor, or the drawn centre from empty space; Ctrl + left drag orbits) | SolidCraft `crates/ui-egui/src/prefs.rs` (`NavPreset`, `NavBinding`, `NAV_PRESETS`, `orbit`) | MIT OR Apache-2.0 | | crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 | | crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index c0a0482..e241975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1367,6 +1367,17 @@ dependencies = [ "zip 9.0.3", ] +[[package]] +name = "fieldcraft-gpu" +version = "0.6.0" +dependencies = [ + "fieldcraft-render", + "log", + "pollster 0.4.0", + "thiserror 2.0.21", + "wgpu", +] + [[package]] name = "fieldcraft-io" version = "0.6.0" @@ -1473,6 +1484,7 @@ dependencies = [ "fieldcraft-engine", "fieldcraft-explicit", "fieldcraft-geometry", + "fieldcraft-gpu", "fieldcraft-model", "fieldcraft-render", "fieldcraft-update", diff --git a/Cargo.toml b/Cargo.toml index 4eb7a3e..b094ea3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ fieldcraft-render = { path = "crates/render" } fieldcraft-ui-egui = { path = "crates/ui-egui" } fieldcraft-mcp = { path = "crates/mcp" } fieldcraft-update = { path = "crates/update" } +fieldcraft-gpu = { path = "crates/gpu" } # foundation thiserror = "2" @@ -51,6 +52,9 @@ resvg = { version = "0.48", default-features = false } egui = "0.36" eframe = { version = "0.36", default-features = false } egui-wgpu = "0.36" +# the wgpu egui-wgpu uses, for the offscreen viewport (crates/gpu); backends come per target there +wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] } +pollster = "0.4" # Shared Craft-family egui components (icon buttons, tabs, widget palette); the same rev as # VectorCraft. MIT OR Apache-2.0. craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675" } diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml new file mode 100644 index 0000000..4f0e24e --- /dev/null +++ b/crates/gpu/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "fieldcraft-gpu" +description = "FieldCraft's GPU viewport pipelines (lit faces, wide lines) and offscreen captures: the window's picture, drawn without a window" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +fieldcraft-render = { workspace = true } +wgpu = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +pollster = { workspace = true } +# Native backends, so the CLI and the tests open a device without a window. +wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] } diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs new file mode 100644 index 0000000..198a6b0 --- /dev/null +++ b/crates/gpu/src/capture.rs @@ -0,0 +1,285 @@ +//! Offscreen pictures with the viewport's pipelines ([`crate::view`]): the scene, the setup's +//! symbols and the overlay drawn with the window's MSAA and depth test into a texture, copied back +//! and laid over the viewport's background gradient. Every GPU call sits inside error scopes, +//! so a device error (out of memory, validation) returns as an error, never a panic. + +use std::sync::Arc; + +use fieldcraft_render::{Camera, Rgb, Scene}; + +use crate::select::{self, Policy, Tier}; +use crate::view::{Frame, GpuScene, GpuTarget, Layer, Pipelines}; +use crate::{GpuError, Result}; + +/// Largest picture side and pixel count drawn (the CPU rasterizer's limits). +pub const MAX_SIDE: u32 = fieldcraft_render::MAX_DIM as u32; +pub const MAX_PIXELS: u64 = fieldcraft_render::MAX_PIXELS as u64; +/// The viewport's MSAA sample count, when the device offers it. +pub const SAMPLES: u32 = 4; +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; +/// The background when a scene gives none (the CPU rasterizer's). +const BACKGROUND: (Rgb, Rgb) = (Rgb(236, 240, 245), Rgb(205, 212, 222)); + +/// A device ready to draw pictures. +pub struct Capturer { + device: wgpu::Device, + queue: wgpu::Queue, + pipelines: Pipelines, + target: GpuTarget, + /// The adapter's name and tier, for reports. + pub adapter: String, + pub tier: Tier, +} + +/// The error a popped error scope caught: a native device answers once it has been polled. +fn popped(f: impl std::future::Future>) -> Option { + let mut f = std::pin::pin!(f); + match f.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(e) => e, + std::task::Poll::Pending => None, + } +} + +impl Capturer { + /// The capturer `policy` picks ([`select::adapter`]); `Ok(None)` for the CPU rasterizer. + pub fn open(policy: Policy) -> Result> { + match select::adapter(policy)? { + Some((adapter, tier)) => Capturer::new(&adapter, tier).map(Some), + None => Ok(None), + } + } + + /// A device on `adapter` with the viewport's pipelines. + pub fn new(adapter: &wgpu::Adapter, tier: Tier) -> Result { + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("fieldcraft capture"), + required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), + ..Default::default() + })) + .map_err(|e| GpuError::Device(e.to_string()))?; + // Errors outside a scope are logged, replacing wgpu's default handler, which panics. + device.on_uncaptured_error(Arc::new(|e: wgpu::Error| log::error!("graphics device error: {e}"))); + let samples = if adapter.get_texture_format_features(FORMAT).flags.sample_count_supported(SAMPLES) + && adapter.get_texture_format_features(DEPTH).flags.sample_count_supported(SAMPLES) + { + SAMPLES + } else { + 1 + }; + let target = GpuTarget { format: FORMAT, depth: Some(DEPTH), samples }; + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let pipelines = Pipelines::new(&device, target); + if let Some(e) = popped(scope.pop()) { + return Err(GpuError::Device(e.to_string())); + } + let info = adapter.get_info(); + Ok(Capturer { device, queue, pipelines, target, adapter: format!("{} ({:?})", info.name, info.backend), tier }) + } + + /// The MSAA sample count pictures are drawn with. + pub fn samples(&self) -> u32 { + self.target.samples + } + + /// RGBA pixels (`w × h`, rows top to bottom) of `scene` with `layers` (the symbols, then + /// the overlay) seen by `cam`, over the scene's background. Widths are taken as pixels. + pub fn rgba(&self, scene: &Scene, layers: &[&Scene], cam: &Camera, w: u32, h: u32) -> Result> { + let max = self.device.limits().max_texture_dimension_2d.min(MAX_SIDE); + if w == 0 || h == 0 || w > max || h > max || u64::from(w) * u64::from(h) > MAX_PIXELS { + return Err(GpuError::Size(format!("picture size {w} x {h} is out of range (1..={max} per side, at most {MAX_PIXELS} pixels)"))); + } + let oom = self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let validation = self.device.push_error_scope(wgpu::ErrorFilter::Validation); + let drawn = self.draw(scene, layers, cam, w, h); + let caught = popped(validation.pop()).or_else(|| popped(oom.pop())); + if let Some(e) = caught { + return Err(GpuError::Failed(e.to_string())); + } + let pixels = drawn?; + Ok(over_background(&pixels, w as usize, h as usize, scene.background.unwrap_or(BACKGROUND))) + } + + fn draw(&self, scene: &Scene, layers: &[&Scene], cam: &Camera, w: u32, h: u32) -> Result> { + let (d, q) = (&self.device, &self.queue); + let mut uploaded = [Layer::default(), Layer::default(), Layer::default()]; + for (layer, s) in uploaded.iter_mut().zip(std::iter::once(scene).chain(layers.iter().copied())) { + layer.upload(d, q, &GpuScene::from_scene(s, 1.0)); + } + self.pipelines.write_frame(q, &Frame::of(cam, w as f32, h as f32, scene.radius)); + let size = wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }; + let texture = |label: &str, format: wgpu::TextureFormat, samples: u32, usage: wgpu::TextureUsages| { + d.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: samples, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + }; + let samples = self.target.samples; + let out = texture("fc_capture", FORMAT, 1, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC); + let out_view = out.create_view(&Default::default()); + let msaa = (samples > 1).then(|| texture("fc_capture_msaa", FORMAT, samples, wgpu::TextureUsages::RENDER_ATTACHMENT)); + let msaa_view = msaa.as_ref().map(|t| t.create_view(&Default::default())); + let depth = texture("fc_capture_depth", DEPTH, samples, wgpu::TextureUsages::RENDER_ATTACHMENT); + let depth_view = depth.create_view(&Default::default()); + let (view, resolve) = match &msaa_view { + Some(m) => (m, Some(&out_view)), + None => (&out_view, None), + }; + let row = padded_row(w); + let buffer = d.create_buffer(&wgpu::BufferDescriptor { + label: Some("fc_capture_read"), + size: u64::from(row) * u64::from(h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = d.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("fc_capture") }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("fc_capture"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + depth_slice: None, + resolve_target: resolve, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + let [s, y, o] = &uploaded; + self.pipelines.paint(&mut pass, s, y, o); + } + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &out, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(h) }, + }, + size, + ); + q.submit([encoder.finish()]); + let slice = buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + d.poll(wgpu::PollType::wait_indefinitely()).map_err(|e| GpuError::Failed(e.to_string()))?; + rx.recv().map_err(|e| GpuError::Failed(e.to_string()))?.map_err(|e| GpuError::Failed(e.to_string()))?; + let mapped = slice.get_mapped_range().map_err(|e| GpuError::Failed(e.to_string()))?; + let pixels = unpad(&mapped, w as usize, h as usize, row as usize); + drop(mapped); + buffer.unmap(); + pixels.ok_or_else(|| GpuError::Failed("the picture read back has the wrong size".into())) + } +} + +/// Bytes per row of a `w` pixel RGBA copy, padded to wgpu's 256-byte alignment. +pub fn padded_row(w: u32) -> u32 { + let a = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + (w * 4).div_ceil(a) * a +} + +/// The `w × h` RGBA pixels of rows `row` bytes apart; `None` when `bytes` is too short. +fn unpad(bytes: &[u8], w: usize, h: usize, row: usize) -> Option> { + let mut out = Vec::new(); + out.try_reserve_exact(w.checked_mul(h)?.checked_mul(4)?).ok()?; + for y in 0..h { + out.extend_from_slice(bytes.get(y * row..y * row + w * 4)?); + } + Some(out) +} + +/// Premultiplied pixels over the vertical gradient `bg` (top, bottom), as the viewport draws +/// its background behind the model. Opaque pixels are unchanged. +fn over_background(px: &[u8], w: usize, h: usize, bg: (Rgb, Rgb)) -> Vec { + let f = |c: Rgb| [c.0, c.1, c.2].map(f64::from); + let (top, bottom) = (f(bg.0), f(bg.1)); + let mut out = px.to_vec(); + for (i, p) in out.chunks_exact_mut(4).enumerate() { + let t = (i / w.max(1)) as f64 / h.max(1) as f64; + let k = 1.0 - f64::from(p[3]) / 255.0; + for (c, (a, b)) in p.iter_mut().zip(top.iter().zip(&bottom)) { + let back = a + (b - a) * t; + *c = (f64::from(*c) + back * k).round().clamp(0.0, 255.0) as u8; + } + p[3] = 255; + } + out +} + +#[cfg(test)] +mod tests { + use fieldcraft_render::{Aabb3, SceneLine, SceneTri, Vec3}; + + use super::*; + + #[test] + fn rows_are_padded_and_unpadded() { + assert_eq!(padded_row(1), 256); + assert_eq!(padded_row(64), 256); + assert_eq!(padded_row(65), 512); + let bytes: Vec = (0..512u32).map(|i| i as u8).collect(); + assert_eq!(unpad(&bytes, 2, 2, 256), Some(vec![0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7])); + assert_eq!(unpad(&bytes, 2, 3, 256), None); + } + + #[test] + fn transparent_pixels_take_the_background() { + let bg = (Rgb(0, 0, 0), Rgb(200, 100, 0)); + let px = [0, 0, 0, 0, 10, 20, 30, 255, 0, 0, 0, 0, 0, 0, 0, 0]; + let out = over_background(&px, 2, 2, bg); + assert_eq!(&out[0..4], &[0, 0, 0, 255]); + assert_eq!(&out[4..8], &[10, 20, 30, 255]); + assert_eq!(&out[8..12], &[100, 50, 0, 255]); + } + + /// A red triangle with a black edge, framed. + fn triangle() -> (Scene, Camera) { + let p = [Vec3::new(-1.0, 0.0, -1.0), Vec3::new(1.0, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0)]; + let scene = Scene { + tris: vec![SceneTri { p, normal: Vec3::new(0.0, -1.0, 0.0), color: Rgb(220, 30, 30) }], + lines: vec![SceneLine { a: p[0], b: p[2], color: Rgb(0, 0, 0), width: 3.0 }], + background: Some((Rgb(255, 255, 255), Rgb(255, 255, 255))), + radius: 4.0, + ..Default::default() + }; + let mut cam = Camera::default(); + cam.set_view(fieldcraft_render::StandardView::Front); + cam.fit_in(&Aabb3 { min: Vec3::new(-1.0, 0.0, -1.0), max: Vec3::new(1.0, 0.0, 1.0) }, 1.0, fieldcraft_render::FULL_VIEW); + (scene, cam) + } + + /// A picture on whatever adapter the machine has: the right size, the triangle's red and + /// the edge's black on the white background. A machine with no adapter (a CI box without + /// a software GPU) is reported, not failed: `auto` then draws on the CPU. + #[test] + fn draws_offscreen_when_an_adapter_exists() { + let cap = match Capturer::open(Policy::Auto) { + Ok(Some(c)) => c, + Ok(None) => return eprintln!("no GPU adapter: skipped"), + Err(e) => return eprintln!("no GPU device ({e}): skipped"), + }; + let (scene, cam) = triangle(); + let px = cap.rgba(&scene, &[], &cam, 96, 64).unwrap(); + assert_eq!(px.len(), 96 * 64 * 4); + let count = |f: &dyn Fn(&[u8]) -> bool| px.chunks(4).filter(|p| f(p)).count(); + assert!(count(&|p| p[0] > 150 && p[1] < 80 && p[2] < 80) > 200, "red faces"); + assert!(count(&|p| p[0] < 40 && p[1] < 40 && p[2] < 40) > 20, "black edge"); + assert!(count(&|p| p == [255, 255, 255, 255]) > 1000, "background"); + // Hostile sizes are refused before anything is allocated. + for (w, h) in [(0, 10), (10, 0), (MAX_SIDE + 1, 10), (u32::MAX, u32::MAX), (8192, 8192)] { + assert!(matches!(cap.rgba(&scene, &[], &cam, w, h), Err(GpuError::Size(_))), "{w} x {h}"); + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs new file mode 100644 index 0000000..9263b36 --- /dev/null +++ b/crates/gpu/src/lib.rs @@ -0,0 +1,31 @@ +//! FieldCraft on the GPU without a window toolkit: the viewport's pipelines ([`view`]), the +//! choice of adapter ([`select`]) and offscreen pictures drawn with them ([`capture`]). The +//! desktop viewport draws through [`view`] in its paint callback; `fieldcraft-cli snapshot` draws +//! through [`capture`], so a headless picture is the one the window draws. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +#[cfg(not(target_arch = "wasm32"))] +pub mod capture; +#[cfg(not(target_arch = "wasm32"))] +pub mod select; +pub mod view; + +pub use wgpu; + +/// What can go wrong on the GPU. Never a panic: device errors are caught in error scopes. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum GpuError { + #[error("{0}")] + NoAdapter(String), + #[error("FIELDCRAFT_GPU must be auto, hardware, software or cpu, not `{0}`")] + Policy(String), + #[error("the GPU device could not be opened: {0}")] + Device(String), + #[error("{0}")] + Size(String), + #[error("the GPU picture failed: {0}")] + Failed(String), +} + +pub type Result = std::result::Result; diff --git a/crates/gpu/src/select.rs b/crates/gpu/src/select.rs new file mode 100644 index 0000000..bd0f706 --- /dev/null +++ b/crates/gpu/src/select.rs @@ -0,0 +1,144 @@ +//! Which adapter draws a headless picture. `FIELDCRAFT_GPU` chooses the policy: +//! +//! - `auto` (the default) walks a hardware GPU, then a software adapter (lavapipe, llvmpipe, +//! WARP, SwiftShader), then the CPU rasterizer; +//! - `hardware` and `software` are strict: an error when there is no such adapter; +//! - `cpu` forces the CPU rasterizer. +//! +//! Backends are Metal on macOS, DX12 and Vulkan on Windows, Vulkan and GL elsewhere; +//! `WGPU_BACKEND` narrows them. A discrete GPU outranks an integrated one, then a virtual one, +//! then any other; a CPU device is the software tier. + +use crate::{GpuError, Result}; + +/// The adapter policy (`FIELDCRAFT_GPU`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Policy { + #[default] + Auto, + Hardware, + Software, + Cpu, +} + +/// The environment variable that chooses the [`Policy`]. +pub const ENV: &str = "FIELDCRAFT_GPU"; + +impl Policy { + pub fn parse(s: &str) -> Option { + Some(match s.trim().to_ascii_lowercase().as_str() { + "" | "auto" => Policy::Auto, + "hardware" => Policy::Hardware, + "software" => Policy::Software, + "cpu" => Policy::Cpu, + _ => return None, + }) + } + + /// The policy `FIELDCRAFT_GPU` names; [`Policy::Auto`] when it is unset. + pub fn from_env() -> Result { + match std::env::var(ENV) { + Ok(v) => Policy::parse(&v).ok_or_else(|| GpuError::Policy(v.chars().take(40).collect())), + Err(_) => Ok(Policy::Auto), + } + } +} + +/// The kind of device a picture was drawn on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tier { + Hardware, + Software, +} + +impl Tier { + pub fn label(self) -> &'static str { + match self { + Tier::Hardware => "hardware GPU", + Tier::Software => "software GPU", + } + } +} + +/// An adapter's tier and its rank within it (lower is better). +pub fn rank(t: wgpu::DeviceType) -> (Tier, u8) { + match t { + wgpu::DeviceType::DiscreteGpu => (Tier::Hardware, 0), + wgpu::DeviceType::IntegratedGpu => (Tier::Hardware, 1), + wgpu::DeviceType::VirtualGpu => (Tier::Hardware, 2), + wgpu::DeviceType::Other => (Tier::Hardware, 3), + wgpu::DeviceType::Cpu => (Tier::Software, 0), + } +} + +/// The index of the adapter `policy` picks among adapters of `types`: `Ok(None)` for the CPU +/// rasterizer, an error when a strict policy finds no adapter of its tier. +pub fn pick(types: &[wgpu::DeviceType], policy: Policy) -> Result> { + let best = |tier: Tier| types.iter().enumerate().filter(|(_, t)| rank(**t).0 == tier).min_by_key(|(_, t)| rank(**t).1).map(|(i, _)| i); + match policy { + Policy::Cpu => Ok(None), + Policy::Auto => Ok(best(Tier::Hardware).or_else(|| best(Tier::Software))), + Policy::Hardware => best(Tier::Hardware).map(Some).ok_or_else(|| GpuError::NoAdapter(format!("{ENV}=hardware: no hardware GPU adapter"))), + Policy::Software => best(Tier::Software) + .map(Some) + .ok_or_else(|| GpuError::NoAdapter(format!("{ENV}=software: no software GPU adapter (lavapipe, llvmpipe, WARP)"))), + } +} + +/// The backends tried on this platform, narrowed by `WGPU_BACKEND`. +pub fn backends() -> wgpu::Backends { + let platform = if cfg!(target_os = "macos") || cfg!(target_os = "ios") { + wgpu::Backends::METAL + } else if cfg!(windows) { + wgpu::Backends::DX12 | wgpu::Backends::VULKAN + } else { + wgpu::Backends::VULKAN | wgpu::Backends::GL + }; + match wgpu::Backends::from_env() { + Some(env) => platform & env, + None => platform, + } +} + +/// The adapter `policy` picks on this machine and its tier; `Ok(None)` for the CPU rasterizer. +pub fn adapter(policy: Policy) -> Result> { + if policy == Policy::Cpu { + return Ok(None); + } + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: backends(), ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let mut adapters = pollster::block_on(instance.enumerate_adapters(backends())); + let types: Vec<_> = adapters.iter().map(|a| a.get_info().device_type).collect(); + let Some(i) = pick(&types, policy)? else { return Ok(None) }; + let tier = types.get(i).map_or(Tier::Hardware, |t| rank(*t).0); + Ok((i < adapters.len()).then(|| (adapters.swap_remove(i), tier))) +} + +#[cfg(test)] +mod tests { + use wgpu::DeviceType::*; + + use super::*; + + #[test] + fn policies_walk_the_tiers() { + assert_eq!(pick(&[Cpu, IntegratedGpu, DiscreteGpu, VirtualGpu], Policy::Auto), Ok(Some(2))); + assert_eq!(pick(&[Cpu, Other, IntegratedGpu], Policy::Auto), Ok(Some(2))); + assert_eq!(pick(&[Cpu, DiscreteGpu], Policy::Software), Ok(Some(0))); + // Auto falls to a software adapter, then to the CPU rasterizer. + assert_eq!(pick(&[Cpu], Policy::Auto), Ok(Some(0))); + assert_eq!(pick(&[], Policy::Auto), Ok(None)); + assert_eq!(pick(&[DiscreteGpu], Policy::Cpu), Ok(None)); + // The strict policies refuse. + assert!(pick(&[Cpu], Policy::Hardware).is_err()); + assert!(pick(&[DiscreteGpu], Policy::Software).is_err()); + assert!(pick(&[], Policy::Hardware).is_err()); + } + + #[test] + fn policy_names() { + assert_eq!(Policy::parse("Software"), Some(Policy::Software)); + assert_eq!(Policy::parse(""), Some(Policy::Auto)); + assert_eq!(Policy::parse("cpu"), Some(Policy::Cpu)); + assert_eq!(Policy::parse("gpu"), None); + } +} diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs new file mode 100644 index 0000000..6ec683e --- /dev/null +++ b/crates/gpu/src/view.rs @@ -0,0 +1,554 @@ +//! The viewport's GPU drawing, with no window toolkit: lit triangles and screen-space wide lines +//! with a depth buffer. The desktop viewport draws through it in an `egui_wgpu` paint callback; +//! [`crate::capture`] draws through it offscreen. Nodes are drawn as zero-length lines, which the +//! line shader turns into squares. A picture is up to three layers (the scene, the setup's +//! symbols and the overlay, the selection highlight), each uploaded on its own: the overlay's +//! faces pass the depth test over the faces they repeat. Every layer's `on_top` lines are drawn +//! last, with no depth test. Translucent faces (a datum plane's fill) are drawn after every +//! opaque face, blended, depth-tested without writing depth, as the CPU renderer draws them. +//! +//! A large mesh does not fit one vertex buffer (wgpu's default `max_buffer_size` is 256 MiB): +//! geometry is split into batches of at most [`MAX_BATCH_BYTES`] and the device's limit, each a +//! whole number of primitives, and every batch is drawn. +//! +//! Adapted from SolidCraft's `crates/ui-egui/src/gpu.rs` (MIT OR Apache-2.0), see +//! ATTRIBUTION.md. + +use std::ops::Range; +use std::sync::{Arc, Mutex}; + +use fieldcraft_render::{Rgb, Scene}; + +/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8). +pub const TRI_SIZE: usize = 28; +/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in physical pixels (f32). +pub const LINE_SIZE: usize = 32; +const UNIFORM_SIZE: u64 = 112; +/// Largest vertex buffer the viewport creates, whatever the device allows. +pub const MAX_BATCH_BYTES: u64 = 128 << 20; + +/// The last error the GPU device reported, waiting for the UI to show it. +pub type GpuErrors = Arc>>; + +/// Scene geometry in the GPU's vertex formats, waiting to be uploaded. +#[derive(Default, Clone, Debug, PartialEq)] +pub struct GpuScene { + pub tris: Vec, + /// Translucent triangles ([`Scene::fills`]), in the triangle format with their alpha. + pub fills: Vec, + pub lines: Vec, + /// Lines drawn over everything ([`Scene::on_top`]). + pub top_lines: Vec, +} + +impl GpuScene { + /// The scene's triangles, lines and points; widths and sizes scaled by `pixels_per_point` + /// (the scene gives them in points). + pub fn from_scene(scene: &Scene, pixels_per_point: f32) -> GpuScene { + let mut g = GpuScene { tris: Vec::with_capacity(scene.tris.len() * 3 * TRI_SIZE), ..GpuScene::default() }; + for t in &scene.tris { + for p in t.p { + push_vertex(&mut g.tris, p.to_f32(), t.normal.to_f32(), t.color, 255); + } + } + for f in &scene.fills { + let alpha = if f.alpha.is_finite() { (f.alpha.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + for p in f.tri.p { + push_vertex(&mut g.fills, p.to_f32(), f.tri.normal.to_f32(), f.tri.color, alpha); + } + } + for l in &scene.lines { + g.line(l.a.to_f32(), l.b.to_f32(), l.color, l.width as f32 * pixels_per_point); + } + for p in &scene.points { + g.line(p.p.to_f32(), p.p.to_f32(), p.color, p.size as f32 * pixels_per_point); + } + for l in &scene.on_top { + push_line(&mut g.top_lines, l.a.to_f32(), l.b.to_f32(), l.color, l.width as f32 * pixels_per_point); + } + g + } + fn line(&mut self, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) { + push_line(&mut self.lines, a, b, c, width); + } +} + +/// A triangle vertex into `buf`. +fn push_vertex(buf: &mut Vec, p: [f32; 3], n: [f32; 3], c: Rgb, alpha: u8) { + for v in p.iter().chain(&n) { + buf.extend_from_slice(&v.to_le_bytes()); + } + buf.extend_from_slice(&[c.0, c.1, c.2, alpha]); +} + +/// A line instance into `buf`. +fn push_line(buf: &mut Vec, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) { + for v in a.iter().chain(&b) { + buf.extend_from_slice(&v.to_le_bytes()); + } + buf.extend_from_slice(&[c.0, c.1, c.2, 255]); + buf.extend_from_slice(&width.to_le_bytes()); +} + +/// One frame's camera. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Frame { + /// Column-major view-projection. + pub view_proj: [[f32; 4]; 4], + /// Direction toward the eye (for lighting). + pub back: [f32; 3], + /// Eye position and 1 for a perspective camera (0: orthographic). + pub eye: [f32; 4], + /// Viewport size in physical pixels. + pub size_px: [f32; 2], +} + +impl Frame { + /// The frame of camera `cam` over a `w` × `h` pixel viewport, clipping at `radius` around + /// its target (the scene's [`Scene::radius`]). + pub fn of(cam: &fieldcraft_render::Camera, w: f32, h: f32, radius: f64) -> Frame { + let aspect = f64::from(w) / f64::from(h).max(1.0); + let eye = cam.eye(); + Frame { + view_proj: cam.view_proj(aspect, radius).to_f32(), + back: cam.back().to_f32(), + eye: [eye.x as f32, eye.y as f32, eye.z as f32, if cam.fov > 0.0 { 1.0 } else { 0.0 }], + size_px: [w, h], + } + } +} + +struct Batch { + buffer: wgpu::Buffer, + count: u32, +} + +/// Uploaded geometry: triangle and line batches. +#[derive(Default)] +pub struct Layer { + tris: Vec, + fills: Vec, + lines: Vec, + top_lines: Vec, +} + +impl Layer { + /// Replace the layer's geometry with `sc`. The old buffers go first, so the new ones don't + /// have to fit beside them. + pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, sc: &GpuScene) { + *self = Layer::default(); + let max = device.limits().max_buffer_size; + self.tris = upload(device, queue, "fc_tris", &sc.tris, TRI_SIZE, 3, max); + self.fills = upload(device, queue, "fc_fills", &sc.fills, TRI_SIZE, 3, max); + self.lines = upload(device, queue, "fc_lines", &sc.lines, LINE_SIZE, 1, max); + self.top_lines = upload(device, queue, "fc_top_lines", &sc.top_lines, LINE_SIZE, 1, max); + } +} + +fn paint_tri_batches(batches: &[Batch], pass: &mut wgpu::RenderPass<'_>, tri: &wgpu::RenderPipeline) { + if !batches.is_empty() { + pass.set_pipeline(tri); + for b in batches { + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..b.count, 0..1); + } + } +} + +fn paint_line_batches(batches: &[Batch], pass: &mut wgpu::RenderPass<'_>, line: &wgpu::RenderPipeline) { + if !batches.is_empty() { + pass.set_pipeline(line); + for b in batches { + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..6, 0..b.count); + } + } +} + +/// The viewport's pipelines and camera uniform, for one target configuration. +pub struct Pipelines { + tri: wgpu::RenderPipeline, + /// The overlay's faces: pulled toward the eye and depth-tested without writing depth. + overlay_tri: wgpu::RenderPipeline, + /// Translucent faces: blended, depth-tested without writing depth. + fill: wgpu::RenderPipeline, + line: wgpu::RenderPipeline, + /// Lines over everything: no depth test. + top_line: wgpu::RenderPipeline, + uniform: wgpu::Buffer, + bind: wgpu::BindGroup, + linear_out: bool, +} + +pub(crate) const SHADER: &str = r#" +struct U { + vp: mat4x4, + back: vec4, // xyz = toward the eye, w = 1 for linear output + screen: vec4, // viewport width, height (px), depth bias + eye: vec4, // eye position, w = 1 for perspective +}; +@group(0) @binding(0) var u: U; + +fn to_linear(c: vec3) -> vec3 { + return select(c, pow((c + vec3(0.055)) / vec3(1.055), vec3(2.4)), c > vec3(0.04045)); +} + +fn out_color(c: vec4) -> vec4 { + if (u.back.w > 0.5) { + return vec4(to_linear(c.rgb), c.a); + } + return c; +} + +struct TOut { + @builtin(position) pos: vec4, + @location(0) n: vec3, + @location(1) c: vec4, +}; + +@vertex +fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { + var o: TOut; + o.pos = u.vp * vec4(p, 1.0); + o.n = n; + o.c = c; + return o; +} + +/// Two-sided lighting: a key light above the eye, a fill from the eye and a small highlight +/// (fieldcraft_render::shade computes the same on the CPU). +@fragment +fn fs_tri(i: TOut) -> @location(0) vec4 { + let v = normalize(u.back.xyz); + var n = normalize(i.n); + if (dot(n, v) < 0.0) { n = -n; } + let key = normalize(v * 0.8 + vec3(-0.3, 0.2, 0.6)); + let diff = max(dot(n, key), 0.0); + let fill = max(dot(n, v), 0.0); + let h = normalize(key + v); + let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25; + let k = 0.42 + 0.38 * diff + 0.25 * fill; + return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a)); +} + +/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye, so edges and +/// nodes on a face win the depth test against it. +fn biased_z(p: vec3, c: vec4, s: f32) -> f32 { + if (u.eye.w > 0.5) { + let q = u.vp * vec4(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0); + return q.z / max(q.w, 1e-6) * c.w; + } + return c.z - u.screen.z * s * c.w; +} + +/// An overlay face: as `vs_tri`, half a depth bias toward the eye, so it covers the scene face it +/// repeats (or the contour bands that split it). +@vertex +fn vs_overlay_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { + var o: TOut; + let clip = u.vp * vec4(p, 1.0); + o.pos = vec4(clip.xy, biased_z(p, clip, 0.5), clip.w); + o.n = n; + o.c = c; + return o; +} + +struct LOut { + @builtin(position) pos: vec4, + @location(0) c: vec4, +}; + +@vertex +fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location(1) b: vec3, @location(2) c: vec4, @location(3) w: f32) -> LOut { + var pa = a; + var pb = b; + var ca = u.vp * vec4(a, 1.0); + var cb = u.vp * vec4(b, 1.0); + // An end behind the near plane has no screen point: cut the segment at the plane. + if (ca.z < 0.0 && cb.z > 0.0) { + let k = ca.z / (ca.z - cb.z); + ca = mix(ca, cb, k); + pa = mix(a, b, k); + } else if (cb.z < 0.0 && ca.z > 0.0) { + let k = cb.z / (cb.z - ca.z); + cb = mix(cb, ca, k); + pb = mix(b, a, k); + } + let half = u.screen.xy * 0.5; + let sa = ca.xy / max(ca.w, 1e-6) * half; + let sb = cb.xy / max(cb.w, 1e-6) * half; + var dir = sb - sa; + let len = length(dir); + // A zero-length line (a node) becomes a square `w` pixels across. + if (len < 1e-6) { dir = vec2(1.0, 0.0); } else { dir = dir / len; } + let nrm = vec2(-dir.y, dir.x); + // Two triangles: corners (t, side). + var ts = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); + var ss = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let t = ts[vi]; + let s = ss[vi]; + let base = select(ca, cb, t > 0.5); + let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5; + var o: LOut; + o.pos = vec4(base.xy + off / half * base.w, biased_z(select(pa, pb, t > 0.5), base, 1.0), base.w); + o.c = c; + return o; +} + +@fragment +fn fs_line(i: LOut) -> @location(0) vec4 { + return out_color(i.c); +} +"#; + +fn tri_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4]; + wgpu::VertexBufferLayout { array_stride: TRI_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } +} + +fn line_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32]; + wgpu::VertexBufferLayout { array_stride: LINE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A } +} + +/// The target configuration: colour format, depth format and MSAA sample count. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuTarget { + pub format: wgpu::TextureFormat, + pub depth: Option, + pub samples: u32, +} + +impl Pipelines { + /// The pipelines for target `t`. + pub fn new(device: &wgpu::Device, t: GpuTarget) -> Self { + let module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("fc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("fc_viewport"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }], + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("fc_viewport"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("fc_viewport_u"), + size: UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fc_viewport"), + layout: &bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], + }); + let depth = |compare: wgpu::CompareFunction, write: bool| -> Option { + t.depth.map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: Some(write), + depth_compare: Some(compare), + stencil: Default::default(), + bias: Default::default(), + }) + }; + let pipeline = |label: &str, + vs: &str, + fs: &str, + buf: wgpu::VertexBufferLayout<'static>, + ds: Option, + blend: Option| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { module: &module, entry_point: Some(vs), buffers: &[Some(buf)], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: ds, + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(fs), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; + Pipelines { + tri: pipeline("fc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), + overlay_tri: pipeline("fc_overlay_tris", "vs_overlay_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), None), + fill: pipeline( + "fc_fills", + "vs_tri", + "fs_tri", + tri_layout(), + depth(wgpu::CompareFunction::Less, false), + Some(wgpu::BlendState::ALPHA_BLENDING), + ), + line: pipeline( + "fc_lines", + "vs_line", + "fs_line", + line_layout(), + depth(wgpu::CompareFunction::LessEqual, false), + Some(wgpu::BlendState::ALPHA_BLENDING), + ), + top_line: pipeline( + "fc_top_lines", + "vs_line", + "fs_line", + line_layout(), + depth(wgpu::CompareFunction::Always, false), + Some(wgpu::BlendState::ALPHA_BLENDING), + ), + uniform, + bind, + linear_out: t.format.is_srgb(), + } + } + + /// Write this frame's camera. + pub fn write_frame(&self, queue: &wgpu::Queue, frame: &Frame) { + queue.write_buffer(&self.uniform, 0, &uniform_bytes(frame, self.linear_out)); + } + + /// Draw the scene, the symbols and the overlay into `pass`: faces first, the overlay's over + /// the scene's, then the translucent faces, then every line, so the mesh edges stay on + /// highlighted faces, then the lines over everything (as the CPU renderer draws them). + pub fn paint(&self, pass: &mut wgpu::RenderPass<'_>, scene: &Layer, symbols: &Layer, overlay: &Layer) { + pass.set_bind_group(0, &self.bind, &[]); + paint_tri_batches(&scene.tris, pass, &self.tri); + paint_tri_batches(&symbols.tris, pass, &self.tri); + paint_tri_batches(&overlay.tris, pass, &self.overlay_tri); + let layers = [scene, symbols, overlay]; + for layer in layers { + paint_tri_batches(&layer.fills, pass, &self.fill); + } + for layer in layers { + paint_line_batches(&layer.lines, pass, &self.line); + } + for layer in layers { + paint_line_batches(&layer.top_lines, pass, &self.top_line); + } + } +} + +/// Byte ranges of `len` bytes of vertex data, `stride` bytes per vertex and `per_primitive` +/// vertices per primitive, for buffers of at most `max_buffer` bytes (and [`MAX_BATCH_BYTES`]). +/// Each range holds whole primitives; a trailing partial primitive is dropped. Empty when not +/// even one primitive fits. +pub fn batch_ranges(len: usize, stride: usize, per_primitive: usize, max_buffer: u64) -> Vec> { + let Some(unit) = stride.checked_mul(per_primitive).filter(|&u| u > 0) else { return Vec::new() }; + let limit = usize::try_from(max_buffer.min(MAX_BATCH_BYTES)).unwrap_or(usize::MAX); + let chunk = limit / unit * unit; + if chunk == 0 { + return Vec::new(); + } + let end = len / unit * unit; + (0..end).step_by(chunk).map(|start| start..end.min(start.saturating_add(chunk))).collect() +} + +/// Upload vertex data as batches (see [`batch_ranges`]); a failed allocation is reported to the +/// device's error handler, not a panic. +fn upload(device: &wgpu::Device, queue: &wgpu::Queue, label: &str, bytes: &[u8], stride: usize, per_primitive: usize, max_buffer: u64) -> Vec { + batch_ranges(bytes.len(), stride, per_primitive, max_buffer) + .into_iter() + .filter_map(|r| { + let data = bytes.get(r)?; + let count = u32::try_from(data.len() / stride).ok()?; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: data.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&buffer, 0, data); + Some(Batch { buffer, count }) + }) + .collect() +} + +/// Uniform bytes for a frame. +fn uniform_bytes(f: &Frame, linear: bool) -> Vec { + let mut v: Vec = f.view_proj.iter().flatten().copied().collect(); + v.extend([f.back[0], f.back[1], f.back[2], if linear { 1.0 } else { 0.0 }]); + v.extend([f.size_px[0].max(1.0), f.size_px[1].max(1.0), 2e-4, 0.0]); + v.extend(f.eye); + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +#[cfg(test)] +mod tests { + use fieldcraft_render::{SceneLine, ScenePoint, SceneTri, Vec3}; + use wgpu::naga; + + use super::*; + + /// The viewport shader parses and validates (a bad shader would only fail at run time). + #[test] + fn shader_is_valid() { + let module = naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER))); + naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap(); + } + + #[test] + fn batches_hold_whole_primitives_within_the_buffer_limit() { + let tri = TRI_SIZE * 3; + // Small scenes are one batch; nothing, or less than one primitive, is no batch. + assert_eq!(batch_ranges(10 * tri, TRI_SIZE, 3, 256 << 20), vec![0..10 * tri]); + assert!(batch_ranges(0, TRI_SIZE, 3, 256 << 20).is_empty()); + assert!(batch_ranges(tri - 1, TRI_SIZE, 3, 256 << 20).is_empty()); + // A trailing partial triangle is dropped. + assert_eq!(batch_ranges(2 * tri + 5, TRI_SIZE, 3, 256 << 20), vec![0..2 * tri]); + // A buffer limit smaller than one primitive, or a zero stride, gives nothing. + assert!(batch_ranges(10 * tri, TRI_SIZE, 3, (tri - 1) as u64).is_empty()); + assert!(batch_ranges(10 * tri, 0, 3, 256 << 20).is_empty()); + assert!(batch_ranges(usize::MAX, usize::MAX, 3, u64::MAX).is_empty()); + // Ten triangles under a limit of four: 4 + 4 + 2. + let r = batch_ranges(10 * tri, TRI_SIZE, 3, (4 * tri + 7) as u64); + assert_eq!(r, vec![0..4 * tri, 4 * tri..8 * tri, 8 * tri..10 * tri]); + // A few million triangles under the 256 MiB default: every batch is within + // MAX_BATCH_BYTES, a whole number of triangles, and together they cover the data. + for (len, stride, per, max) in + [(5_000_000 * tri, TRI_SIZE, 3, 256u64 << 20), (9_000_000 * LINE_SIZE, LINE_SIZE, 1, u64::MAX), (777 * tri, TRI_SIZE, 3, 1000)] + { + let r = batch_ranges(len, stride, per, max); + assert!(r.len() > 1, "{len}"); + let mut at = 0; + for b in &r { + assert_eq!(b.start, at); + assert!(b.len() as u64 <= max.min(MAX_BATCH_BYTES) && b.len() % (stride * per) == 0 && !b.is_empty()); + at = b.end; + } + assert_eq!(at, len / (stride * per) * (stride * per)); + } + } + + #[test] + fn scene_bytes_match_the_vertex_layouts() { + let scene = Scene { + tris: vec![SceneTri { p: [Vec3::ZERO, Vec3::X, Vec3::Y], normal: Vec3::Z, color: Rgb(1, 2, 3) }], + lines: vec![SceneLine { a: Vec3::ZERO, b: Vec3::Z, color: Rgb(4, 5, 6), width: 1.5 }], + points: vec![ScenePoint { p: Vec3::X, color: Rgb(7, 8, 9), size: 4.0 }], + on_top: vec![SceneLine { a: Vec3::ZERO, b: Vec3::X, color: Rgb(1, 1, 1), width: 2.0 }], + ..Default::default() + }; + let g = GpuScene::from_scene(&scene, 2.0); + assert_eq!(g.tris.len(), 3 * TRI_SIZE); + assert_eq!(g.lines.len(), 2 * LINE_SIZE); + assert_eq!(g.top_lines.len(), LINE_SIZE); + assert_eq!(&g.tris[24..28], &[1, 2, 3, 255]); + // Widths are in physical pixels. + assert_eq!(f32::from_le_bytes([g.lines[28], g.lines[29], g.lines[30], g.lines[31]]), 3.0); + assert_eq!(f32::from_le_bytes([g.lines[60], g.lines[61], g.lines[62], g.lines[63]]), 8.0); + let f = Frame { view_proj: [[0.0; 4]; 4], back: [0.0; 3], eye: [0.0; 4], size_px: [1.0, 1.0] }; + assert_eq!(uniform_bytes(&f, true).len() as u64, UNIFORM_SIZE); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a3daebe..27d0d21 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -20,6 +20,7 @@ fieldcraft-update = { workspace = true } egui = { workspace = true } craft-ui = { workspace = true } egui-wgpu = { workspace = true } +fieldcraft-gpu = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 329d159..5657945 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -1,101 +1,23 @@ -//! GPU viewport: lit triangles and screen-space wide lines with a depth buffer, drawn in an -//! `egui_wgpu` paint callback. Geometry is uploaded when the scene changes; each frame only the -//! camera uniform changes. Nodes are drawn as zero-length lines, which the line shader turns -//! into squares. The setup's symbols are a second scene, uploaded on their own, so zooming -//! (which resizes them) never uploads the mesh again. The selection highlight is a third, small -//! scene (the overlay) drawn over the first: its faces pass the depth test over the faces they -//! repeat, so a hover change uploads only the overlay. Every layer's `on_top` lines are drawn -//! last, with no depth test. Translucent faces (a datum plane's fill) are drawn after every -//! opaque face, blended, depth-tested without writing depth, as the CPU renderer draws them. -//! -//! A large mesh does not fit one vertex buffer (wgpu's default `max_buffer_size`, which eframe -//! requests, is 256 MiB): geometry is split into batches of at most [`MAX_BATCH_BYTES`] and the -//! device's limit, each a whole number of primitives, and every batch is drawn. Device errors +//! The GPU viewport in the window: an `egui_wgpu` paint callback drawing through +//! [`fieldcraft_gpu::view`]'s pipelines. Geometry is uploaded when the scene changes; each frame +//! only the camera uniform changes. The setup's symbols are a second scene, uploaded on their +//! own, so zooming (which resizes them) never uploads the mesh again; the selection highlight is +//! a third, small scene (the overlay), so a hover change uploads only the overlay. Device errors //! (out of memory, validation) go to [`GpuErrors`] and the status bar instead of wgpu's default //! handler, which panics. //! //! Adapted from SolidCraft's `crates/ui-egui/src/gpu.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. -use std::ops::Range; use std::sync::{Arc, Mutex}; use egui_wgpu::wgpu; -use fieldcraft_render::{Rgb, Scene}; +pub use fieldcraft_gpu::view::{Frame, GpuErrors, GpuScene, GpuTarget}; +use fieldcraft_gpu::view::{Layer, Pipelines}; -/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8). -pub const TRI_SIZE: usize = 28; -/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in physical pixels (f32). -pub const LINE_SIZE: usize = 32; -const UNIFORM_SIZE: u64 = 112; -/// Largest vertex buffer the viewport creates, whatever the device allows. -pub const MAX_BATCH_BYTES: u64 = 128 << 20; /// Longest device error message kept for the status bar. const MAX_ERROR_LEN: usize = 500; -/// The last error the GPU device reported, waiting for the UI to show it. -pub type GpuErrors = Arc>>; - -/// Scene geometry in the GPU's vertex formats, waiting to be uploaded. -#[derive(Default, Clone, Debug, PartialEq)] -pub struct GpuScene { - pub tris: Vec, - /// Translucent triangles ([`Scene::fills`]), in the triangle format with their alpha. - pub fills: Vec, - pub lines: Vec, - /// Lines drawn over everything ([`Scene::on_top`]). - pub top_lines: Vec, -} - -impl GpuScene { - /// The scene's triangles, lines and points; widths and sizes scaled by `pixels_per_point` - /// (the scene gives them in points). - pub fn from_scene(scene: &Scene, pixels_per_point: f32) -> GpuScene { - let mut g = GpuScene { tris: Vec::with_capacity(scene.tris.len() * 3 * TRI_SIZE), ..GpuScene::default() }; - for t in &scene.tris { - for p in t.p { - push_vertex(&mut g.tris, p.to_f32(), t.normal.to_f32(), t.color, 255); - } - } - for f in &scene.fills { - let alpha = if f.alpha.is_finite() { (f.alpha.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; - for p in f.tri.p { - push_vertex(&mut g.fills, p.to_f32(), f.tri.normal.to_f32(), f.tri.color, alpha); - } - } - for l in &scene.lines { - g.line(l.a.to_f32(), l.b.to_f32(), l.color, l.width as f32 * pixels_per_point); - } - for p in &scene.points { - g.line(p.p.to_f32(), p.p.to_f32(), p.color, p.size as f32 * pixels_per_point); - } - for l in &scene.on_top { - push_line(&mut g.top_lines, l.a.to_f32(), l.b.to_f32(), l.color, l.width as f32 * pixels_per_point); - } - g - } - fn line(&mut self, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) { - push_line(&mut self.lines, a, b, c, width); - } -} - -/// A triangle vertex into `buf`. -fn push_vertex(buf: &mut Vec, p: [f32; 3], n: [f32; 3], c: Rgb, alpha: u8) { - for v in p.iter().chain(&n) { - buf.extend_from_slice(&v.to_le_bytes()); - } - buf.extend_from_slice(&[c.0, c.1, c.2, alpha]); -} - -/// A line instance into `buf`. -fn push_line(buf: &mut Vec, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) { - for v in a.iter().chain(&b) { - buf.extend_from_slice(&v.to_le_bytes()); - } - buf.extend_from_slice(&[c.0, c.1, c.2, 255]); - buf.extend_from_slice(&width.to_le_bytes()); -} - /// Where the UI hands a new scene to the renderer (taken on the next frame's `prepare`). pub type SceneSlot = Arc>>; @@ -110,363 +32,33 @@ pub struct ViewportCallback { /// The same for the overlay (the selection highlight). pub overlay_key: u64, pub overlay_slot: SceneSlot, - /// Column-major view-projection. - pub view_proj: [[f32; 4]; 4], - /// Direction toward the eye (for lighting). - pub back: [f32; 3], - /// Eye position and 1 for a perspective camera (0: orthographic). - pub eye: [f32; 4], - /// Viewport size in physical pixels. - pub size_px: [f32; 2], + pub frame: Frame, } -struct Batch { - buffer: wgpu::Buffer, - count: u32, -} - -/// Uploaded geometry: triangle and line batches, and the key they were uploaded for. +/// An uploaded layer and the key it was uploaded for. #[derive(Default)] -struct Layer { +struct Keyed { key: Option, - tris: Vec, - fills: Vec, - lines: Vec, - top_lines: Vec, + layer: Layer, } -impl Layer { +impl Keyed { fn take(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u64, slot: &SceneSlot) { if self.key == Some(key) { return; } if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) { - // The old buffers go first, so the new ones don't have to fit beside them. - self.tris.clear(); - self.fills.clear(); - self.lines.clear(); - self.top_lines.clear(); - let max = device.limits().max_buffer_size; - self.tris = upload(device, queue, "fc_tris", &sc.tris, TRI_SIZE, 3, max); - self.fills = upload(device, queue, "fc_fills", &sc.fills, TRI_SIZE, 3, max); - self.lines = upload(device, queue, "fc_lines", &sc.lines, LINE_SIZE, 1, max); - self.top_lines = upload(device, queue, "fc_top_lines", &sc.top_lines, LINE_SIZE, 1, max); + self.layer.upload(device, queue, &sc); self.key = Some(key); } } - - fn paint_tris(&self, pass: &mut wgpu::RenderPass<'static>, tri: &wgpu::RenderPipeline) { - paint_tri_batches(&self.tris, pass, tri); - } -} - -fn paint_tri_batches(batches: &[Batch], pass: &mut wgpu::RenderPass<'static>, tri: &wgpu::RenderPipeline) { - if !batches.is_empty() { - pass.set_pipeline(tri); - for b in batches { - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..b.count, 0..1); - } - } -} - -fn paint_line_batches(batches: &[Batch], pass: &mut wgpu::RenderPass<'static>, line: &wgpu::RenderPipeline) { - if !batches.is_empty() { - pass.set_pipeline(line); - for b in batches { - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..6, 0..b.count); - } - } } struct Resources { - tri: wgpu::RenderPipeline, - /// The overlay's faces: pulled toward the eye and depth-tested without writing depth. - overlay_tri: wgpu::RenderPipeline, - /// Translucent faces: blended, depth-tested without writing depth. - fill: wgpu::RenderPipeline, - line: wgpu::RenderPipeline, - /// Lines over everything: no depth test. - top_line: wgpu::RenderPipeline, - uniform: wgpu::Buffer, - bind: wgpu::BindGroup, - linear_out: bool, - scene: Layer, - symbols: Layer, - overlay: Layer, -} - -const SHADER: &str = r#" -struct U { - vp: mat4x4, - back: vec4, // xyz = toward the eye, w = 1 for linear output - screen: vec4, // viewport width, height (px), depth bias - eye: vec4, // eye position, w = 1 for perspective -}; -@group(0) @binding(0) var u: U; - -fn to_linear(c: vec3) -> vec3 { - return select(c, pow((c + vec3(0.055)) / vec3(1.055), vec3(2.4)), c > vec3(0.04045)); -} - -fn out_color(c: vec4) -> vec4 { - if (u.back.w > 0.5) { - return vec4(to_linear(c.rgb), c.a); - } - return c; -} - -struct TOut { - @builtin(position) pos: vec4, - @location(0) n: vec3, - @location(1) c: vec4, -}; - -@vertex -fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { - var o: TOut; - o.pos = u.vp * vec4(p, 1.0); - o.n = n; - o.c = c; - return o; -} - -/// Two-sided lighting: a key light above the eye, a fill from the eye and a small highlight -/// (fieldcraft_render::shade computes the same on the CPU). -@fragment -fn fs_tri(i: TOut) -> @location(0) vec4 { - let v = normalize(u.back.xyz); - var n = normalize(i.n); - if (dot(n, v) < 0.0) { n = -n; } - let key = normalize(v * 0.8 + vec3(-0.3, 0.2, 0.6)); - let diff = max(dot(n, key), 0.0); - let fill = max(dot(n, v), 0.0); - let h = normalize(key + v); - let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25; - let k = 0.42 + 0.38 * diff + 0.25 * fill; - return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a)); -} - -/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye, so edges and -/// nodes on a face win the depth test against it. -fn biased_z(p: vec3, c: vec4, s: f32) -> f32 { - if (u.eye.w > 0.5) { - let q = u.vp * vec4(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0); - return q.z / max(q.w, 1e-6) * c.w; - } - return c.z - u.screen.z * s * c.w; -} - -/// An overlay face: as `vs_tri`, half a depth bias toward the eye, so it covers the scene face it -/// repeats (or the contour bands that split it). -@vertex -fn vs_overlay_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { - var o: TOut; - let clip = u.vp * vec4(p, 1.0); - o.pos = vec4(clip.xy, biased_z(p, clip, 0.5), clip.w); - o.n = n; - o.c = c; - return o; -} - -struct LOut { - @builtin(position) pos: vec4, - @location(0) c: vec4, -}; - -@vertex -fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location(1) b: vec3, @location(2) c: vec4, @location(3) w: f32) -> LOut { - var pa = a; - var pb = b; - var ca = u.vp * vec4(a, 1.0); - var cb = u.vp * vec4(b, 1.0); - // An end behind the near plane has no screen point: cut the segment at the plane. - if (ca.z < 0.0 && cb.z > 0.0) { - let k = ca.z / (ca.z - cb.z); - ca = mix(ca, cb, k); - pa = mix(a, b, k); - } else if (cb.z < 0.0 && ca.z > 0.0) { - let k = cb.z / (cb.z - ca.z); - cb = mix(cb, ca, k); - pb = mix(b, a, k); - } - let half = u.screen.xy * 0.5; - let sa = ca.xy / max(ca.w, 1e-6) * half; - let sb = cb.xy / max(cb.w, 1e-6) * half; - var dir = sb - sa; - let len = length(dir); - // A zero-length line (a node) becomes a square `w` pixels across. - if (len < 1e-6) { dir = vec2(1.0, 0.0); } else { dir = dir / len; } - let nrm = vec2(-dir.y, dir.x); - // Two triangles: corners (t, side). - var ts = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); - var ss = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); - let t = ts[vi]; - let s = ss[vi]; - let base = select(ca, cb, t > 0.5); - let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5; - var o: LOut; - o.pos = vec4(base.xy + off / half * base.w, biased_z(select(pa, pb, t > 0.5), base, 1.0), base.w); - o.c = c; - return o; -} - -@fragment -fn fs_line(i: LOut) -> @location(0) vec4 { - return out_color(i.c); -} -"#; - -fn tri_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4]; - wgpu::VertexBufferLayout { array_stride: TRI_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } -} - -fn line_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32]; - wgpu::VertexBufferLayout { array_stride: LINE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A } -} - -/// The target configuration: colour format, depth format and MSAA sample count of the app. -#[derive(Clone, Copy, Debug)] -pub struct GpuTarget { - pub format: wgpu::TextureFormat, - pub depth: Option, - pub samples: u32, -} - -impl Resources { - fn new(device: &wgpu::Device, t: GpuTarget) -> Self { - let module = - device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("fc_viewport"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) }); - let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("fc_viewport"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, - ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, - count: None, - }], - }); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("fc_viewport"), - bind_group_layouts: &[Some(&bgl)], - immediate_size: 0, - }); - let uniform = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("fc_viewport_u"), - size: UNIFORM_SIZE, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("fc_viewport"), - layout: &bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], - }); - let depth = |compare: wgpu::CompareFunction, write: bool| -> Option { - t.depth.map(|format| wgpu::DepthStencilState { - format, - depth_write_enabled: Some(write), - depth_compare: Some(compare), - stencil: Default::default(), - bias: Default::default(), - }) - }; - let pipeline = |label: &str, - vs: &str, - fs: &str, - buf: wgpu::VertexBufferLayout<'static>, - ds: Option, - blend: Option| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&layout), - vertex: wgpu::VertexState { module: &module, entry_point: Some(vs), buffers: &[Some(buf)], compilation_options: Default::default() }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, - depth_stencil: ds, - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &module, - entry_point: Some(fs), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }) - }; - Resources { - tri: pipeline("fc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), - overlay_tri: pipeline("fc_overlay_tris", "vs_overlay_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), None), - fill: pipeline( - "fc_fills", - "vs_tri", - "fs_tri", - tri_layout(), - depth(wgpu::CompareFunction::Less, false), - Some(wgpu::BlendState::ALPHA_BLENDING), - ), - line: pipeline( - "fc_lines", - "vs_line", - "fs_line", - line_layout(), - depth(wgpu::CompareFunction::LessEqual, false), - Some(wgpu::BlendState::ALPHA_BLENDING), - ), - top_line: pipeline( - "fc_top_lines", - "vs_line", - "fs_line", - line_layout(), - depth(wgpu::CompareFunction::Always, false), - Some(wgpu::BlendState::ALPHA_BLENDING), - ), - uniform, - bind, - linear_out: t.format.is_srgb(), - scene: Layer::default(), - symbols: Layer::default(), - overlay: Layer::default(), - } - } -} - -/// Byte ranges of `len` bytes of vertex data, `stride` bytes per vertex and `per_primitive` -/// vertices per primitive, for buffers of at most `max_buffer` bytes (and [`MAX_BATCH_BYTES`]). -/// Each range holds whole primitives; a trailing partial primitive is dropped. Empty when not -/// even one primitive fits. -pub fn batch_ranges(len: usize, stride: usize, per_primitive: usize, max_buffer: u64) -> Vec> { - let Some(unit) = stride.checked_mul(per_primitive).filter(|&u| u > 0) else { return Vec::new() }; - let limit = usize::try_from(max_buffer.min(MAX_BATCH_BYTES)).unwrap_or(usize::MAX); - let chunk = limit / unit * unit; - if chunk == 0 { - return Vec::new(); - } - let end = len / unit * unit; - (0..end).step_by(chunk).map(|start| start..end.min(start.saturating_add(chunk))).collect() -} - -/// Upload vertex data as batches (see [`batch_ranges`]); a failed allocation is reported to the -/// device's error handler, not a panic. -fn upload(device: &wgpu::Device, queue: &wgpu::Queue, label: &str, bytes: &[u8], stride: usize, per_primitive: usize, max_buffer: u64) -> Vec { - batch_ranges(bytes.len(), stride, per_primitive, max_buffer) - .into_iter() - .filter_map(|r| { - let data = bytes.get(r)?; - let count = u32::try_from(data.len() / stride).ok()?; - let buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some(label), - size: data.len() as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - queue.write_buffer(&buffer, 0, data); - Some(Batch { buffer, count }) - }) - .collect() + pipelines: Pipelines, + scene: Keyed, + symbols: Keyed, + overlay: Keyed, } /// Create the pipelines. Call once with the app's render state and its depth and MSAA settings. @@ -482,7 +74,7 @@ pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32, errors ctx.request_repaint(); })); let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples }; - let res = Resources::new(&rs.device, t); + let res = Resources { pipelines: Pipelines::new(&rs.device, t), scene: Keyed::default(), symbols: Keyed::default(), overlay: Keyed::default() }; rs.renderer.write().callback_resources.insert(res); t } @@ -500,7 +92,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { res.scene.take(device, queue, self.key, &self.slot); res.symbols.take(device, queue, self.symbols_key, &self.symbols_slot); res.overlay.take(device, queue, self.overlay_key, &self.overlay_slot); - queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, res.linear_out)); + res.pipelines.write_frame(queue, &self.frame); Vec::new() } @@ -511,109 +103,6 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { return; } pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0); - pass.set_bind_group(0, &res.bind, &[]); - // Faces first, the overlay's over the scene's, then every line, so the mesh edges stay on - // highlighted faces, then the lines over everything (as the CPU renderer draws them). - res.scene.paint_tris(pass, &res.tri); - res.symbols.paint_tris(pass, &res.tri); - res.overlay.paint_tris(pass, &res.overlay_tri); - for layer in [&res.scene, &res.symbols, &res.overlay] { - paint_tri_batches(&layer.fills, pass, &res.fill); - } - for layer in [&res.scene, &res.symbols, &res.overlay] { - paint_line_batches(&layer.lines, pass, &res.line); - } - for layer in [&res.scene, &res.symbols, &res.overlay] { - paint_line_batches(&layer.top_lines, pass, &res.top_line); - } - } -} - -/// Uniform bytes for a frame. -fn uniform_bytes(cb: &ViewportCallback, linear: bool) -> Vec { - let mut v: Vec = cb.view_proj.iter().flatten().copied().collect(); - v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]); - v.extend([cb.size_px[0].max(1.0), cb.size_px[1].max(1.0), 2e-4, 0.0]); - v.extend(cb.eye); - v.iter().flat_map(|x| x.to_le_bytes()).collect() -} - -#[cfg(test)] -mod tests { - use egui_wgpu::wgpu::naga; - use fieldcraft_render::{SceneLine, ScenePoint, SceneTri, Vec3}; - - use super::*; - - /// The viewport shader parses and validates (a bad shader would only fail at run time). - #[test] - fn shader_is_valid() { - let module = naga::front::wgsl::parse_str(SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(SHADER))); - naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap(); - } - - #[test] - fn batches_hold_whole_primitives_within_the_buffer_limit() { - let tri = TRI_SIZE * 3; - // Small scenes are one batch; nothing, or less than one primitive, is no batch. - assert_eq!(batch_ranges(10 * tri, TRI_SIZE, 3, 256 << 20), vec![0..10 * tri]); - assert!(batch_ranges(0, TRI_SIZE, 3, 256 << 20).is_empty()); - assert!(batch_ranges(tri - 1, TRI_SIZE, 3, 256 << 20).is_empty()); - // A trailing partial triangle is dropped. - assert_eq!(batch_ranges(2 * tri + 5, TRI_SIZE, 3, 256 << 20), vec![0..2 * tri]); - // A buffer limit smaller than one primitive, or a zero stride, gives nothing. - assert!(batch_ranges(10 * tri, TRI_SIZE, 3, (tri - 1) as u64).is_empty()); - assert!(batch_ranges(10 * tri, 0, 3, 256 << 20).is_empty()); - assert!(batch_ranges(usize::MAX, usize::MAX, 3, u64::MAX).is_empty()); - // Ten triangles under a limit of four: 4 + 4 + 2. - let r = batch_ranges(10 * tri, TRI_SIZE, 3, (4 * tri + 7) as u64); - assert_eq!(r, vec![0..4 * tri, 4 * tri..8 * tri, 8 * tri..10 * tri]); - // A few million triangles under the 256 MiB default: every batch is within - // MAX_BATCH_BYTES, a whole number of triangles, and together they cover the data. - for (len, stride, per, max) in - [(5_000_000 * tri, TRI_SIZE, 3, 256u64 << 20), (9_000_000 * LINE_SIZE, LINE_SIZE, 1, u64::MAX), (777 * tri, TRI_SIZE, 3, 1000)] - { - let r = batch_ranges(len, stride, per, max); - assert!(r.len() > 1, "{len}"); - let mut at = 0; - for b in &r { - assert_eq!(b.start, at); - assert!(b.len() as u64 <= max.min(MAX_BATCH_BYTES) && b.len() % (stride * per) == 0 && !b.is_empty()); - at = b.end; - } - assert_eq!(at, len / (stride * per) * (stride * per)); - } - } - - #[test] - fn scene_bytes_match_the_vertex_layouts() { - let scene = Scene { - tris: vec![SceneTri { p: [Vec3::ZERO, Vec3::X, Vec3::Y], normal: Vec3::Z, color: Rgb(1, 2, 3) }], - lines: vec![SceneLine { a: Vec3::ZERO, b: Vec3::Z, color: Rgb(4, 5, 6), width: 1.5 }], - points: vec![ScenePoint { p: Vec3::X, color: Rgb(7, 8, 9), size: 4.0 }], - on_top: vec![SceneLine { a: Vec3::ZERO, b: Vec3::X, color: Rgb(1, 1, 1), width: 2.0 }], - ..Default::default() - }; - let g = GpuScene::from_scene(&scene, 2.0); - assert_eq!(g.tris.len(), 3 * TRI_SIZE); - assert_eq!(g.lines.len(), 2 * LINE_SIZE); - assert_eq!(g.top_lines.len(), LINE_SIZE); - assert_eq!(&g.tris[24..28], &[1, 2, 3, 255]); - // Widths are in physical pixels. - assert_eq!(f32::from_le_bytes([g.lines[28], g.lines[29], g.lines[30], g.lines[31]]), 3.0); - assert_eq!(f32::from_le_bytes([g.lines[60], g.lines[61], g.lines[62], g.lines[63]]), 8.0); - let cb = ViewportCallback { - key: 0, - slot: SceneSlot::default(), - symbols_key: 0, - symbols_slot: SceneSlot::default(), - overlay_key: 0, - overlay_slot: SceneSlot::default(), - view_proj: [[0.0; 4]; 4], - back: [0.0; 3], - eye: [0.0; 4], - size_px: [1.0, 1.0], - }; - assert_eq!(uniform_bytes(&cb, true).len() as u64, UNIFORM_SIZE); + res.pipelines.paint(pass, &res.scene.layer, &res.symbols.layer, &res.overlay.layer); } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index fa36e77..f8363a4 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -20,7 +20,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2}; use fieldcraft_render::{StandardView, Vec3}; use crate::browser::Def; -use crate::gpu::{GpuScene, ViewportCallback}; +use crate::gpu::{Frame, GpuScene, ViewportCallback}; use crate::icons::{self, Look}; use crate::menu::{self, Target}; use crate::navigation::{self, NavButton}; @@ -463,8 +463,6 @@ fn paint_gpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R } } let cam = app.cam; - let aspect = f64::from(rect.width()) / f64::from(rect.height()).max(1.0); - let eye = cam.eye(); let (overlay_key, overlay) = app.overlay(); if app.viewport.overlay_uploaded != Some(overlay_key) && let Ok(mut slot) = app.viewport.overlay_slot.lock() @@ -479,10 +477,7 @@ fn paint_gpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R symbols_slot: app.viewport.symbols_slot.clone(), overlay_key, overlay_slot: app.viewport.overlay_slot.clone(), - view_proj: cam.view_proj(aspect, radius).to_f32(), - back: cam.back().to_f32(), - eye: [eye.x as f32, eye.y as f32, eye.z as f32, if cam.fov > 0.0 { 1.0 } else { 0.0 }], - size_px: [rect.width() * ppp, rect.height() * ppp], + frame: Frame::of(&cam, rect.width() * ppp, rect.height() * ppp, radius), }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 0f5712b..e51898b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -56,6 +56,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("doc", Class::Layer(4)), ("io", Class::Layer(5)), ("engine", Class::Layer(6)), + ("gpu", Class::Layer(7)), ("ui-egui", Class::Layer(7)), ("mcp", Class::Layer(7)), ("testkit", Class::Testkit), @@ -67,7 +68,10 @@ pub const TABLE: &[(&str, Class)] = &[ ]; /// Explicit orderings *within* a layer (earlier may be used by later). -pub const INTRA_LAYER_ORDER: &[&[&str]] = &[]; +pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ + // The window draws through the GPU crate's pipelines. + &["gpu", "ui-egui"], +]; fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to));