perf: allocate a shadow map only for viewports that cast shadows

`gpu-live` made this the obvious next one. Once idle slots started giving
their memory back, the shadow maps were the largest thing that never did:
128 MiB across eight slots, 37 % of the 341 MiB floor, and none of it
sampled — `shadow_enabled` defaults to false and most visual styles never
turn it on.

Every slot allocated `SHADOW_MAP_SIZE` squared at `Depth32Float` in
`Pipeline::new` and held it for the life of the slot, because the frame
bind group needs *something* at its shadow binding and the field was not
optional.

It is now `Option<(Texture, TextureView)>` alongside a 1x1 placeholder
that satisfies the binding while shadows are off — nothing samples it
then, so one texel is the whole requirement. `sync_shadow_target`
allocates on the transition into shadows and drops on the way out, and
only on the transition, so a viewport that keeps its setting does not
rebuild a bind group every frame.

The frame bind group had two inline builders — one in `Pipeline::new`, one
in `upload_background_images` — and this adds a third caller, so they are
now one `rebuild_frame_bind_group`. A shadow transition cannot forget the
background image the slot already had.

The shadow pass now tests for the target rather than the flag: wanting
shadows and having somewhere to put them are different questions, and
without the second one a failed 16 MiB allocation would have rendered the
pass into the 1x1 placeholder. Releasing a cold slot hands the target back
too, and `gpu-live` reports what is actually held rather than a constant.

Covered by a GPU test that toggles the setting off/on/off/on against a
real adapter with a validation scope armed, then releases the slot — the
transition is the risky path and the other GPU tests never take it.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-05 18:53:09 +03:00
1 parent deebc14012
commit 49318a1c55
3 files changed
+199 -67

No files matched your search

