Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-15 22:19:26 -04:00
1 parent 48322f6464
commit 1a0b33fcf3
54 files changed
+3525 -1998

No files matched your search

+428
View File
@@ -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
View File
@@ -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}")]
+78
View File
@@ -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);
}
"#;
+261
View File
@@ -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