gpu: the viewport's pipelines move to a crate of their own (fieldcraft-gpu)

ui-egui keeps only the egui_wgpu paint callback; the shaders, batching and
pipelines are fieldcraft-gpu's view module, so an offscreen picture can draw
with the window's pipelines.

Refs #422

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 20:19:00 -04:00
1 parent 575ae10dbf
commit 248e74ca9e
12 files changed
+1079 -540

No files matched your search

+1 -1
View File
@@ -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 |
Generated
+12
View File
@@ -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",
+4
View File
@@ -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" }
+20
View File
@@ -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"] }
+285
View File
@@ -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<Output = Option<wgpu::Error>>) -> Option<wgpu::Error> {
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<Option<Capturer>> {
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<Capturer> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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<u8> = (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}");
}
}
}
+31
View File
@@ -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<T> = std::result::Result<T, GpuError>;
+144
View File
@@ -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<Policy> {
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<Policy> {
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<Option<usize>> {
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<Option<(wgpu::Adapter, Tier)>> {
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);
}
}
+554
View File
@@ -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<Mutex<Option<String>>>;
/// Scene geometry in the GPU's vertex formats, waiting to be uploaded.
#[derive(Default, Clone, Debug, PartialEq)]
pub struct GpuScene {
pub tris: Vec<u8>,
/// Translucent triangles ([`Scene::fills`]), in the triangle format with their alpha.
pub fills: Vec<u8>,
pub lines: Vec<u8>,
/// Lines drawn over everything ([`Scene::on_top`]).
pub top_lines: Vec<u8>,
}
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<u8>, 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<u8>, 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<Batch>,
fills: Vec<Batch>,
lines: Vec<Batch>,
top_lines: Vec<Batch>,
}
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<f32>,
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
screen: vec4<f32>, // viewport width, height (px), depth bias
eye: vec4<f32>, // eye position, w = 1 for perspective
};
@group(0) @binding(0) var<uniform> u: U;
fn to_linear(c: vec3<f32>) -> vec3<f32> {
return select(c, pow((c + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4)), c > vec3<f32>(0.04045));
}
fn out_color(c: vec4<f32>) -> vec4<f32> {
if (u.back.w > 0.5) {
return vec4<f32>(to_linear(c.rgb), c.a);
}
return c;
}
struct TOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) c: vec4<f32>,
};
@vertex
fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
o.pos = u.vp * vec4<f32>(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<f32> {
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<f32>(-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<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(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<f32>, c: vec4<f32>, s: f32) -> f32 {
if (u.eye.w > 0.5) {
let q = u.vp * vec4<f32>(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<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
let clip = u.vp * vec4<f32>(p, 1.0);
o.pos = vec4<f32>(clip.xy, biased_z(p, clip, 0.5), clip.w);
o.n = n;
o.c = c;
return o;
}
struct LOut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
};
@vertex
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(1) b: vec3<f32>, @location(2) c: vec4<f32>, @location(3) w: f32) -> LOut {
var pa = a;
var pb = b;
var ca = u.vp * vec4<f32>(a, 1.0);
var cb = u.vp * vec4<f32>(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<f32>(1.0, 0.0); } else { dir = dir / len; }
let nrm = vec2<f32>(-dir.y, dir.x);
// Two triangles: corners (t, side).
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
var ss = array<f32, 6>(-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<f32>(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<f32> {
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<wgpu::TextureFormat>,
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<wgpu::DepthStencilState> {
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<wgpu::DepthStencilState>,
blend: Option<wgpu::BlendState>| {
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<Range<usize>> {
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> {
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<u8> {
let mut v: Vec<f32> = 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);
}
}
+1
View File
@@ -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 }
+20 -531
View File
@@ -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<Mutex<Option<String>>>;
/// Scene geometry in the GPU's vertex formats, waiting to be uploaded.
#[derive(Default, Clone, Debug, PartialEq)]
pub struct GpuScene {
pub tris: Vec<u8>,
/// Translucent triangles ([`Scene::fills`]), in the triangle format with their alpha.
pub fills: Vec<u8>,
pub lines: Vec<u8>,
/// Lines drawn over everything ([`Scene::on_top`]).
pub top_lines: Vec<u8>,
}
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<u8>, 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<u8>, 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<Mutex<Option<GpuScene>>>;
@@ -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<u64>,
tris: Vec<Batch>,
fills: Vec<Batch>,
lines: Vec<Batch>,
top_lines: Vec<Batch>,
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<f32>,
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
screen: vec4<f32>, // viewport width, height (px), depth bias
eye: vec4<f32>, // eye position, w = 1 for perspective
};
@group(0) @binding(0) var<uniform> u: U;
fn to_linear(c: vec3<f32>) -> vec3<f32> {
return select(c, pow((c + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4)), c > vec3<f32>(0.04045));
}
fn out_color(c: vec4<f32>) -> vec4<f32> {
if (u.back.w > 0.5) {
return vec4<f32>(to_linear(c.rgb), c.a);
}
return c;
}
struct TOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) c: vec4<f32>,
};
@vertex
fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
o.pos = u.vp * vec4<f32>(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<f32> {
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<f32>(-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<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(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<f32>, c: vec4<f32>, s: f32) -> f32 {
if (u.eye.w > 0.5) {
let q = u.vp * vec4<f32>(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<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
let clip = u.vp * vec4<f32>(p, 1.0);
o.pos = vec4<f32>(clip.xy, biased_z(p, clip, 0.5), clip.w);
o.n = n;
o.c = c;
return o;
}
struct LOut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
};
@vertex
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(1) b: vec3<f32>, @location(2) c: vec4<f32>, @location(3) w: f32) -> LOut {
var pa = a;
var pb = b;
var ca = u.vp * vec4<f32>(a, 1.0);
var cb = u.vp * vec4<f32>(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<f32>(1.0, 0.0); } else { dir = dir / len; }
let nrm = vec2<f32>(-dir.y, dir.x);
// Two triangles: corners (t, side).
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
var ss = array<f32, 6>(-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<f32>(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<f32> {
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<wgpu::TextureFormat>,
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<wgpu::DepthStencilState> {
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<wgpu::DepthStencilState>,
blend: Option<wgpu::BlendState>| {
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<Range<usize>> {
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> {
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<u8> {
let mut v: Vec<f32> = 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);
}
}
+2 -7
View File
@@ -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));
}
+5 -1
View File
@@ -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));