+51
View File
@@ -268,3 +268,54 @@ fn bounded_gpu_uploads_preserve_geometry_and_instancing() {
"GPU validation failed"
);
}
/// The shadow map is 16 MiB and every slot used to hold one whether or not its
/// visual style enabled shadows. Now it is allocated on the transition, which
/// means the frame bind group is rebuilt while the device is watching — the one
/// path the other GPU tests never take.
#[test]
#[ignore = "requires a GPU adapter"]
fn toggling_shadows_reallocates_without_upsetting_the_device() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
assert!(
pipeline.shadow_full.is_none(),
"a fresh slot must not hold a shadow map"
);
let camera = crate::scene::view::camera::Camera::default();
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(64.0, 64.0));
let with_shadows = |on: bool| {
let mut u = Uniforms::new(&camera, bounds, false);
u.shadow_params[0] = if on { 1.0 } else { 0.0 };
u
};
// Off → on → off → on, so both transitions are exercised twice and the
// second `on` proves the release did not leave the bind group stale.
for expected in [true, false, true] {
pipeline.upload_uniforms(&device, &queue, &with_shadows(expected));
assert_eq!(
pipeline.shadow_full.is_some(),
expected,
"shadow target should follow shadow_params[0]"
);
}
// Releasing a cold slot gives it back and must rebuild the bind group too.
pipeline.release_heavy_resources(&device);
assert!(pipeline.shadow_full.is_none(), "a released slot holds none");
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert!(
block_on(validation.pop()).is_none(),
"GPU validation failed"
);
}
+147 -66
View File
@@ -150,8 +150,16 @@ pub struct Pipeline {
background_sampler: wgpu::Sampler,
background_source_id: usize,
environment_source_id: usize,
_shadow_texture: wgpu::Texture,
shadow_view: wgpu::TextureView,
/// The shadow depth target, allocated only while this viewport actually
/// casts shadows. `SHADOW_MAP_SIZE` squared at `Depth32Float` is 16 MiB,
/// and every slot used to hold one whether or not its visual style enabled
/// shadows — 128 MiB across eight viewports, none of it ever sampled.
shadow_full: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Bound at the frame group's shadow slot while `shadow_full` is `None`.
/// The binding must be filled for the layout to be satisfied, but nothing
/// samples it when shadows are off, so 1x1 is the whole requirement.
_shadow_fallback_texture: wgpu::Texture,
shadow_fallback_view: wgpu::TextureView,
shadow_sampler: wgpu::Sampler,
shadow_enabled: bool,
wipeout_bgl1: wgpu::BindGroupLayout,
@@ -489,21 +497,9 @@ impl Pipeline {
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
});
let shadow_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("shadow.texture"),
size: wgpu::Extent3d {
width: SHADOW_MAP_SIZE,
height: SHADOW_MAP_SIZE,
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 shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default());
let shadow_fallback_texture = create_shadow_texture(device, 1);
let shadow_fallback_view =
shadow_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default());
let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("shadow.sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
@@ -542,7 +538,7 @@ impl Pipeline {
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&shadow_view),
resource: wgpu::BindingResource::TextureView(&shadow_fallback_view),
},
wgpu::BindGroupEntry {
binding: 6,
@@ -2276,8 +2272,9 @@ impl Pipeline {
background_sampler,
background_source_id: 0,
environment_source_id: 0,
_shadow_texture: shadow_texture,
shadow_view,
shadow_full: None,
_shadow_fallback_texture: shadow_fallback_texture,
shadow_fallback_view,
shadow_sampler,
shadow_enabled: false,
wipeout_bgl1,
@@ -3494,8 +3491,90 @@ impl Pipeline {
}
}
pub fn upload_uniforms(&mut self, queue: &wgpu::Queue, uniforms: &Uniforms) {
/// Whatever is bound at the frame group's shadow slot: the real depth
/// target when this viewport casts shadows, the 1x1 placeholder otherwise.
fn shadow_view(&self) -> &wgpu::TextureView {
match &self.shadow_full {
Some((_, view)) => view,
None => &self.shadow_fallback_view,
}
}
/// Rebuild the frame bind group from whatever this slot currently holds.
///
/// Three things can change what belongs in it — a background image, an
/// environment image, and whether the shadow target exists — and it used to
/// be assembled inline at each. One builder means a shadow transition
/// cannot forget the background the slot already had.
fn rebuild_frame_bind_group(&mut self, device: &wgpu::Device) {
let background_view = self
.background_texture
.create_view(&wgpu::TextureViewDescriptor::default());
let environment_view = self
.environment_texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer.bind_group"),
layout: &self.frame_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&background_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&environment_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(self.shadow_view()),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
},
],
});
}
/// Allocate or drop the shadow target to match `shadow_enabled`.
///
/// Only on a transition: the frame bind group has to be rebuilt with the
/// new view, and a viewport that keeps its shadow setting should not pay
/// for that every frame.
fn sync_shadow_target(&mut self, device: &wgpu::Device) {
match (self.shadow_enabled, self.shadow_full.is_some()) {
(true, false) => {
let texture = create_shadow_texture(device, SHADOW_MAP_SIZE);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.shadow_full = Some((texture, view));
}
(false, true) => self.shadow_full = None,
_ => return,
}
self.rebuild_frame_bind_group(device);
}
pub fn upload_uniforms(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
uniforms: &Uniforms,
) {
self.shadow_enabled = uniforms.shadow_params[0] > 0.5;
self.sync_shadow_target(device);
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
}
@@ -3562,46 +3641,7 @@ impl Pipeline {
environment,
[128, 128, 128, 255],
);
let background_view = self
.background_texture
.create_view(&wgpu::TextureViewDescriptor::default());
let environment_view = self
.environment_texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer.bind_group"),
layout: &self.frame_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&background_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&environment_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.background_sampler),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&self.shadow_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
},
],
});
self.rebuild_frame_bind_group(device);
self.background_source_id = background_id;
self.environment_source_id = environment_id;
}
@@ -3670,12 +3710,20 @@ impl Pipeline {
// render rectangle does the clipping).
let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 };
if self.shadow_enabled && !self.skip_geometry && !mesh_wireframe {
// `shadow_enabled` says the style wants shadows; the target existing
// says the device could give us one. Without the second test a failed
// 16 MiB allocation would render the pass into the 1x1 placeholder.
if let Some(shadow_target) = self
.shadow_full
.as_ref()
.map(|(_, view)| view)
.filter(|_| self.shadow_enabled && !self.skip_geometry && !mesh_wireframe)
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("shadow.render_pass"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.shadow_view,
view: shadow_target,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
@@ -4663,6 +4711,13 @@ impl Pipeline {
self.silhouette_source_groups.clear();
self.clip_boundary = None;
// A cold slot's shadow target goes back too. `shadow_enabled` is left
// alone, so `sync_shadow_target` reallocates on the next frame that
// still wants shadows.
if self.shadow_full.take().is_some() {
self.rebuild_frame_bind_group(device);
}
self.text_atlas_gpu = None;
self.text_gpu.clear();
self.block_text_gpu.clear();
@@ -4845,8 +4900,12 @@ impl Pipeline {
let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height);
GpuLiveBytes {
slots: 1,
// Allocated by every slot whether or not shadows are enabled.
shadow: u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4,
// Only while this viewport casts shadows; the placeholder that
// stands in otherwise is 4 bytes.
shadow: match &self.shadow_full {
Some(_) => u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4,
None => 4,
},
render_targets: pixels * BYTES_PER_PIXEL,
text_atlas: self
.text_atlas_gpu
@@ -4901,7 +4960,9 @@ impl MultiPipeline {
}
// Nothing to give back — releasing again would only churn the
// render targets it just rebuilt at their smallest size.
if self.inners[index].gpu_live_bytes().wire_arena == 0
let held = self.inners[index].gpu_live_bytes();
if held.wire_arena == 0
&& held.shadow <= 4
&& self.inners[index].alloc_size == Size::new(0, 0)
{
continue;
@@ -4948,6 +5009,26 @@ fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::Bl
pass.draw(0..wire.vertex_count, 0..wire.instance_count);
}
/// A square `Depth32Float` render target for the shadow pass. `side` is 1 for
/// the placeholder that keeps the frame bind group satisfied while shadows are
/// off, and `SHADOW_MAP_SIZE` for the real thing.
fn create_shadow_texture(device: &wgpu::Device, side: u32) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("shadow.texture"),
size: wgpu::Extent3d {
width: side,
height: side,
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: &[],
})
}
fn create_depth_texture(device: &wgpu::Device, size: Size<u32>) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer.depth_texture"),
+1 -1
View File
@@ -424,7 +424,7 @@ impl shader::Primitive for Primitive {
vp.background_image.as_ref(),
vp.environment_image.as_ref(),
);
inner.upload_uniforms(queue, &vp.uniforms);
inner.upload_uniforms(device, queue, &vp.uniforms);
// ── Scene-render cache ────────────────────────────────────────
// A pure cursor move — or any frame where the view, geometry,