GPU: adapters without float render targets keep the GPU path for effect-free documents

Once Format stopped failing first, the Linux runner reached the GPU tests
for the first time: its software adapter (GL) can't render Rgba32Float or
R32Float. The app already picks Rgba16Float there, but the effect-map
pipelines (R32Float/R16Float) still panicked. They are now built inside an
error scope; where they can't be, documents with layer effects use the CPU
compositor and everything else stays on the GPU.

The exact-parity tests skip on adapters without 32-bit float targets (the
app uses the CPU compositor there), like they already skip without an
adapter; the fallback-path tests build with try_new_with_format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 11:55:25 -04:00
parent 71272d5cd7
commit 66d5a5381e
3 changed files with 91 additions and 45 deletions

View File

@ -344,6 +344,9 @@ pub struct Compositor {
max_dim: u32,
frame: u64,
acc_format: wgpu::TextureFormat,
/// Whether the effect-map pipelines could be built (else documents with layer effects are
/// [`Unsupported`] and use the CPU compositor).
effect_maps: bool,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
@ -380,6 +383,24 @@ struct Bound {
patterns: Vec<Option<wgpu::TextureView>>,
}
/// Runs `f` inside validation and internal-error scopes and returns the first error raised.
/// Native wgpu reports errors synchronously; where they only arrive asynchronously (WebGPU) none
/// is reported.
fn first_error<T>(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option<wgpu::Error>) {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let out = f();
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let mut error = None;
// Scopes pop in reverse order.
for pop in [internal.pop(), validation.pop()] {
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
error.get_or_insert(e);
}
}
(out, error)
}
impl Compositor {
/// Create a compositor with the default accumulation format ([`ACC_FORMAT`]).
pub fn new(device: &wgpu::Device) -> Self {
@ -399,18 +420,10 @@ impl Compositor {
/// to the CPU compositor. Where errors only arrive asynchronously (WebGPU), creation is assumed
/// to have worked.
pub fn try_new_with_format(device: &wgpu::Device, acc_format: wgpu::TextureFormat) -> Result<Self, Unsupported> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let comp = Self::new_with_format(device, acc_format);
// Pop in reverse order; native wgpu reports errors synchronously, so the futures are ready.
let errors = [internal.pop(), validation.pop()];
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
for f in errors {
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(f).poll(&mut cx) {
return Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}")));
}
match first_error(device, || Self::new_with_format(device, acc_format)) {
(comp, None) => Ok(comp),
(_, Some(e)) => Err(Unsupported(format!("couldn't build the compositor's pipelines: {e}"))),
}
Ok(comp)
}
/// Create a compositor whose accumulation/render-target format is `acc_format`. Pass
@ -431,29 +444,34 @@ impl Compositor {
bind_group_layouts: &[Some(&bgl0), Some(&bgl1)],
immediate_size: 0,
});
let mut pipelines = HashMap::new();
for k in Kernel::DRAWN {
let pipeline = |k: Kernel, format: wgpu::TextureFormat| {
let entry = k.entry().expect("drawn kernels have an entry point");
let formats: &[wgpu::TextureFormat] = if k.is_map() { &[MAP32, MAP16] } else { &[acc_format] };
for &format in formats {
let p = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(entry),
layout: Some(&layout),
vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() },
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(entry),
targets: &[Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
pipelines.insert((k, format), p);
}
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(entry),
layout: Some(&layout),
vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() },
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(entry),
targets: &[Some(wgpu::ColorTargetState { format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
let mut pipelines: HashMap<_, _> = Kernel::DRAWN.iter().filter(|k| !k.is_map()).map(|&k| ((k, acc_format), pipeline(k, acc_format))).collect();
// Effect maps render to R32Float / R16Float, which some adapters (e.g. GL without float
// render targets) can't: then layer effects use the CPU compositor and the rest stays here.
let maps = Kernel::DRAWN.iter().filter(|k| k.is_map()).flat_map(|&k| [(k, MAP32), (k, MAP16)]);
let (maps, map_error) = first_error(device, || maps.map(|key| (key, pipeline(key.0, key.1))).collect::<Vec<_>>());
if let Some(e) = &map_error {
log::info!("GPU compositor: no effect-map pipelines ({e}); layer effects use the CPU");
} else {
pipelines.extend(maps);
}
let dummy = Tex::new(device, "pc_compose_dummy", 1, 1, wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureUsages::TEXTURE_BINDING).view;
Self {
@ -466,6 +484,7 @@ impl Compositor {
max_dim: device.limits().max_texture_dimension_2d,
frame: 0,
acc_format,
effect_maps: map_error.is_none(),
}
}
@ -481,6 +500,9 @@ impl Compositor {
/// Effect regions and patterns must fit in textures.
fn check_fx(&self, doc: &Document, p: &Plan<'_>) -> Result<(), Unsupported> {
if !self.effect_maps && !p.fx.is_empty() {
return Err(Unsupported("layer effects need float effect-map render targets, which this GPU lacks".into()));
}
for f in &p.fx {
if f.region.width() > self.max_dim || f.region.height() > self.max_dim {
return Err(Unsupported(format!("effect region of `{}` larger than the GPU texture limit ({})", f.layer.name, self.max_dim)));

View File

@ -25,10 +25,29 @@ fn gpu() -> Option<Gpu> {
}
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
let comp = Compositor::new(&device);
// Exact parity needs 32-bit float targets. Adapters without them (e.g. GL software
// rasterizers) use the CPU compositor in the app, so there is nothing to compare there.
if Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
eprintln!("skipping GPU parity tests: adapter can't render Rgba32Float");
return None;
}
let comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float) {
Ok(c) => c,
Err(e) => {
eprintln!("skipping GPU parity tests: {e}");
return None;
}
};
Some(Gpu { device, queue, comp })
}
/// Any adapter and device, for the fallback-path tests (which don't need 32-bit float targets).
fn any_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let adapter = pollster::block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
Some((adapter, device, queue))
}
/// Deterministic pseudo-random values in 0..1.
fn rnd(seed: u32, i: u32) -> f32 {
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
@ -1092,14 +1111,11 @@ fn rgba16f_fallback_path_renders() {
// Rgba16Float. Force that path here (even on a 32f-capable GPU) to prove it works end to end —
// pipeline creation, the accumulation texture, and the half-float readback — within display
// tolerance of the CPU reference. This is the path Intel-Vulkan / limited GPUs take.
let instance = wgpu::Instance::default();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
return;
let Some((adapter, device, queue)) = any_device() else { return };
let mut comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba16Float) {
Ok(c) => c,
Err(e) => return eprintln!("skipping: {e}"),
};
let Some((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok() else {
return;
};
let mut comp = Compositor::new_with_format(&device, wgpu::TextureFormat::Rgba16Float);
let mut d = base_doc(48, 32);
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 48, 32), 7, 1.0);
top.blend = BlendMode::Multiply;
@ -1125,8 +1141,12 @@ fn rgba16f_fallback_path_renders() {
fn unbuildable_pipelines_are_an_error_not_a_panic() {
// The app falls back to the CPU compositor on Err; a panic here would crash it (as FXC once
// did on D3D12). A depth format can't be a colour target, so its pipelines fail to build.
let Some(g) = gpu() else { return };
let e = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Depth32Float).err().expect("depth target must fail");
let Some((adapter, device, _)) = any_device() else { return };
let e = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Depth32Float).err().expect("depth target must fail");
assert!(e.0.contains("pipelines"), "{e}");
assert!(Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba16Float).is_ok());
// The format the app picks builds (effect maps the adapter can't render are left out, not fatal).
let f = Compositor::preferred_acc_format(&adapter);
if adapter.get_texture_format_features(f).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) {
assert!(Compositor::try_new_with_format(&device, f).is_ok());
}
}

View File

@ -9,7 +9,11 @@ fn gpu() -> Option<(wgpu::Device, wgpu::Queue, photocraft_gpu::Compositor)> {
let instance = wgpu::Instance::default();
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
let comp = photocraft_gpu::Compositor::new(&device);
// Exact comparison needs 32-bit float targets (see parity.rs); skip on adapters without them.
if photocraft_gpu::Compositor::preferred_acc_format(&adapter) != wgpu::TextureFormat::Rgba32Float {
return None;
}
let comp = photocraft_gpu::Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
Some((device, queue, comp))
}