Fall back instead of crashing when the scaled world runs out of video memory

Post::prepare built the scaled world targets while the old ones were still
alive and without catching a failed allocation, so dragging the Resolution
Scale slider could run a small GPU out of memory and wgpu's fatal default
handler panicked (#6607).

Drop the old target first, build the new one inside an out-of-memory and
validation error scope (a failed texture is invalid, so its view fails
validation too), and on failure step the scale down by a quarter, to a floor
of 0.25, then draw at the window's size.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
ClickingMasterandClaude Sonnet 5.5 committed 2026-11-01 18:00:46 -08:00
1 parent 7366f3f7aa
commit f9fb435f94
1 file changed
+62 -12
@@ -10,6 +10,8 @@
use glam::{DMat4, DVec3, Mat4, Vec2};
/// The least a world is scaled to when memory runs out.
const MIN_SCALE: f32 = 0.25;
/// The TAA history format: 8 bits would band as frames accumulate.
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -322,12 +324,65 @@ impl Post {
self.target = None;
return None;
}
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
// The old target goes first: a slider drag would otherwise hold the old and the new
// targets together, and the sum is what runs a small GPU out of memory.
loop {
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
{
break;
}
self.target = None;
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
// that failed is invalid, so its view fails validation too: catch that as well.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let target = self.build_target(device, size, history_size);
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
if error.is_none() {
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(target);
break;
}
drop(target);
if self.scale <= MIN_SCALE {
log::warn!(
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = 1.0;
self.target = None;
return None;
}
let reduced = (self.scale * 0.75).max(MIN_SCALE);
log::warn!(
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
self.scale,
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = reduced;
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
return None;
}
}
self.target
.as_ref()
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
/// is set.
fn build_target(
&self,
device: &wgpu::Device,
size: [u32; 2],
history_size: Option<[u32; 2]>,
) -> WorldTarget {
{
let make_tex = |label, size: [u32; 2], format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -403,19 +458,14 @@ impl Post {
show,
})
});
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(WorldTarget {
WorldTarget {
size,
color,
depth,
group,
taa,
});
}
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The size of the world's own target, when it has one.