fix(render): resolve validation crash from egui camera + brightpass uniform

Two runtime validation errors crashed the app on startup:

1. BrightPassMaterial used #[uniform(0)] threshold: f32 (bare f32 = 4-byte
   binding), but the WGSL BrightPassUniform struct is 16 bytes (threshold +
   3 pads). The pipeline-layout mismatch ("Buffer structure size 16...
   greater than min_binding_size, which is 4") killed the opaque_mesh2d
   pipeline. Fix: wrap threshold in a BrightPassUniform struct matching the
   WGSL layout, same pattern as BlurUniform/CompositeUniform.

2. bevy_egui 0.41's auto-context assigns PrimaryEguiContext to the FIRST
   Added<Camera> — with the 7-camera bloom pipeline, that's the offscreen
   camera (renders to Rgba16Float Image). egui's pipeline expects the
   window's Rgba8UnormSrgb surface → format mismatch crash. Fix: disable
   auto_create_primary_context (PreStartup system) and explicitly tag the
   composite (window) camera with PrimaryEguiContext.

Both fixes verified: the app now runs without validation errors for 8+
seconds. Physics tests (20/20) unaffected.
This commit is contained in:
xfy 2026-07-15 09:42:19 +08:00
parent 7591b790b2
commit 33ce1061ba
2 changed files with 38 additions and 4 deletions

View File

@ -123,12 +123,24 @@ impl Material2d for BlackHoleMaterial {
}
}
/// Uniform for the bright-pass (bloom stage [2]).
/// Must be a struct (not bare f32) so the Rust binding size matches the
/// WGSL `BrightPassUniform` (16 bytes: threshold + 3 pads). A bare f32
/// registers a 4-byte min_binding_size, causing a pipeline-layout mismatch.
#[derive(Clone, ShaderType, Default)]
pub struct BrightPassUniform {
pub threshold: f32,
pub _pad0: f32,
pub _pad1: f32,
pub _pad2: f32,
}
/// Extracts luminance above a threshold from the HDR offscreen into a
/// half-res float texture (bloom stage [2]). Soft-knee, not hard cut.
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct BrightPassMaterial {
#[uniform(0)]
pub threshold: f32,
pub uniform: BrightPassUniform,
#[texture(1)]
#[sampler(2)]
pub source: Handle<Image>,

View File

@ -80,6 +80,15 @@ pub struct BloomFinalTarget(pub Handle<Image>);
#[derive(Resource)]
pub struct AppliedBloomQuality(pub crate::params::BloomQuality);
/// Disables bevy_egui's auto-context creation so we can explicitly assign
/// `PrimaryEguiContext` to the composite (window) camera. Without this,
/// bevy_egui assigns to the first spawned camera — the offscreen camera,
/// which renders to an Rgba16Float Image that egui's Rgba8UnormSrgb pipeline
/// can't render into.
fn disable_egui_auto_context(mut settings: ResMut<bevy_egui::EguiGlobalSettings>) {
settings.auto_create_primary_context = false;
}
pub struct BlackHolePlugin;
impl Plugin for BlackHolePlugin {
@ -92,6 +101,14 @@ impl Plugin for BlackHolePlugin {
.add_plugins(Material2dPlugin::<crate::render::material::BrightPassMaterial>::default())
.add_plugins(Material2dPlugin::<crate::render::material::BlurMaterial>::default())
.add_plugins(bevy_egui::EguiPlugin::default())
// The bloom pipeline spawns 7 Camera2d entities (offscreen + brightpass
// + 4 blur + composite). bevy_egui's auto-context assigns PrimaryEguiContext
// to the FIRST Added<Camera> — the offscreen camera, which renders to an
// Rgba16Float Image. egui's pipeline expects the window's Rgba8UnormSrgb
// surface → format mismatch crash. Disable auto-assignment and explicitly
// tag the composite (window) camera with PrimaryEguiContext (see spawn).
// Runs in PreStartup, before setup_primary_egui_context_system.
.add_systems(bevy::prelude::PreStartup, disable_egui_auto_context)
.add_systems(Startup, spawn_fullscreen_quad)
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
.add_systems(
@ -238,7 +255,10 @@ fn spawn_fullscreen_quad(
CompositeQuad,
RenderLayers::layer(1),
));
commands.spawn((Camera2d, Msaa::Off, UpscaleCamera, Nudgable, RenderLayers::layer(1)));
// The composite camera renders to the window (Rgba8UnormSrgb surface).
// PrimaryEguiContext tells bevy_egui to render the UI onto THIS camera,
// not the offscreen/bloom cameras (which render to Rgba16Float Images).
commands.spawn((Camera2d, Msaa::Off, UpscaleCamera, Nudgable, bevy_egui::PrimaryEguiContext, RenderLayers::layer(1)));
}
/// Spawns the bloom pipeline (brightpass + blur pyramid + bloom_final).
@ -272,7 +292,9 @@ fn spawn_bloom_pipeline(
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
MeshMaterial2d(bp_materials.add(crate::render::material::BrightPassMaterial {
threshold: 1.0,
uniform: crate::render::material::BrightPassUniform {
threshold: 1.0, _pad0: 0.0, _pad1: 0.0, _pad2: 0.0,
},
source: offscreen.clone(),
})),
Transform::default().with_scale(Vec3::new(bw as f32 / 2.0, bh as f32 / 2.0, 1.0)),
@ -601,7 +623,7 @@ fn mirror_params(
}
// Update brightpass threshold (live-tunable).
for (_, mat) in brightpass_materials.iter_mut() {
mat.threshold = params.bloom_threshold;
mat.uniform.threshold = params.bloom_threshold;
}
// Update composite material uniforms (bloom strength + exposure live-tunable).
for (_, mat) in composite_materials.iter_mut() {