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 <noreply@anthropic.com>
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"
);
}