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
+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");