forked from 1b-agent/fieldcraft-upstream
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
555 lines
23 KiB
Rust
555 lines
23 KiB
Rust
//! 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);
|
||
}
|
||
}
|