Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)
Thanks @jyviko.
This commit is contained in:
1 parent
48322f6464
commit
1a0b33fcf3
54 files changed
+3525
-1998
No files matched your search
@@ -0,0 +1,428 @@
|
||||
//! Pictures of a [`Scene`] drawn offscreen with the viewport's pipelines ([`crate::view`]): the
|
||||
//! same shading, edges, silhouettes, section cap and surface analysis the viewport shows, read
|
||||
//! back as RGBA. `solidcraft-cli snapshot`, MCP screenshots, `ui.render` and thumbnails draw
|
||||
//! through it, on the application's device ([`Capturer::new`]) or on a headless one
|
||||
//! ([`Capturer::headless`], [`shared`]). Without a qualifying adapter, the `auto` policy falls
|
||||
//! to the last tier, the CPU rasterizer ([`headless_rgba`]), and says so: every picture names the
|
||||
//! renderer that drew it (`gpu`, `gpu-software` or `cpu`); `hardware` and `software` fail with an
|
||||
//! error that names the policy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{AnalysisMode, Camera, Rgb, SECTION_CAP, Scene};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::view::{AccessMap, Frame, GpuScene, GpuTarget, Renderer, analysis_colors, analysis_uniform};
|
||||
use crate::{GpuError, Result, popped};
|
||||
|
||||
/// The colour format pictures are drawn in: sRGB bytes as they are, like the viewport's usual
|
||||
/// (non-sRGB) target.
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
|
||||
/// Samples per pixel (MSAA), as the desktop viewport's default; 1 where the device has no 4×.
|
||||
pub const SAMPLES: u32 = 4;
|
||||
/// The largest picture side (pixels); the device's texture limit may lower it.
|
||||
pub const MAX_SIDE: u32 = 8192;
|
||||
/// The background of a scene without one of its own: the light theme's viewport gradient.
|
||||
const BACKGROUND: (Rgb, Rgb) = (Rgb(236, 240, 245), Rgb(205, 212, 222));
|
||||
/// How long a picture may take on the GPU before it is given up.
|
||||
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// The viewport's pipelines on one device, drawing pictures offscreen. Pictures are drawn one at
|
||||
/// a time (callers on several threads take turns).
|
||||
pub struct Capturer {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
adapter: String,
|
||||
/// The adapter is a GPU driver running on the CPU (llvmpipe, lavapipe, WARP).
|
||||
software: bool,
|
||||
samples: u32,
|
||||
max_side: u32,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
renderer: Renderer,
|
||||
background: wgpu::RenderPipeline,
|
||||
/// The last picture's scene key and accessibility map version (each picture uploads anew).
|
||||
key: u64,
|
||||
access_version: u64,
|
||||
}
|
||||
|
||||
impl Capturer {
|
||||
/// The pipelines on an existing device (the application's).
|
||||
pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result<Self> {
|
||||
let flags = |f: wgpu::TextureFormat| adapter.get_texture_format_features(f).flags;
|
||||
let samples = if flags(FORMAT).sample_count_supported(SAMPLES) && flags(DEPTH).sample_count_supported(SAMPLES) { SAMPLES } else { 1 };
|
||||
let scopes = error_scopes(&device);
|
||||
let renderer = Renderer::new(&device, GpuTarget { format: FORMAT, depth: Some(DEPTH), samples, tess: false }, None);
|
||||
let background = background_pipeline(&device, samples);
|
||||
caught(scopes).map_or(Ok(()), |e| Err(GpuError::Pipeline(e)))?;
|
||||
let max_side = device.limits().max_texture_dimension_2d.min(MAX_SIDE);
|
||||
Ok(Capturer {
|
||||
adapter: adapter.get_info().name,
|
||||
software: adapter.get_info().device_type == wgpu::DeviceType::Cpu,
|
||||
samples,
|
||||
max_side,
|
||||
state: Mutex::new(State { renderer, background, key: 0, access_version: 0 }),
|
||||
device,
|
||||
queue,
|
||||
})
|
||||
}
|
||||
|
||||
/// A device of its own, without a window (native only): the adapter the `SOLIDCRAFT_GPU`
|
||||
/// policy picks ([`crate::select`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn headless() -> Result<Self> {
|
||||
let (adapter, device, queue) = crate::open_headless()?;
|
||||
// Errors are caught per picture (error scopes); anything else is logged, never a panic.
|
||||
device.on_uncaptured_error(Arc::new(|e: wgpu::Error| log::error!("GPU: {e}")));
|
||||
Self::new(&adapter, device, queue)
|
||||
}
|
||||
|
||||
/// The adapter's name.
|
||||
pub fn adapter_name(&self) -> &str {
|
||||
&self.adapter
|
||||
}
|
||||
|
||||
/// What a picture's reply names as its renderer: `gpu`, or `gpu-software` on a software
|
||||
/// adapter.
|
||||
pub fn renderer(&self) -> &'static str {
|
||||
if self.software { "gpu-software" } else { "gpu" }
|
||||
}
|
||||
|
||||
/// Samples per pixel the pictures are drawn with.
|
||||
pub fn samples(&self) -> u32 {
|
||||
self.samples
|
||||
}
|
||||
|
||||
/// `scene` seen by `cam`, `width` × `height` pixels: straight RGBA, rows top to bottom.
|
||||
pub fn rgba(&self, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Vec<u8>> {
|
||||
if !(1..=self.max_side).contains(&width) || !(1..=self.max_side).contains(&height) {
|
||||
return Err(GpuError::Render(format!("a picture is 1 to {} pixels a side, not {width} × {height}", self.max_side)));
|
||||
}
|
||||
let mut state = self.state.lock().map_err(|_| GpuError::Render("an earlier picture failed on this device".into()))?;
|
||||
let state = &mut *state;
|
||||
state.key = state.key.wrapping_add(1);
|
||||
let mode = scene.analysis.map(|a| a.mode);
|
||||
let access = match mode {
|
||||
Some(AnalysisMode::Access { dir }) => {
|
||||
state.access_version = state.access_version.wrapping_add(1);
|
||||
AccessMap::build(&scene.meshes.iter().map(|m| m.mesh.as_ref()).collect::<Vec<_>>(), dir, state.access_version)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let frame = frame(scene, cam, [width, height], state.key, access);
|
||||
let (device, queue) = (&self.device, &self.queue);
|
||||
let scopes = error_scopes(device);
|
||||
let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1 };
|
||||
let texture = |label: &str, format: wgpu::TextureFormat, samples: u32, usage: wgpu::TextureUsages| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: samples,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let resolved = texture("sc_capture", FORMAT, 1, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC);
|
||||
let multisampled = (self.samples > 1).then(|| texture("sc_capture_ms", FORMAT, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT));
|
||||
let depth = texture("sc_capture_depth", DEPTH, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
let (resolved_view, depth_view) = (resolved.create_view(&Default::default()), depth.create_view(&Default::default()));
|
||||
let multisampled_view = multisampled.as_ref().map(|t| t.create_view(&Default::default()));
|
||||
let (top, bottom) = scene.background.unwrap_or(BACKGROUND);
|
||||
let background = background_vertices(device, top, bottom);
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("sc_capture") });
|
||||
state.renderer.prepare(device, queue, &mut encoder, &frame);
|
||||
{
|
||||
let (view, resolve_target, store) = match &multisampled_view {
|
||||
Some(ms) => (ms, Some(&resolved_view), wgpu::StoreOp::Discard),
|
||||
None => (&resolved_view, None, wgpu::StoreOp::Store),
|
||||
};
|
||||
let mut pass = encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("sc_capture"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
depth_slice: None,
|
||||
resolve_target,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), 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: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }),
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
})
|
||||
.forget_lifetime();
|
||||
// The background first, as the viewport paints it before the model.
|
||||
pass.set_pipeline(&state.background);
|
||||
pass.set_vertex_buffer(0, background.slice(..));
|
||||
pass.draw(0..6, 0..1);
|
||||
state.renderer.paint(&frame, &mut pass, [0.0, 0.0, width as f32, height as f32]);
|
||||
}
|
||||
let row = width * 4;
|
||||
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("sc_capture_read"),
|
||||
size: u64::from(padded) * u64::from(height),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
resolved.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(height) },
|
||||
},
|
||||
size,
|
||||
);
|
||||
let submission = queue.submit([encoder.finish()]);
|
||||
if let Some(e) = caught(scopes) {
|
||||
return Err(GpuError::Render(e));
|
||||
}
|
||||
let slice = read.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
device
|
||||
.poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(TIMEOUT) })
|
||||
.map_err(|e| GpuError::Render(format!("waiting for the picture: {e}")))?;
|
||||
match rx.try_recv() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(GpuError::Render(format!("reading the picture back: {e}"))),
|
||||
Err(_) => return Err(GpuError::Unsupported("reading a picture back without waiting for the browser".into())),
|
||||
}
|
||||
let data = slice.get_mapped_range().map_err(|e| GpuError::Render(format!("reading the picture back: {e}")))?;
|
||||
let mut rgba = Vec::with_capacity((row as usize) * (height as usize));
|
||||
for r in data.chunks(padded as usize).take(height as usize) {
|
||||
rgba.extend_from_slice(r.get(..row as usize).unwrap_or_default());
|
||||
}
|
||||
if rgba.len() != (row as usize) * (height as usize) {
|
||||
return Err(GpuError::Render("the picture read back short".into()));
|
||||
}
|
||||
Ok(rgba)
|
||||
}
|
||||
|
||||
/// [`Capturer::rgba`] as PNG bytes.
|
||||
pub fn png(&self, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Vec<u8>> {
|
||||
let rgba = self.rgba(scene, cam, width, height)?;
|
||||
solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| GpuError::Render("PNG encoding failed".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The process's headless capturer (native only), opened on first use under the `SOLIDCRAFT_GPU`
|
||||
/// policy and kept; the error when no adapter qualifies or the device fails (not retried).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn shared() -> std::result::Result<&'static Capturer, &'static GpuError> {
|
||||
static SHARED: std::sync::OnceLock<Result<Capturer>> = std::sync::OnceLock::new();
|
||||
SHARED.get_or_init(Capturer::headless).as_ref()
|
||||
}
|
||||
|
||||
/// What draws this process's headless pictures (native only): the shared headless device, or the
|
||||
/// CPU rasterizer where the `SOLIDCRAFT_GPU` policy lands there (`cpu`, or `auto` with no
|
||||
/// qualifying adapter).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub enum Headless {
|
||||
Gpu(&'static Capturer),
|
||||
Cpu,
|
||||
}
|
||||
|
||||
/// [`Headless`] under the `SOLIDCRAFT_GPU` policy; an error when the policy is `hardware` or
|
||||
/// `software` and no adapter of that tier opens (or the variable names no policy).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn headless() -> std::result::Result<Headless, String> {
|
||||
use crate::select::Policy;
|
||||
let policy = Policy::from_env()?;
|
||||
if policy == Policy::Cpu {
|
||||
return Ok(Headless::Cpu);
|
||||
}
|
||||
match shared() {
|
||||
Ok(c) => Ok(Headless::Gpu(c)),
|
||||
Err(e) if policy == Policy::Auto => {
|
||||
log::warn!("pictures are drawn by the CPU rasterizer: {e}");
|
||||
Ok(Headless::Cpu)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `scene` drawn by [`headless`]: straight RGBA and the renderer that drew it (`gpu`,
|
||||
/// `gpu-software` or `cpu`). The CPU rasterizer draws no surface analysis, highlights or legends.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn headless_rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> std::result::Result<(Vec<u8>, &'static str), String> {
|
||||
match headless()? {
|
||||
Headless::Gpu(c) => c.rgba(scene, cam, width, height).map(|rgba| (rgba, c.renderer())).map_err(|e| e.to_string()),
|
||||
Headless::Cpu => {
|
||||
if !(1..=MAX_SIDE).contains(&width) || !(1..=MAX_SIDE).contains(&height) {
|
||||
return Err(format!("a picture is 1 to {MAX_SIDE} pixels a side, not {width} × {height}"));
|
||||
}
|
||||
let canvas = solidcraft_render::render(scene, cam, width as usize, height as usize);
|
||||
Ok((canvas.rgba, "cpu"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame parameters of a picture: the scene's own geometry as the model scene (key `key`),
|
||||
/// the camera, the section, the analysis, and the silhouette outlines of the default visual style.
|
||||
fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, access: Option<AccessMap>) -> Frame {
|
||||
let empty = || Arc::new(Mutex::new(None));
|
||||
let eye = cam.eye().to_f32();
|
||||
let cap = scene.cap.unwrap_or(SECTION_CAP);
|
||||
let mode = scene.analysis.map(|a| a.mode);
|
||||
Frame {
|
||||
product: false,
|
||||
key,
|
||||
slot: Arc::new(Mutex::new(Some(scene_geometry(scene)))),
|
||||
sketch_key: 0,
|
||||
sketch_slot: empty(),
|
||||
hl_key: 0,
|
||||
hl_slot: empty(),
|
||||
pv_key: 0,
|
||||
pv_slot: empty(),
|
||||
view_proj: cam.view_proj(f64::from(width) / f64::from(height.max(1)), scene.radius.max(1.0)).to_f32(),
|
||||
back: cam.back().to_f32(),
|
||||
size_px: [width as f32, height as f32],
|
||||
clip: scene.clip.map_or([0.0; 4], |(n, d)| [n.x as f32, n.y as f32, n.z as f32, d as f32]),
|
||||
cap: [f32::from(cap.0) / 255.0, f32::from(cap.1) / 255.0, f32::from(cap.2) / 255.0, 1.0],
|
||||
images: Vec::new(),
|
||||
analysis: analysis_uniform(mode, access.as_ref()),
|
||||
analysis_colors: scene.analysis.map_or([0.0; 36], |a| analysis_colors(&a.colors)),
|
||||
access,
|
||||
outline: true,
|
||||
eye: [eye[0], eye[1], eye[2], if cam.fov > 0.0 { 1.0 } else { 0.0 }],
|
||||
ssaa: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A scene's meshes and lines as the viewport's model scene: faces in their colours (smooth
|
||||
/// normals; a face normal where a vertex has none), lines as wide lines (over everything when
|
||||
/// `on_top`), the grid under everything.
|
||||
pub fn scene_geometry(scene: &Scene) -> GpuScene {
|
||||
let mut sc = GpuScene::default();
|
||||
let c4 = |c: Rgb| [c.0, c.1, c.2, 255];
|
||||
for m in &scene.meshes {
|
||||
let faces: HashMap<u32, Rgb> = m.face_colors.iter().copied().collect();
|
||||
let mesh = &m.mesh;
|
||||
for (ti, t) in mesh.triangles.iter().enumerate() {
|
||||
let col = c4(mesh.tri_face.get(ti).and_then(|f| faces.get(f)).copied().unwrap_or(m.color));
|
||||
let [Some(a), Some(b), Some(c)] = t.map(|k| mesh.positions.get(k as usize).copied()) else { continue };
|
||||
let flat = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z);
|
||||
for (k, p) in t.iter().zip([a, b, c]) {
|
||||
let n = mesh.normals.get(*k as usize).copied().filter(|n| n.is_finite() && n.len() > 0.5).unwrap_or(flat);
|
||||
sc.tri(p.to_f32(), n.to_f32(), col);
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &scene.grid {
|
||||
for w in l.points.windows(2) {
|
||||
sc.under_line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32);
|
||||
}
|
||||
}
|
||||
for l in &scene.lines {
|
||||
for w in l.points.windows(2) {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32, l.on_top);
|
||||
}
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
/// Error scopes catching what a picture's GPU work may raise (popped by [`caught`]).
|
||||
fn error_scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 3] {
|
||||
[wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f))
|
||||
}
|
||||
|
||||
/// The first error the scopes caught.
|
||||
fn caught(scopes: [wgpu::ErrorScopeGuard; 3]) -> Option<String> {
|
||||
let mut failed = None;
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(e) = popped(scope.pop()) {
|
||||
failed.get_or_insert(e.to_string());
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
/// The background gradient: the viewport's top colour at the top, its bottom one at the bottom,
|
||||
/// mixed in sRGB as the window paints it.
|
||||
const BACKGROUND_SHADER: &str = r#"
|
||||
struct O { @builtin(position) pos: vec4<f32>, @location(0) color: vec4<f32> };
|
||||
@vertex
|
||||
fn vs(@location(0) p: vec2<f32>, @location(1) c: vec4<f32>) -> O {
|
||||
var o: O;
|
||||
o.pos = vec4<f32>(p, 0.0, 1.0);
|
||||
o.color = c;
|
||||
return o;
|
||||
}
|
||||
@fragment
|
||||
fn fs(i: O) -> @location(0) vec4<f32> {
|
||||
return i.color;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Background vertex: NDC position (2 × f32) and sRGB colour (4 × u8).
|
||||
const BACKGROUND_VERTEX: u64 = 12;
|
||||
|
||||
fn background_vertices(device: &wgpu::Device, top: Rgb, bottom: Rgb) -> wgpu::Buffer {
|
||||
let mut bytes = Vec::with_capacity(6 * BACKGROUND_VERTEX as usize);
|
||||
let corner = |x: f32, y: f32| (x, y, if y > 0.0 { top } else { bottom });
|
||||
for (x, y, c) in [corner(-1.0, 1.0), corner(1.0, 1.0), corner(1.0, -1.0), corner(-1.0, 1.0), corner(1.0, -1.0), corner(-1.0, -1.0)] {
|
||||
bytes.extend_from_slice(&x.to_le_bytes());
|
||||
bytes.extend_from_slice(&y.to_le_bytes());
|
||||
bytes.extend_from_slice(&[c.0, c.1, c.2, 255]);
|
||||
}
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("sc_capture_background"),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
})
|
||||
}
|
||||
|
||||
fn background_pipeline(device: &wgpu::Device, samples: u32) -> wgpu::RenderPipeline {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("sc_capture_background"),
|
||||
source: wgpu::ShaderSource::Wgsl(BACKGROUND_SHADER.into()),
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("sc_capture_background"),
|
||||
bind_group_layouts: &[],
|
||||
immediate_size: 0,
|
||||
});
|
||||
const A: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Unorm8x4];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_capture_background"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: BACKGROUND_VERTEX, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A })],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState { count: samples, mask: !0, alpha_to_coverage_enabled: false },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
+24
-14
@@ -1,7 +1,9 @@
|
||||
//! GPU evaluation of display tessellation plans: the surface point and normal at every
|
||||
//! vertex's parameters, in a compute shader (`tess.wgsl`), the GPU twin of
|
||||
//! [`TessPlan::evaluate`]. Shared edge nodes keep their exact positions, so faces still meet
|
||||
//! exactly; evaluation runs in f32.
|
||||
//! SolidCraft's GPU work: the viewport's render pipelines ([`view`]), pictures drawn with them
|
||||
//! offscreen ([`capture`]), and display tessellation plans evaluated in a compute shader.
|
||||
//!
|
||||
//! Plan evaluation: the surface point and normal at every vertex's parameters, in a compute
|
||||
//! shader (`tess.wgsl`), the GPU twin of [`TessPlan::evaluate`]. Shared edge nodes keep their
|
||||
//! exact positions, so faces still meet exactly; evaluation runs in f32.
|
||||
//!
|
||||
//! Use [`TessGpu::new`] with the application's device, or [`TessGpu::headless`] (native only)
|
||||
//! to open one without a window, as the CLI and tests do. A viewport draws a plan straight from
|
||||
@@ -17,6 +19,12 @@ use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::tess::{EvalCurve, EvalSurface, TessPlan};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub mod capture;
|
||||
mod pbr;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod select;
|
||||
pub mod view;
|
||||
|
||||
/// The tessellation compute shader (WGSL).
|
||||
pub const SHADER: &str = include_str!("tess.wgsl");
|
||||
|
||||
@@ -30,17 +38,15 @@ fn popped(f: impl std::future::Future<Output = Option<wgpu::Error>>) -> Option<w
|
||||
}
|
||||
}
|
||||
|
||||
/// An adapter and device without a window (native only). The environment may pick the backend
|
||||
/// and the D3D12 shader compiler (`WGPU_BACKEND`, `WGPU_DX12_COMPILER`).
|
||||
/// An adapter and device without a window (native only): the adapter [`select::pick`] chooses
|
||||
/// under the `SOLIDCRAFT_GPU` policy. The environment may narrow the backends and pick the D3D12
|
||||
/// shader compiler (`WGPU_BACKEND`, `WGPU_DX12_COMPILER`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
let adapter = pollster::block_on(
|
||||
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
|
||||
)
|
||||
.map_err(|_| GpuError::NoAdapter)?;
|
||||
let policy = select::Policy::from_env().map_err(GpuError::Policy)?;
|
||||
let (adapter, _) = select::pick(policy).ok_or(GpuError::NoAdapter(policy.name()))?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("solidcraft compute"),
|
||||
label: Some("solidcraft headless"),
|
||||
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
|
||||
..Default::default()
|
||||
}))
|
||||
@@ -54,12 +60,16 @@ const WORKGROUP: u32 = 64;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GpuError {
|
||||
#[error("no GPU adapter is available")]
|
||||
NoAdapter,
|
||||
#[error("no GPU adapter qualifies under the `{0}` policy (SOLIDCRAFT_GPU); `solidcraft-cli gpu` lists the adapters this machine offers")]
|
||||
NoAdapter(&'static str),
|
||||
#[error("{0}")]
|
||||
Policy(String),
|
||||
#[error("the GPU device could not be opened: {0}")]
|
||||
Device(String),
|
||||
#[error("the GPU evaluation failed: {0}")]
|
||||
Failed(String),
|
||||
#[error("the GPU render failed: {0}")]
|
||||
Render(String),
|
||||
#[error("the GPU tessellation pipeline could not be created: {0}")]
|
||||
Pipeline(String),
|
||||
#[error("not supported yet: {0}")]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Original GPU translation of the L1 opaque surface and fixed studio reference.
|
||||
//! Appended to the existing viewport shader so camera/output conventions are shared.
|
||||
|
||||
pub const VERTEX_SIZE: usize = 48;
|
||||
|
||||
pub const SOURCE: &str = r#"
|
||||
struct PbrOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) n: vec3<f32>,
|
||||
@location(1) base: vec3<f32>,
|
||||
@location(2) wp: vec3<f32>,
|
||||
@location(3) parameters: vec3<f32>,
|
||||
};
|
||||
|
||||
fn pbr_unit(v: vec3<f32>) -> vec3<f32> {
|
||||
let scale = max(max(abs(v.x), abs(v.y)), abs(v.z));
|
||||
if (scale == 0.0) { return vec3<f32>(0.0, 0.0, 1.0); }
|
||||
let s = v / scale;
|
||||
return s / length(s);
|
||||
}
|
||||
|
||||
fn pbr_encode(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(c * 12.92, 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055), c > vec3<f32>(0.0031308));
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) base: vec3<f32>, @location(3) parameters: vec3<f32>) -> PbrOut {
|
||||
var o: PbrOut;
|
||||
o.pos = u.vp * vec4<f32>(p, 1.0);
|
||||
o.n = n;
|
||||
o.base = base;
|
||||
o.wp = p;
|
||||
o.parameters = parameters;
|
||||
return o;
|
||||
}
|
||||
|
||||
fn pbr_fresnel(f0: vec3<f32>, cosine: f32) -> vec3<f32> {
|
||||
let t = 1.0 - cosine;
|
||||
return f0 + (vec3<f32>(1.0) - f0) * (t * t * t * t * t);
|
||||
}
|
||||
|
||||
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
|
||||
let nv = clamp(dot(n, v), -1.0, 1.0);
|
||||
let nl = clamp(dot(n, l), -1.0, 1.0);
|
||||
if (nv <= 0.0 || nl <= 0.0) { return vec3<f32>(0.0); }
|
||||
let h = pbr_unit(v + l);
|
||||
let nh = clamp(dot(n, h), 0.0, 1.0);
|
||||
let vh = clamp(dot(v, h), 0.0, 1.0);
|
||||
let alpha = parameters.y * parameters.y;
|
||||
let a2 = alpha * alpha;
|
||||
let denominator = (1.0 - nh * nh) + nh * nh * a2;
|
||||
let distribution = a2 / (3.141592653589793 * denominator * denominator);
|
||||
let gv = 2.0 / (nv + sqrt(a2 + (1.0 - a2) * nv * nv));
|
||||
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
|
||||
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
|
||||
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
|
||||
let specular = pbr_fresnel(f0, vh) * (distribution * 0.25 * gv * gl);
|
||||
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
|
||||
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl)) * diffuse;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_pbr(i: PbrOut) -> @location(0) vec4<f32> {
|
||||
let n = pbr_unit(i.n);
|
||||
var view = u.back.xyz;
|
||||
if (u.eye.w > 0.5) { view = u.eye.xyz - i.wp; }
|
||||
let v = pbr_unit(view);
|
||||
let lights = array<vec4<f32>, 3>(vec4<f32>(0.6, 0.0, 0.8, 3.0), vec4<f32>(-0.8, 0.0, 0.6, 1.0), vec4<f32>(0.0, -0.6, 0.8, 1.5));
|
||||
var radiance = vec3<f32>(0.0);
|
||||
for (var k = 0u; k < 3u; k += 1u) {
|
||||
let light = lights[k];
|
||||
radiance += pbr_brdf(i.base, i.parameters, n, v, light.xyz) * (light.w * max(dot(n, light.xyz), 0.0));
|
||||
}
|
||||
let mapped = radiance / (vec3<f32>(1.0) + radiance);
|
||||
if (u.back.w > 0.5) { return vec4<f32>(mapped, 1.0); }
|
||||
return vec4<f32>(pbr_encode(mapped), 1.0);
|
||||
}
|
||||
"#;
|
||||
@@ -0,0 +1,261 @@
|
||||
//! What draws a headless picture: every adapter the platform's native backends offer is
|
||||
//! classified as hardware (a discrete, integrated or virtual GPU) or software (a GPU driver that
|
||||
//! runs on the CPU, such as llvmpipe, lavapipe or WARP), and a [`Policy`] picks one. The last
|
||||
//! tier, below any adapter, is SolidCraft's own CPU rasterizer (no surface analysis, highlights
|
||||
//! or legends): `auto` lands there only when no adapter qualifies, `cpu` forces it, `hardware`
|
||||
//! and `software` never use it. macOS uses Metal only, and has no software adapter. `SOLIDCRAFT_GPU`
|
||||
//! (`auto`, `hardware`, `software`, `cpu`) sets the policy from the environment; `WGPU_BACKEND`
|
||||
//! still narrows the backends.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Whether an adapter is a GPU or a software rasterizer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Tier {
|
||||
Hardware,
|
||||
Software,
|
||||
}
|
||||
|
||||
/// Which adapters a headless device may open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Policy {
|
||||
/// The best hardware adapter, else a software one.
|
||||
#[default]
|
||||
Auto,
|
||||
/// A hardware adapter or nothing.
|
||||
Hardware,
|
||||
/// A software adapter or nothing.
|
||||
Software,
|
||||
/// The CPU rasterizer, whatever adapters there are.
|
||||
Cpu,
|
||||
}
|
||||
|
||||
/// Where a policy lands: an adapter of a tier, or the CPU rasterizer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Landing {
|
||||
Hardware,
|
||||
Software,
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl Landing {
|
||||
/// What a picture's reply names as its renderer: `gpu`, `gpu-software` or `cpu`.
|
||||
pub fn renderer(self) -> &'static str {
|
||||
match self {
|
||||
Landing::Hardware => "gpu",
|
||||
Landing::Software => "gpu-software",
|
||||
Landing::Cpu => "cpu",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Tier> for Landing {
|
||||
fn from(t: Tier) -> Self {
|
||||
match t {
|
||||
Tier::Hardware => Landing::Hardware,
|
||||
Tier::Software => Landing::Software,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// `auto`, `hardware`, `software` or `cpu` (case-insensitive).
|
||||
pub fn parse(s: &str) -> Option<Policy> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Some(Policy::Auto),
|
||||
"hardware" | "hw" | "gpu" => Some(Policy::Hardware),
|
||||
"software" | "sw" => Some(Policy::Software),
|
||||
"cpu" => Some(Policy::Cpu),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The policy `SOLIDCRAFT_GPU` names; [`Policy::Auto`] when it is unset, and an error when
|
||||
/// it names none.
|
||||
pub fn from_env() -> Result<Policy, String> {
|
||||
match std::env::var("SOLIDCRAFT_GPU") {
|
||||
Ok(v) => Policy::parse(&v).ok_or_else(|| format!("SOLIDCRAFT_GPU must be auto, hardware, software or cpu, not `{v}`")),
|
||||
Err(_) => Ok(Policy::Auto),
|
||||
}
|
||||
}
|
||||
|
||||
/// `auto`, `hardware`, `software` or `cpu`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Policy::Auto => "auto",
|
||||
Policy::Hardware => "hardware",
|
||||
Policy::Software => "software",
|
||||
Policy::Cpu => "cpu",
|
||||
}
|
||||
}
|
||||
|
||||
fn admits(self, tier: Tier) -> bool {
|
||||
match self {
|
||||
Policy::Auto => true,
|
||||
Policy::Hardware => tier == Tier::Hardware,
|
||||
Policy::Software => tier == Tier::Software,
|
||||
Policy::Cpu => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the policy lands when the best qualifying adapter is of tier `adapter` (`None`: no
|
||||
/// adapter qualifies): that adapter; else the CPU rasterizer under `auto` (and always under
|
||||
/// `cpu`); else nowhere, and a picture is an error.
|
||||
pub fn lands_on(self, adapter: Option<Tier>) -> Option<Landing> {
|
||||
match (self, adapter) {
|
||||
(Policy::Cpu, _) => Some(Landing::Cpu),
|
||||
(_, Some(t)) => Some(t.into()),
|
||||
(Policy::Auto, None) => Some(Landing::Cpu),
|
||||
(Policy::Hardware | Policy::Software, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One adapter as the survey saw it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdapterReport {
|
||||
pub name: String,
|
||||
pub backend: String,
|
||||
pub device_type: String,
|
||||
pub driver: String,
|
||||
pub tier: Tier,
|
||||
}
|
||||
|
||||
/// The backends a headless device looks at on this platform: Metal on macOS, Vulkan and D3D12
|
||||
/// on Windows, Vulkan and GL elsewhere; `WGPU_BACKEND` narrows them further.
|
||||
pub fn native_backends() -> wgpu::Backends {
|
||||
let native = if cfg!(target_os = "macos") {
|
||||
wgpu::Backends::METAL
|
||||
} else if cfg!(target_os = "windows") {
|
||||
wgpu::Backends::DX12 | wgpu::Backends::VULKAN
|
||||
} else {
|
||||
wgpu::Backends::VULKAN | wgpu::Backends::GL
|
||||
};
|
||||
match wgpu::Backends::from_env() {
|
||||
Some(env) if !(env & native).is_empty() => env & native,
|
||||
_ => native,
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_of(t: wgpu::DeviceType) -> Tier {
|
||||
match t {
|
||||
wgpu::DeviceType::Cpu => Tier::Software,
|
||||
wgpu::DeviceType::DiscreteGpu | wgpu::DeviceType::IntegratedGpu | wgpu::DeviceType::VirtualGpu | wgpu::DeviceType::Other => Tier::Hardware,
|
||||
}
|
||||
}
|
||||
|
||||
/// Preference among adapters (higher first): a discrete GPU, an integrated one, a virtual one,
|
||||
/// an unknown kind, a software rasterizer.
|
||||
fn rank(t: wgpu::DeviceType) -> u8 {
|
||||
match t {
|
||||
wgpu::DeviceType::DiscreteGpu => 4,
|
||||
wgpu::DeviceType::IntegratedGpu => 3,
|
||||
wgpu::DeviceType::VirtualGpu => 2,
|
||||
wgpu::DeviceType::Other => 1,
|
||||
wgpu::DeviceType::Cpu => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn report(info: &wgpu::AdapterInfo) -> AdapterReport {
|
||||
AdapterReport {
|
||||
name: info.name.clone(),
|
||||
backend: format!("{:?}", info.backend),
|
||||
device_type: format!("{:?}", info.device_type),
|
||||
driver: [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" "),
|
||||
tier: tier_of(info.device_type),
|
||||
}
|
||||
}
|
||||
|
||||
fn instance() -> wgpu::Instance {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
|
||||
desc.backends = native_backends();
|
||||
wgpu::Instance::new(desc)
|
||||
}
|
||||
|
||||
/// Every adapter the native backends offer, best first.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn adapters(instance: &wgpu::Instance) -> Vec<wgpu::Adapter> {
|
||||
let mut all = pollster::block_on(instance.enumerate_adapters(native_backends()));
|
||||
all.sort_by_key(|a| std::cmp::Reverse(rank(a.get_info().device_type)));
|
||||
all
|
||||
}
|
||||
|
||||
/// What a survey found, which adapter the policy picks (`chosen` indexes `adapters`) and where
|
||||
/// it lands (`None`: nowhere, a picture is an error).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Survey {
|
||||
pub backends: String,
|
||||
pub adapters: Vec<AdapterReport>,
|
||||
pub chosen: Option<usize>,
|
||||
pub lands_on: Option<Landing>,
|
||||
}
|
||||
|
||||
/// Every adapter the native backends offer, best first, and which one `policy` picks.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn survey(policy: Policy) -> Survey {
|
||||
let found: Vec<AdapterReport> = adapters(&instance()).iter().map(|a| report(&a.get_info())).collect();
|
||||
let chosen = found.iter().position(|r| policy.admits(r.tier));
|
||||
let lands_on = policy.lands_on(chosen.and_then(|i| found.get(i)).map(|r| r.tier));
|
||||
Survey { backends: format!("{:?}", native_backends()), adapters: found, chosen, lands_on }
|
||||
}
|
||||
|
||||
/// The adapter `policy` picks, with its report; `None` when no adapter qualifies.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn pick(policy: Policy) -> Option<(wgpu::Adapter, AdapterReport)> {
|
||||
adapters(&instance())
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let r = report(&a.get_info());
|
||||
(a, r)
|
||||
})
|
||||
.find(|(_, r)| policy.admits(r.tier))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn policies_parse_and_admit_their_tiers() {
|
||||
assert_eq!(Policy::parse(" Hardware "), Some(Policy::Hardware));
|
||||
assert_eq!(Policy::parse("sw"), Some(Policy::Software));
|
||||
assert_eq!(Policy::parse("metal"), None);
|
||||
assert_eq!(Policy::parse("CPU"), Some(Policy::Cpu));
|
||||
assert!(!Policy::Cpu.admits(Tier::Software) && !Policy::Cpu.admits(Tier::Hardware));
|
||||
assert!(Policy::Auto.admits(Tier::Software) && Policy::Auto.admits(Tier::Hardware));
|
||||
assert!(!Policy::Hardware.admits(Tier::Software));
|
||||
assert!(!Policy::Software.admits(Tier::Hardware));
|
||||
}
|
||||
|
||||
/// Hardware GPU, then a software adapter, then the CPU rasterizer: `auto` walks all three,
|
||||
/// `hardware` and `software` never fall to the CPU, and `cpu` always lands there.
|
||||
#[test]
|
||||
fn policies_land_on_their_tiers() {
|
||||
use Landing as L;
|
||||
let (hw, sw) = (Some(Tier::Hardware), Some(Tier::Software));
|
||||
assert_eq!([hw, sw, None].map(|t| Policy::Auto.lands_on(t)), [Some(L::Hardware), Some(L::Software), Some(L::Cpu)]);
|
||||
assert_eq!(Policy::Hardware.lands_on(hw), Some(L::Hardware));
|
||||
assert_eq!(Policy::Hardware.lands_on(None), None);
|
||||
assert_eq!(Policy::Software.lands_on(sw), Some(L::Software));
|
||||
assert_eq!(Policy::Software.lands_on(None), None);
|
||||
assert_eq!([hw, sw, None].map(|t| Policy::Cpu.lands_on(t)), [Some(L::Cpu); 3]);
|
||||
assert_eq!([L::Hardware, L::Software, L::Cpu].map(L::renderer), ["gpu", "gpu-software", "cpu"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_rasterizers_rank_last() {
|
||||
assert_eq!(tier_of(wgpu::DeviceType::Cpu), Tier::Software);
|
||||
assert!(rank(wgpu::DeviceType::IntegratedGpu) > rank(wgpu::DeviceType::Cpu));
|
||||
assert!(rank(wgpu::DeviceType::DiscreteGpu) > rank(wgpu::DeviceType::IntegratedGpu));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_surveys_metal_only() {
|
||||
assert_eq!(native_backends() & !wgpu::Backends::METAL, wgpu::Backends::empty());
|
||||
let s = survey(Policy::Auto);
|
||||
assert!(s.adapters.iter().all(|a| a.backend == "Metal"), "{s:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user