429 lines
20 KiB
Rust
429 lines
20 KiB
Rust
//! 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,
|
||
})
|
||
}
|