Merge upstream/main into pr/overlay-windows (#1027 landed)

GPU handles(): only output-device proofs stay on the CPU; Show All Masks now renders zoom windows (upstream); proof-boundary tolerance in the GPU equivalence test; device-profile draft test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-20 10:57:07 +11:00
co-authored by Claude Sonnet 5.5
521 changed files with 85675 additions and 4257 deletions
+3
View File
@@ -14,6 +14,7 @@ lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
lightcraft-develop = { workspace = true }
lightcraft-faces = { workspace = true }
lightcraft-pipeline = { workspace = true }
log = { workspace = true }
@@ -29,5 +30,7 @@ default = []
denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"]
[dev-dependencies]
# tests record why they skip (`cargo xtask ci` lists the skips)
lightcraft-testkit = { workspace = true }
lightcraft-scenes = { workspace = true }
lightcraft-raw = { workspace = true }
+30 -16
View File
@@ -213,12 +213,12 @@ fn disarm(marker: &std::path::Path, active: &Mutex<u32>) {
}
/// Removes the marker on the way out, a panic included (the process survived).
struct Armed(Option<PathBuf>);
struct Armed<'a>(Option<PathBuf>, &'a Mutex<u32>);
impl Drop for Armed {
impl Drop for Armed<'_> {
fn drop(&mut self) {
if let Some(m) = &self.0 {
disarm(m, &ACTIVE);
disarm(m, self.1);
}
}
}
@@ -227,10 +227,16 @@ impl Drop for Armed {
/// removing the init marker (if one is set).
pub(crate) fn with_marker<T>(what: &str, f: impl FnOnce() -> T) -> T {
let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone();
with_marker_on(marker, &ACTIVE, what, f)
}
/// [`with_marker`] on an explicit marker path and scope counter (the tests use their own, so that
/// device-creating tests running in parallel cannot join their scopes).
fn with_marker_on<T>(marker: Option<PathBuf>, active: &Mutex<u32>, what: &str, f: impl FnOnce() -> T) -> T {
if let Some(m) = &marker {
arm(m, &ACTIVE, what);
arm(m, active, what);
}
let _armed = Armed(marker);
let _armed = Armed(marker, active);
f()
}
@@ -352,9 +358,6 @@ mod tests {
}
use super::*;
/// The tests that set the process-wide marker take turns.
static GLOBAL: Mutex<()> = Mutex::new(());
#[test]
fn parses_backend_names() {
assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12)));
@@ -421,13 +424,12 @@ mod tests {
#[test]
fn init_marker_is_written_during_creation_and_removed_after() {
let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id()));
let m = dir.join("gpu-init.marker");
let _ = std::fs::remove_dir_all(&dir);
set_init_marker(Some(m.clone()));
let seen = with_init_marker(Backends::DX12, || m.exists());
set_init_marker(None);
// a local scope counter: other tests create real devices through the process-wide one
let active = Mutex::new(0);
let seen = with_marker_on(Some(m.clone()), &active, "device creation", || m.exists());
assert!(seen, "the marker exists while the device is created");
assert!(!m.exists(), "and is removed afterwards");
// a marker left behind by a crashed process is reported once; reading it leaves it in place
@@ -440,6 +442,20 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn with_init_marker_writes_the_process_wide_marker() {
let dir = std::env::temp_dir().join(format!("lc-gpu-global-{}", std::process::id()));
let m = dir.join("gpu-init.marker");
let _ = std::fs::remove_dir_all(&dir);
set_init_marker(Some(m.clone()));
let seen = with_init_marker(Backends::DX12, || m.exists());
set_init_marker(None);
// not checked for removal: device-creating tests running in parallel may still hold the file
// (the test above checks removal on its own scope counter)
assert!(seen, "the marker set with set_init_marker exists while the device is created");
let _ = std::fs::remove_dir_all(&dir);
}
/// The window's renderer fallback: forced, remembered, the platform chain, the failed one last.
#[test]
fn window_renderer_fallback_order() {
@@ -505,13 +521,11 @@ mod tests {
#[test]
fn a_panic_inside_the_scope_still_removes_the_marker() {
let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("lc-gpu-panic-{}", std::process::id()));
let m = dir.join("gpu-init.marker");
let _ = std::fs::remove_dir_all(&dir);
set_init_marker(Some(m.clone()));
let r = std::panic::catch_unwind(|| with_marker("kernel compilation", || panic!("compile")));
set_init_marker(None);
let active = Mutex::new(0);
let r = std::panic::catch_unwind(|| with_marker_on(Some(m.clone()), &active, "kernel compilation", || panic!("compile")));
assert!(r.is_err());
assert!(!m.exists(), "the process survived, so nothing is left to report");
let _ = std::fs::remove_dir_all(&dir);
File diff suppressed because it is too large Load Diff
+31 -9
View File
@@ -35,6 +35,8 @@ pub mod backend;
mod ctx;
#[cfg(not(target_arch = "wasm32"))]
mod exit;
#[cfg(not(target_arch = "wasm32"))]
pub mod faces;
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
pub mod nn;
#[cfg(not(target_arch = "wasm32"))]
@@ -239,9 +241,9 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool {
}
}
/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the
/// Why GPU compute that has a device of its own (denoise, faces) is switched off by the user's settings, or `None`: the
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))]
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn switched_off() -> Option<String> {
if env_disabled() {
return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());
@@ -437,6 +439,15 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R
try_render(src, info, s, req, stages).ok()
}
/// Whether the GPU renders requests of this kind at all (a given render can still fall back to
/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports render on the CPU, and so do
/// soft proofs against an output-device profile (`device_proof`: its 3D table is CPU-side) and
/// Visualize HDR, which needs the float HDR render. [`try_render`] follows it, and the loupe sizes
/// its slider-drag drafts by it (issue #330), so the two can't drift apart.
pub fn handles(depth: lightcraft_pipeline::OutputDepth, device_proof: bool, overlay: lightcraft_pipeline::Overlay) -> bool {
depth == lightcraft_pipeline::OutputDepth::U8 && !device_proof && overlay != lightcraft_pipeline::Overlay::HdrRange
}
/// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU
/// was not used (callers can ask [`unavailable_reason`]), [`Fallback::Failed`] with the reason
/// when the GPU render failed and the CPU must render it. The reason belongs to this call, unlike
@@ -453,13 +464,8 @@ pub fn try_render(
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let s: &DevelopSettings = &effective;
// the kernel writes 8-bit output: high-bit-depth exports render on the CPU, and so do soft
// proofs against an output-device profile (its 3D table); Visualize HDR needs the float HDR render: CPU too
if !enabled()
|| req.depth != lightcraft_pipeline::OutputDepth::U8
|| req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some())
|| req.overlay == lightcraft_pipeline::Overlay::HdrRange
{
let device_proof = req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some());
if !enabled() || !handles(req.depth, device_proof, req.overlay) {
return Err(Fallback::NotTried);
}
let s = &*lightcraft_pipeline::settings_for(s, req);
@@ -533,3 +539,19 @@ pub fn try_render(
Err(Fallback::NotTried)
}
}
#[cfg(test)]
mod tests {
use lightcraft_pipeline::{OutputDepth, Overlay};
// Given a request the 8-bit kernel can't draw (high bit depth, a soft proof, Visualize HDR),
// the GPU doesn't take it; the loupe and diagnostic overlays it can draw stay on the GPU
#[test]
fn the_gpu_handles_8_bit_unproofed_renders_without_the_hdr_range() {
assert!(super::handles(OutputDepth::U8, false, Overlay::None));
assert!(super::handles(OutputDepth::U8, false, Overlay::Spots(50)));
assert!(!super::handles(OutputDepth::U16, false, Overlay::None));
assert!(!super::handles(OutputDepth::U8, true, Overlay::None));
assert!(!super::handles(OutputDepth::U8, false, Overlay::HdrRange));
}
}
+1 -1
View File
@@ -907,7 +907,7 @@ mod tests {
match runner(net, tile) {
Ok(r) => Some(r),
Err(e) => {
eprintln!("skipped: no GPU runner here ({e})");
lightcraft_testkit::skip!(Gpu, "no GPU runner here ({e})");
None
}
}
+3 -2
View File
@@ -824,7 +824,7 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho
fn denoise(cx: &mut Cx<'_>, img: Buf, plan: &Plan<'_>) -> Buf {
let (w, h) = (plan.w, plan.h);
let n = w * h;
let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h));
let (lum, col) = local::nr_params(&plan.settings, plan.detail_scale);
let mut img = img;
if let Some(nr) = lum {
let l = cx.gpu.buffer(n);
@@ -1015,7 +1015,8 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
}
MaskShape::Brush { strokes } => {
let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect();
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS {
// pen pressure varies radius and flow per dab, which the kernel doesn't take: the CPU rasterizes it
if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS || dabs.iter().any(|d| !d.pressure.is_empty()) {
None
} else if dabs.iter().any(|d| d.auto) {
// Auto Mask: stroke by stroke (each one refined before it's combined)
+171
View File
@@ -0,0 +1,171 @@
// Convolution for the face networks (lightcraft-faces), planar (NCHW) float32, batch 1, any stride, padding and grouping.
//
// `tiled` is an implicit matrix product per group: rows are the group's output channels (M), columns its output
// pixels (N), and K runs over input channel x kernel tap. The input is gathered (im2col) straight into workgroup
// memory, so the lowered matrix is never made. 256 threads make a 64 x 64 block, 4 x 4 values each.
// `direct` gives one output value per thread, for depthwise convolutions (one output channel per group), where a
// 64-row tile would be almost all waste.
//
// out[co][p] = bias[co] + sum_k W[co][k] * X[k][p] X[k][p] = input[g*cg + k/taps][oy*sh + ky - pt][ox*sw + kx - pl]
struct P {
m: u32, // output channels per group
n: u32, // output pixels (oh * ow)
k: u32, // cg * kh * kw
cg: u32, // input channels per group
h: u32,
w: u32,
ow: u32,
kh: u32,
kw: u32,
sh: u32,
sw: u32,
pt: u32,
pl: u32,
bias: u32, // 1 when there is a bias
total: u32, // cout * n
row: u32, // workgroups along x, for `direct` and `reduce` (so a large layer can use y too)
splits: u32, // K is cut into this many slices (z = group * splits + slice), summed by `reduce`
kchunk: u32, // K values per slice, a multiple of BK
_a: u32,
_b: u32,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> src: array<f32>;
@group(0) @binding(2) var<storage, read> wgt: array<f32>;
@group(0) @binding(3) var<storage, read> bias: array<f32>;
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
const BM: u32 = 64u;
const BN: u32 = 64u;
const BK: u32 = 16u;
var<workgroup> As: array<f32, 1024>; // [BK][BM]
var<workgroup> Bs: array<f32, 1024>; // [BK][BN]
fn gather(g: u32, k: u32, pix: u32) -> f32 {
let taps = p.kh * p.kw;
let ci = g * p.cg + k / taps;
let tap = k % taps;
let oy = pix / p.ow;
let ox = pix % p.ow;
let iy = i32(oy * p.sh + tap / p.kw) - i32(p.pt);
let ix = i32(ox * p.sw + tap % p.kw) - i32(p.pl);
if (iy < 0 || ix < 0 || iy >= i32(p.h) || ix >= i32(p.w)) {
return 0.0;
}
return src[(ci * p.h + u32(iy)) * p.w + u32(ix)];
}
@compute @workgroup_size(256)
fn tiled(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let n0 = wg.x * BN;
let m0 = wg.y * BM;
let g = wg.z / p.splits;
let slice = wg.z % p.splits;
let k_lo = slice * p.kchunk;
let k_hi = min(p.k, k_lo + p.kchunk);
let tx = tid % 16u;
let ty = tid / 16u;
var acc: array<array<f32, 4>, 4>;
for (var i = 0u; i < 4u; i++) {
for (var j = 0u; j < 4u; j++) {
acc[i][j] = 0.0;
}
}
for (var k0 = k_lo; k0 < k_hi; k0 += BK) {
// 1024 values of each tile, 4 per thread; every slot has exactly one writer
for (var e = tid; e < 1024u; e += 256u) {
// weights: neighbouring threads read neighbouring k of one row
let mm = e / BK;
let ka = k0 + e % BK;
let m = m0 + mm;
var a = 0.0;
if (ka < k_hi && m < p.m) {
a = wgt[(g * p.m + m) * p.k + ka];
}
As[(e % BK) * BM + mm] = a;
// input: neighbouring threads read neighbouring pixels
let kk = e / BN;
let k = k0 + kk;
let pix = n0 + e % BN;
var b = 0.0;
if (k < k_hi && pix < p.n) {
b = gather(g, k, pix);
}
Bs[e] = b;
}
workgroupBarrier();
for (var kk = 0u; kk < BK; kk++) {
var a: array<f32, 4>;
var b: array<f32, 4>;
for (var i = 0u; i < 4u; i++) {
a[i] = As[kk * BM + ty * 4u + i];
b[i] = Bs[kk * BN + tx + 16u * i];
}
for (var i = 0u; i < 4u; i++) {
for (var j = 0u; j < 4u; j++) {
acc[i][j] += a[i] * b[j];
}
}
}
workgroupBarrier();
}
for (var i = 0u; i < 4u; i++) {
let m = m0 + ty * 4u + i;
if (m >= p.m) {
continue;
}
let co = g * p.m + m;
var b0 = 0.0;
if (p.bias != 0u && p.splits == 1u) {
b0 = bias[co];
}
// with slices, each writes a partial sum of its own: [slice][cout][n]
let base = slice * p.total + co * p.n;
for (var j = 0u; j < 4u; j++) {
// columns tx, tx + 16, tx + 32, tx + 48: neighbouring threads write neighbouring pixels
let pix = n0 + tx + 16u * j;
if (pix < p.n) {
dst[base + pix] = acc[i][j] + b0;
}
}
}
}
@compute @workgroup_size(256)
fn direct(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let at = (wg.y * p.row + wg.x) * 256u + tid;
if (at >= p.total) {
return;
}
let co = at / p.n;
let pix = at % p.n;
let g = co / p.m;
var v = 0.0;
if (p.bias != 0u) {
v = bias[co];
}
for (var k = 0u; k < p.k; k++) {
v += wgt[co * p.k + k] * gather(g, k, pix);
}
dst[at] = v;
}
// The sum of the K slices' partial results, plus the bias. `src` holds [splits][cout][n].
@compute @workgroup_size(256)
fn reduce(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) {
let at = (wg.y * p.row + wg.x) * 256u + tid;
if (at >= p.total) {
return;
}
var v = 0.0;
if (p.bias != 0u) {
v = bias[at / p.n];
}
for (var s = 0u; s < p.splits; s++) {
v += src[s * p.total + at];
}
dst[at] = v;
}
+188
View File
@@ -0,0 +1,188 @@
// The face networks' other operators (lightcraft-faces `net::program`), so activations stay on the device between layers.
// Same bindings as faces_conv.wgsl: the parameters, three read-only operands and the output. Element kernels run one
// thread per output value; `gavg` and `gemv` one workgroup per output value.
//
// Parameters are 24 numbers: q(0) = values (or workgroups) to make, q(1) = workgroups along x, then per kernel.
struct P {
v: array<vec4<u32>, 6>,
}
@group(0) @binding(0) var<uniform> p: P;
@group(0) @binding(1) var<storage, read> a: array<f32>;
@group(0) @binding(2) var<storage, read> b: array<f32>;
@group(0) @binding(3) var<storage, read> c: array<f32>;
@group(0) @binding(4) var<storage, read_write> dst: array<f32>;
fn q(i: u32) -> u32 {
return p.v[i / 4u][i % 4u];
}
fn index(wid: vec3<u32>, lid: u32) -> u32 {
return (wid.y * q(1) + wid.x) * 256u + lid;
}
// q(2): 0 relu, 1 sigmoid
@compute @workgroup_size(256)
fn unary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let x = a[i];
if (q(2) == 0u) {
dst[i] = max(x, 0.0);
} else {
dst[i] = 1.0 / (1.0 + exp(-x));
}
}
// q(2) plane, q(3) channels: x * b[c] + c[c]
@compute @workgroup_size(256)
fn affine(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let ch = (i / q(2)) % q(3);
dst[i] = a[i] * b[ch] + c[ch];
}
// q(2) plane, q(3) channels: x < 0 ? x * b[c] : x
@compute @workgroup_size(256)
fn prelu(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let x = a[i];
let ch = (i / q(2)) % q(3);
dst[i] = select(x, x * b[ch], x < 0.0);
}
// q(2): 0 add, 1 sub, 2 mul; q(3..7) output dims; q(7..11) a's strides; q(11..15) b's strides
@compute @workgroup_size(256)
fn binary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
var rest = i;
var ia = 0u;
var ib = 0u;
for (var d = 3; d >= 0; d = d - 1) {
let du = u32(d);
let size = q(3u + du);
let at = rest % size;
rest = rest / size;
ia = ia + at * q(7u + du);
ib = ib + at * q(11u + du);
}
let x = a[ia];
let y = b[ib];
if (q(2) == 0u) {
dst[i] = x + y;
} else if (q(2) == 1u) {
dst[i] = x - y;
} else {
dst[i] = x * y;
}
}
// q(3..7) output dims; q(7..11) the input's strides for each output axis
@compute @workgroup_size(256)
fn transpose(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
var rest = i;
var at_in = 0u;
for (var d = 3; d >= 0; d = d - 1) {
let du = u32(d);
let size = q(3u + du);
at_in = at_in + (rest % size) * q(7u + du);
rest = rest / size;
}
dst[i] = a[at_in];
}
// q(2) h, q(3) w, q(4) kh, q(5) kw, q(6) sh, q(7) sw, q(8) oh, q(9) ow
@compute @workgroup_size(256)
fn maxpool(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let h = q(2);
let w = q(3);
let oh = q(8);
let ow = q(9);
let ox = i % ow;
let oy = (i / ow) % oh;
let ch = i / (ow * oh);
var m = -3.402823e38;
for (var ky = 0u; ky < q(4); ky = ky + 1u) {
for (var kx = 0u; kx < q(5); kx = kx + 1u) {
let y = oy * q(6) + ky;
let x = ox * q(7) + kx;
if (y < h && x < w) {
m = max(m, a[(ch * h + y) * w + x]);
}
}
}
dst[i] = m;
}
// q(2) h, q(3) w, q(4) sh, q(5) sw: nearest upscale by whole numbers
@compute @workgroup_size(256)
fn resize(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let i = index(wid, lid);
if (i >= q(0)) { return; }
let h = q(2);
let w = q(3);
let ow = w * q(5);
let oh = h * q(4);
let ox = i % ow;
let oy = (i / ow) % oh;
let ch = i / (ow * oh);
dst[i] = a[(ch * h + oy / q(4)) * w + ox / q(5)];
}
var<workgroup> partial: array<f32, 256>;
fn reduce(lid: u32) {
for (var s = 128u; s > 0u; s = s >> 1u) {
workgroupBarrier();
if (lid < s) {
partial[lid] = partial[lid] + partial[lid + s];
}
}
workgroupBarrier();
}
// one workgroup per channel; q(2) plane
@compute @workgroup_size(256)
fn gavg(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let ch = wid.y * q(1) + wid.x;
let plane = q(2);
var sum = 0.0;
if (ch < q(0)) {
for (var j = lid; j < plane; j = j + 256u) {
sum = sum + a[ch * plane + j];
}
}
partial[lid] = sum;
reduce(lid);
if (lid == 0u && ch < q(0)) {
dst[ch] = partial[0] / f32(plane);
}
}
// one workgroup per output; q(2) k, q(3) n, q(4) 1 when B is [n][k], q(5) alpha's bits
@compute @workgroup_size(256)
fn gemv(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) {
let o = wid.y * q(1) + wid.x;
let k = q(2);
let n = q(3);
var sum = 0.0;
if (o < q(0)) {
for (var j = lid; j < k; j = j + 256u) {
let wi = select(j * n + o, o * k + j, q(4) == 1u);
sum = sum + a[j] * b[wi];
}
}
partial[lid] = sum;
reduce(lid);
if (lid == 0u && o < q(0)) {
dst[o] = bitcast<f32>(q(5)) * partial[0] + c[o];
}
}
+35 -2
View File
@@ -19,7 +19,7 @@ const MAX_LSB: u8 = 3;
fn gpu() -> bool {
let ok = lightcraft_gpu::available();
if !ok {
eprintln!("skipped: no GPU adapter");
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
}
ok
}
@@ -79,6 +79,19 @@ fn check(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings,
(mean, max)
}
/// [`check`] for gamut-warning renders: a warning is a hard decision at the gamut boundary, so a
/// pixel sitting exactly on it may be painted by one renderer and not the other (float rounding).
/// Such flips are allowed for at most 0.01 % of the pixels; everything else is held to [`check`]'s bounds.
fn check_warned(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) {
let cpu = render(src, info, s, req).image;
let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render");
let (mean, _, over) = diff(&cpu, &gpu.image);
let flips = cpu.data.iter().zip(&gpu.image.data).filter(|(p, q)| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB)).count();
let share = flips as f64 / cpu.data.len() as f64;
eprintln!("{name:<28} {}x{} mean {mean:.4} >1: {:.4}% boundary flips {flips}", cpu.width, cpu.height, over * 100.0);
assert!(mean < MEAN_LSB && share <= 1e-4, "{name}: mean {mean:.4} LSB, {flips} pixels differ by more than {MAX_LSB} LSB");
}
type Edit = fn(&mut DevelopSettings);
fn typical(s: &mut DevelopSettings) {
@@ -93,6 +106,26 @@ fn typical(s: &mut DevelopSettings) {
s.detail.sharpen_amount = 40.0;
}
/// Issue #960: a preview's noise reduction works in the original's pixels (a smaller radius, a
/// smaller `eps`, faded below one pixel). The GPU takes the same parameters (`local::nr_params`).
#[test]
fn preview_noise_reduction_matches() {
if !gpu() {
return;
}
let src = scene(3, 640, 480);
let mut s = DevelopSettings::default();
s.detail.nr_luminance = 70.0;
s.detail.nr_detail = 30.0;
s.detail.nr_color = 60.0;
s.detail.nr_color_smoothness = 50.0;
// a preview level of a 6000-px original: loupe-, grid- and tiny-thumbnail-sized outputs
let info = SourceInfo { raw: true, native_long: Some(6000), ..Default::default() };
for (name, edge) in [("NR preview 640 of 6000", 640), ("NR preview 320 of 6000", 320), ("NR preview 128 of 6000", 128)] {
check(name, &src, &info, &s, &RenderRequest::fit(edge, edge));
}
}
/// A render big enough for the per-pixel stage to run in several bands of rows (and the render
/// in many submissions), with grain (position-dependent) and masks.
#[test]
@@ -876,7 +909,7 @@ fn soft_proofs_match() {
saturated(&mut s);
let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 });
let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) };
check(&format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}"), &src, &raw, &s, &req);
check_warned(&format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}"), &src, &raw, &s, &req);
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ fn masked(s: &mut DevelopSettings, k: usize) {
#[test]
fn failed_gpu_renders_fall_back_with_a_reason() {
if !lightcraft_gpu::available() {
eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason());
lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default());
return;
}
assert_eq!(lightcraft_gpu::unavailable_reason(), None);
+87
View File
@@ -0,0 +1,87 @@
//! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of
//! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs;
//! xtask runs it after the workspace tests, so it costs no extra build:
//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`.
//!
//! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`,
//! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build
//! contains.
//! - `LIGHTCRAFT_GPU_PROBE=<backend>` (`vulkan`, `metal`, `dx12`, `gl`): every adapter that
//! backend lists, software ones (lavapipe, llvmpipe, WARP) included. One backend per process, so
//! a driver that crashes while it is enumerated (issue #136) costs only its own line.
//!
//! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy.
#![cfg(not(target_arch = "wasm32"))]
use lightcraft_gpu::backend::{self, Backends};
const NAMES: [(&str, Backends); 4] = [("vulkan", Backends::VULKAN), ("metal", Backends::METAL), ("dx12", Backends::DX12), ("gl", Backends::GL)];
#[test]
#[ignore = "the capability probe of `cargo xtask ci`"]
fn gpu_probe() {
let line = match std::env::var("LIGHTCRAFT_GPU_PROBE").ok().filter(|b| !b.is_empty()) {
None => pipeline(),
Some(name) => adapters(&name),
};
// its own line, prefixed: libtest may have printed `test gpu_probe ... ` on this one
println!("\ngpu_probe: {line}");
}
fn names(set: Backends) -> String {
let list: Vec<String> = NAMES.iter().filter(|(_, b)| set.contains(*b)).map(|(n, _)| json_str(n)).collect();
format!("[{}]", list.join(","))
}
fn pipeline() -> String {
let available = lightcraft_gpu::available();
let opt = |s: Option<String>| s.map_or_else(|| "null".to_owned(), |s| json_str(&s));
let compiled = wgpu::Instance::enabled_backend_features();
format!(
"{{\"pipeline\":{{\"available\":{available},\"adapter\":{},\"reason\":{},\"backends\":{},\"compiled\":{}}}}}",
opt(lightcraft_gpu::adapter_name()),
opt(lightcraft_gpu::unavailable_reason()),
names(backend::compute_backends().unwrap_or(Backends::empty()) & compiled),
names(compiled),
)
}
fn adapters(name: &str) -> String {
let (_, set) = NAMES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("unknown backend `{name}` (vulkan, metal, dx12, gl)"));
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = *set;
desc.backend_options = backend::backend_options();
desc.flags = backend::instance_flags_from_env(desc.flags);
let instance = wgpu::Instance::new(desc);
let adapters: Vec<String> = pollster::block_on(instance.enumerate_adapters(*set))
.iter()
.map(|a| {
let i = a.get_info();
format!(
"{{\"name\":{},\"type\":{},\"vendor\":\"0x{:04x}\",\"device\":\"0x{:04x}\",\"driver\":{},\"driver_info\":{}}}",
json_str(&i.name),
json_str(&format!("{:?}", i.device_type)),
i.vendor,
i.device,
json_str(&i.driver),
json_str(&i.driver_info),
)
})
.collect();
format!("{{\"backend\":{},\"adapters\":[{}]}}", json_str(name), adapters.join(","))
}
/// A JSON string literal.
fn json_str(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))),
c => out.push(c),
}
}
out.push('"');
out
}
+1 -1
View File
@@ -9,7 +9,7 @@ use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache};
#[test]
fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() {
if !lightcraft_gpu::available() {
eprintln!("skipped: no GPU adapter");
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
return;
}
let limit = 64u64 << 20;
+2 -1
View File
@@ -21,7 +21,8 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() {
let req = RenderRequest::fit(w, h);
let has_gpu = lightcraft_gpu::available();
if !has_gpu {
eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason());
let why = lightcraft_gpu::unavailable_reason().unwrap_or_default();
lightcraft_testkit::skip!(Gpu, "no GPU adapter ({why}), so only the CPU side is checked");
}
assert!(!lightcraft_gpu::shutting_down());
assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet");