From 511faedcfa4b0868aa5df883dfbf5653e1ed9d25 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Tue, 20 Oct 2026 17:41:40 +0200 Subject: [PATCH] Fix OpenGL startup with portable AO depth reads (#2888) (#2933) * claim: #2888 * fix: preserve AO depth in a portable integer texture (#2888) --- assets/shaders/viewport.wgsl | 18 ++++++++--- crates/gpu/src/view/ao.rs | 60 ++++++++++++++++++++++++------------ crates/gpu/tests/capture.rs | 33 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 25 deletions(-) diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index 42ce753b79..7e8c27a58c 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -29,7 +29,7 @@ fn near_z(c: vec4) -> f32 { } @group(1) @binding(2) var acc_t: texture_2d; -@group(2) @binding(0) var ao_depth: texture_depth_2d; +@group(2) @binding(0) var ao_depth: texture_2d; fn clipped(p: vec3) -> bool { return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w; @@ -141,11 +141,19 @@ fn solid(i: TOut, front: bool) -> vec4 { } // A single-sample opaque depth map, independent of the window's MSAA and its UI offsets. +// Preserve the depth's bits in R32Uint: GLSL cannot textureLoad a depth texture, +// and float colour attachments are not available on every GL adapter. +struct AoDepth { + @builtin(frag_depth) depth: f32, + @location(0) value: u32, +}; + @fragment -fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> @builtin(frag_depth) f32 { +fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth { if (clipped(i.wp) || length(i.n) < 0.5) { discard; } - if (!front && length(u.clip.xyz) > 0.5) { return cap_depth(i.wp, i.pos.z); } - return i.pos.z; + var depth = i.pos.z; + if (!front && length(u.clip.xyz) > 0.5) { depth = cap_depth(i.wp, i.pos.z); } + return AoDepth(depth, bitcast(depth)); } // Deterministic cosine-weighted hemisphere samples. Projecting the sample and a point one @@ -180,7 +188,7 @@ fn occlusion(i: TOut) -> f32 { let ndc = q.xyz / q.w; let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); if (any(uv < vec2(0.0)) || any(uv >= vec2(1.0)) || ndc.z < 0.0 || ndc.z > 1.0) { continue; } - let d0 = textureLoad(ao_depth, vec2(uv * vec2(size)), 0); + let d0 = bitcast(textureLoad(ao_depth, vec2(uv * vec2(size)), 0).x); let zs = select(ndc.z, 1.0 - ndc.z, flip); let zn = select(near.z / near.w, 1.0 - near.z / near.w, flip); let d = select(d0, 1.0 - d0, flip); diff --git a/crates/gpu/src/view/ao.rs b/crates/gpu/src/view/ao.rs index 3fce30cf31..9381a20a24 100644 --- a/crates/gpu/src/view/ao.rs +++ b/crates/gpu/src/view/ao.rs @@ -2,6 +2,9 @@ //! depth is drawn once into a single-sample depth map the lit fragment samples for occlusion. //! The map is independent of the window's MSAA, and the depth runs the same way as the //! viewport's own buffer (see [`super::depth_compare`] and [`super::far_depth`]). +//! Depth testing chooses the nearest surface; an R32Uint colour attachment stores that depth's +//! bits for portable integer texel reads. GLSL cannot load from a depth texture, and float +//! colour attachments are not available on every GL adapter. use super::{Batches, color_layout, depth_compare, far_depth, planned_layout, tri_layout}; @@ -9,29 +12,34 @@ pub(super) struct Resources { pub layout: wgpu::BindGroupLayout, pub bind: wgpu::BindGroup, depth: wgpu::TextureView, + values: wgpu::TextureView, size: [u32; 2], pipeline: wgpu::RenderPipeline, planned_pipeline: wgpu::RenderPipeline, } -fn texture(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: [u32; 2]) -> (wgpu::TextureView, wgpu::BindGroup) { - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("sc_ao_depth"), - size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let depth = texture.create_view(&Default::default()); +fn texture(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: [u32; 2]) -> (wgpu::TextureView, wgpu::TextureView, wgpu::BindGroup) { + let texture = |label, format, usage| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + }; + let depth = texture("sc_ao_depth", wgpu::TextureFormat::Depth32Float, wgpu::TextureUsages::RENDER_ATTACHMENT).create_view(&Default::default()); + let values = texture("sc_ao_values", wgpu::TextureFormat::R32Uint, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING) + .create_view(&Default::default()); let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("sc_ao_depth"), layout, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&depth) }], + entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&values) }], }); - (depth, bind) + (depth, values, bind) } impl Resources { @@ -42,7 +50,7 @@ impl Resources { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, + sample_type: wgpu::TextureSampleType::Uint, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, @@ -78,7 +86,11 @@ impl Resources { fragment: Some(wgpu::FragmentState { module, entry_point: Some("fs_ao_depth"), - targets: &[], + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::R32Uint, + blend: None, + write_mask: wgpu::ColorWrites::RED, + })], compilation_options: Default::default(), }), multiview_mask: None, @@ -87,8 +99,8 @@ impl Resources { }; let planned_pipeline = pipeline(true); let pipeline = pipeline(false); - let (depth, bind) = texture(device, &layout, [1, 1]); - Self { layout, bind, depth, size: [1, 1], pipeline, planned_pipeline } + let (depth, values, bind) = texture(device, &layout, [1, 1]); + Self { layout, bind, depth, values, size: [1, 1], pipeline, planned_pipeline } } pub fn prepare( @@ -105,7 +117,7 @@ impl Resources { // Bound memory at large window sizes; normalized coordinates also handle downsampling. let size = target_size(enabled, size_px, device.limits().max_texture_dimension_2d); if size != self.size { - (self.depth, self.bind) = texture(device, &self.layout, size); + (self.depth, self.values, self.bind) = texture(device, &self.layout, size); self.size = size; } if !enabled { @@ -113,7 +125,15 @@ impl Resources { } let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("sc_ao_prepass"), - color_attachments: &[], + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.values, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r: f64::from(far_depth(reversed).to_bits()), g: 0.0, b: 0.0, a: 0.0 }), + store: wgpu::StoreOp::Store, + }, + })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(far_depth(reversed)), store: wgpu::StoreOp::Store }), diff --git a/crates/gpu/tests/capture.rs b/crates/gpu/tests/capture.rs index e71d3a7ff7..e368d11941 100644 --- a/crates/gpu/tests/capture.rs +++ b/crates/gpu/tests/capture.rs @@ -39,6 +39,39 @@ fn session(script: &[(&str, serde_json::Value)]) -> Session { s } +/// #2888: compiling the viewport on GL used to reject AO's depth textureLoad, even +/// with AO disabled. Select GL explicitly so a working Vulkan adapter cannot hide it. +#[test] +fn opengl_viewport_pipelines_render_a_model() { + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = wgpu::Backends::GL; + let instance = wgpu::Instance::new(desc); + let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) { + Ok(adapter) => adapter, + Err(e) => { + let required = std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_some() && wgpu::Backends::from_env() == Some(wgpu::Backends::GL); + assert!(!required, "OpenGL is required here: {e}"); + eprintln!("skipping the OpenGL regression: {e}"); + return; + } + }; + assert_eq!(adapter.get_info().backend, wgpu::Backend::Gl); + eprintln!("OpenGL regression: {:?}", adapter.get_info()); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_features: solidcraft_gpu::depth_features(&adapter), + required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), + ..Default::default() + })) + .unwrap(); + let c = Capturer::new(&adapter, device, queue).unwrap(); + let s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]); + let cam = view::home_camera(&s); + let img = c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid: false, axes: false }), &cam, 160, 100).unwrap(); + let empty = c.rgba(&Default::default(), &cam, 160, 100).unwrap(); + let changed = pixels(&img).zip(pixels(&empty)).filter(|(a, b)| a != b).count(); + assert!(changed > 500, "the model covers only {changed} pixels"); +} + /// Appearances show as the viewport shows them: a body's colour, a face's own look. #[test] fn pictures_show_appearances() {