Files
lightcraft/crates/gpu/src/ctx.rs
T
Brandon ThomasandClaude Opus 5.5 8c1c7282b2 LR-MASK-BRUSH + LR-MASK-SLIDERS: Auto Mask brush and local Noise / Moiré / Defringe on CPU and GPU
Auto Mask strokes weight each dab by similarity (log luminance + chromaticity) to the pixel under
its centre and are refined by a cross guided filter on log luminance (max-combined, never removing
paint). Local Noise smooths small-amplitude luminance detail (negative boosts it) and calms colour
noise; Moiré blends chromaticity towards a blurred plane (appended to the GPU mask buffer);
Defringe desaturates purple/green casts on edges. Pixel tests per slider and for the edge
constraint, CPU/GPU equivalence cases, Auto Mask checkbox and Moiré/Defringe sliders in the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:09:14 -04:00

380 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The wgpu device, the compiled kernels, buffers, dispatch and readback.
use std::collections::HashMap;
use wgpu::util::DeviceExt;
/// A kernel module: WGSL source, its storage bindings (name, writable, element type) after the
/// parameter block `P` (binding 0), and its entry points.
struct Module {
src: &'static str,
bindings: &'static [(&'static str, bool, &'static str)],
entries: &'static [&'static str],
}
const MODULES: &[Module] = &[
Module {
src: include_str!("wgsl/finish.wgsl"),
bindings: &[
("img", false, "f32"),
("log_l", false, "f32"),
("base_p", false, "f32"),
("clar", false, "f32"),
("tex", false, "f32"),
("dark", false, "f32"),
("masks", false, "f32"),
("aux", false, "f32"),
("out", true, "u32"),
],
entries: &["main"],
},
Module { src: include_str!("wgsl/blur.wgsl"), bindings: &[("src", false, "f32"), ("dst", true, "f32")], entries: &["box_h", "box_v"] },
Module {
src: include_str!("wgsl/resize.wgsl"),
bindings: &[("src", false, "f32"), ("table", false, "u32"), ("dst", true, "f32")],
entries: &["resize_h", "resize_v"],
},
Module {
src: include_str!("wgsl/map.wgsl"),
bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")],
entries: &[
"log_lum_k",
"dark_k",
"guided_pre",
"guided_ab",
"guided_apply",
"wb_k",
"nr_lum",
"chroma_k",
"nr_col",
"subsample",
"redeye_k",
"xguided_pre",
"xguided_ab",
"xguided_apply",
],
},
Module {
src: include_str!("wgsl/mask.wgsl"),
bindings: &[("img", false, "f32"), ("log_l", false, "f32"), ("aux", false, "f32"), ("c", true, "f32"), ("alpha", true, "f32")],
entries: &["shape", "combine", "finalize"],
},
Module {
src: include_str!("wgsl/geom.wgsl"),
bindings: &[("src", false, "f32"), ("dst", true, "f32")],
entries: &["orient", "sample_affine", "sample_warp"],
},
];
struct Kernel {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
nbuf: usize,
}
/// A device buffer of `len` 32-bit values.
///
/// Dropped buffers are recycled: they first wait in a per-thread list (commands recorded on this
/// thread may still use them) and become reusable once this thread submits ([`Gpu::submitted`]),
/// as the queue then runs every earlier use before any later one. Reuse matters: allocating (and
/// zero-filling) a fresh 100–300 MB buffer per pass costs as much as the pass itself.
pub struct Buf {
buf: Option<wgpu::Buffer>,
pub len: usize,
}
impl Buf {
pub fn raw(&self) -> &wgpu::Buffer {
self.buf.as_ref().expect("live buffer")
}
}
impl Drop for Buf {
fn drop(&mut self) {
if let Some(b) = self.buf.take() {
// during thread teardown the list may already be gone: then the buffer leaks
let mut b = Some(b);
let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take()));
if let Some(b) = b {
std::mem::forget(b);
}
}
}
}
/// Buffers dropped on a thread since its last submit.
struct Retired(std::cell::RefCell<Vec<wgpu::Buffer>>);
impl Drop for Retired {
/// At thread exit wgpu's own thread-locals may already be destroyed, and dropping a buffer
/// then aborts the process: leak the few buffers left instead (the device owns their memory).
fn drop(&mut self) {
for b in self.0.get_mut().drain(..) {
std::mem::forget(b);
}
}
}
thread_local! {
static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) };
}
/// Most bytes kept in the free pool.
const POOL_BYTES: u64 = 2 << 30;
pub struct Gpu {
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub info: wgpu::AdapterInfo,
kernels: HashMap<&'static str, Kernel>,
dummy: wgpu::Buffer,
/// Recycled buffers (see [`Buf`]).
free: std::sync::Mutex<Vec<wgpu::Buffer>>,
/// Largest storage buffer the device accepts (bytes).
pub max_buffer: u64,
}
/// WGSL constants shared by every module (generated from the CPU pipeline's values).
fn constants() -> String {
use lightcraft_pipeline::finish::{GRAIN_HASH, MASK_SUMS, MASK_TERMS, SRGB_LUT_N};
use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N};
let mut s = String::new();
let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices();
let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32();
for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab), ("TO_SRGB", to_srgb)] {
let rows: Vec<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
}
s += &format!("const TONE_MIN_EV: f32 = {LUT_MIN_EV:?};\nconst TONE_MAX_EV: f32 = {LUT_MAX_EV:?};\nconst TONE_N: u32 = {LUT_N}u;\n");
s += &format!("const TONE_MIN_GAIN: f32 = {:?};\n", 2f32.powf(LUT_MIN_EV));
let b = lightcraft_pipeline::geometry::BLANK;
s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]);
s += &format!(
"const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\nconst MASK_SUMS: u32 = {MASK_SUMS}u;\n",
crate::params::CURVE_N
);
s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS);
s += &format!("const EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_WORDS);
use lightcraft_pipeline::masks::{AUTO_TOL_CHROMA, AUTO_TOL_EV};
s += &format!("const AUTO_TOL_EV: f32 = {AUTO_TOL_EV:?};\nconst AUTO_TOL_CHROMA: f32 = {AUTO_TOL_CHROMA:?};\n");
s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT);
for (i, h) in GRAIN_HASH.iter().enumerate() {
s += &format!("const GRAIN_H{i}: u32 = {h}u;\n");
}
for (name, idx) in crate::params::finish_fields() {
s += &format!("const F_{name}: u32 = {idx}u;\n");
}
s
}
fn module_source(m: &Module, consts: &str) -> String {
let mut s = String::from(consts);
s += "@group(0) @binding(0) var<storage, read> P: array<u32>;\n";
for (i, (name, write, ty)) in m.bindings.iter().enumerate() {
let access = if *write { "read_write" } else { "read" };
s += &format!("@group(0) @binding({}) var<storage, {access}> {name}: array<{ty}>;\n", i + 1);
}
s += include_str!("wgsl/common.wgsl");
s += m.src;
s
}
impl Gpu {
/// Create a device on the best available adapter (no software fallback), or `None`.
pub fn new() -> Option<Gpu> {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::PRIMARY;
let instance = wgpu::Instance::new(desc);
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
)
.ok()?;
let info = adapter.get_info();
if info.device_type == wgpu::DeviceType::Cpu {
log::info!("gpu: only a software adapter ({}), using the CPU pipeline", info.name);
return None;
}
let limits = adapter.limits();
if limits.max_storage_buffers_per_shader_stage < 10 {
log::info!("gpu: {} has too few storage buffers per stage", info.name);
return None;
}
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("lightcraft"),
required_limits: limits.clone(),
..Default::default()
}))
.ok()?;
// Errors outside an error scope (e.g. out of memory during a render): log, and stop using
// the GPU (the render in flight is redone on the CPU).
device.on_uncaptured_error(std::sync::Arc::new(|e| {
log::error!("gpu: {e}");
crate::mark_broken();
}));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let consts = constants();
let mut kernels = HashMap::new();
for m in MODULES {
let src = module_source(m, &consts);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) });
let mut entries = vec![storage_entry(0, true)];
entries.extend(m.bindings.iter().enumerate().map(|(i, (_, w, _))| storage_entry(i as u32 + 1, !w)));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &entries });
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
for e in m.entries {
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(e),
layout: Some(&pl),
module: &module,
entry_point: Some(e),
compilation_options: Default::default(),
cache: None,
});
kernels.insert(*e, Kernel { pipeline, layout: layout.clone(), nbuf: m.bindings.len() });
}
}
if let Some(e) = pollster::block_on(scope.pop()) {
log::error!("gpu: kernels failed to build on {}: {e}", info.name);
return None;
}
let dummy = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dummy"),
size: 16,
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
});
let max_buffer = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
log::info!("gpu: {} ({:?})", info.name, info.backend);
Some(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer })
}
/// A buffer of `len` 32-bit values with undefined contents (a recycled one when possible).
pub fn buffer(&self, len: usize) -> Buf {
let size = (len.max(1) * 4) as u64;
let recycled = {
let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner());
f.iter().position(|b| b.size() == size).map(|i| f.swap_remove(i))
};
let buf = recycled.unwrap_or_else(|| {
self.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
});
Buf { buf: Some(buf), len }
}
/// Submit command buffers, then make the buffers this thread dropped before reusable.
pub fn submit(&self, cmds: impl IntoIterator<Item = wgpu::CommandBuffer>) {
self.queue.submit(cmds);
let retired = RETIRED.with(|r| std::mem::take(&mut *r.0.borrow_mut()));
let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner());
f.extend(retired);
let mut total: u64 = f.iter().map(|b| b.size()).sum();
while total > POOL_BYTES && !f.is_empty() {
total -= f.remove(0).size();
}
}
/// Upload 32-bit values.
pub fn upload<T: bytemuck::Pod>(&self, data: &[T]) -> Buf {
let bytes: &[u8] = bytemuck::cast_slice(data);
let len = bytes.len() / 4;
if bytes.is_empty() {
return self.buffer(1);
}
let buf = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
});
Buf { buf: Some(buf), len }
}
/// Whether a buffer of `len` 32-bit values fits the device limits.
pub fn fits(&self, len: usize) -> bool {
(len as u64) * 4 <= self.max_buffer
}
pub fn encoder(&self) -> wgpu::CommandEncoder {
self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None })
}
/// Record kernel `name` with parameters `p` over buffers `bufs` (None = unused binding).
pub fn run(&self, enc: &mut wgpu::CommandEncoder, name: &str, p: &[u32], bufs: &[Option<&Buf>], groups: [u32; 3]) {
let k = self.kernels.get(name).unwrap_or_else(|| panic!("unknown kernel {name}"));
assert_eq!(bufs.len(), k.nbuf, "{name}: binding count");
let params = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(if p.is_empty() { &[0u32] } else { p }),
usage: wgpu::BufferUsages::STORAGE,
});
let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: params.as_entire_binding() }];
for (i, b) in bufs.iter().enumerate() {
let buf = b.map(|b| b.raw()).unwrap_or(&self.dummy);
entries.push(wgpu::BindGroupEntry { binding: i as u32 + 1, resource: buf.as_entire_binding() });
}
let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: None, layout: &k.layout, entries: &entries });
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(name), timestamp_writes: None });
pass.set_pipeline(&k.pipeline);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(groups[0], groups[1], groups[2]);
}
/// Submit `enc`, then read back the first `len` 32-bit values of `src`.
pub fn finish_and_read<T: bytemuck::Pod>(&self, mut enc: wgpu::CommandEncoder, src: &Buf, len: usize) -> Vec<T> {
assert!(len <= src.len, "readback of {len} values from a buffer of {}", src.len);
let size = (len * 4) as u64;
let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: size.max(4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
enc.copy_buffer_to_buffer(src.raw(), 0, &staging, 0, size);
self.submit([enc.finish()]);
let slice = staging.slice(..size);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = self.device.poll(wgpu::PollType::wait_indefinitely());
match rx.recv() {
Ok(Ok(())) => {}
other => panic!("gpu readback failed: {other:?}"),
}
let data = slice.get_mapped_range().expect("mapped");
let out: Vec<T> = bytemuck::cast_slice(&data).to_vec();
drop(data);
staging.unmap();
out
}
}
fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None },
count: None,
}
}
/// Workgroups covering a `w × h` grid with `wg`-sized groups.
pub fn groups2(w: usize, h: usize, wg: [usize; 2]) -> [u32; 3] {
[w.div_ceil(wg[0]).max(1) as u32, h.div_ceil(wg[1]).max(1) as u32, 1]
}
/// Workgroups for a 1-D kernel of `n` threads (256 per group) on a 2-D grid (see `lin_index`).
pub fn groups1(n: usize) -> [u32; 3] {
let g = n.div_ceil(256).max(1);
let x = g.min(32768);
[x as u32, g.div_ceil(x) as u32, 1]
}