Skip to content

Prepass vertex shader crashes when there is a bind group #24690

Description

@TravisRybka

Bevy version and features

0.19.0

What you did

Create a Material/MaterialExtension with a prepass_vertex_shader that has custom bindings.

What went wrong

Rendering crashes with:

ERROR bevy_render::error_handler: Caught rendering error: Validation Error

Caused by:
  In Device::create_render_pipeline, label = 'pbr_prepass_pipeline'
    Error matching ShaderStages(VERTEX) shader requirements against the pipeline
      Shader global ResourceBinding { group: 3, binding: 100 } is not available in the pipeline layout
        Binding is missing from the pipeline layout

Additional information

Here is a near minimal example that causes crashes. It is a bit non-sensical since I removed a lot of stuff that actually matters, but still causes crashes. If I downgrade to Bevy 0.18.0, there are no crashes. In my actual game, I do a lot of vertex shader logic and it needs bindings. Then I use the same bindings in the prepass shader to make sure shadows are handled correctly, and not using the original geometry.

Example:

use bevy::pbr::{ExtendedMaterial, MaterialExtension};
use bevy::prelude::*;
use bevy::render::render_resource::AsBindGroup;
use bevy::shader::ShaderRef;

type BugMaterial = ExtendedMaterial<StandardMaterial, ReadsMaterialInPrepass>;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(MaterialPlugin::<BugMaterial>::default())
        .add_systems(Startup, setup)
        .run();
}

#[derive(Asset, TypePath, AsBindGroup, Clone)]
struct ReadsMaterialInPrepass {
    #[uniform(100)]
    offset: Vec4,
}

impl MaterialExtension for ReadsMaterialInPrepass {
    fn prepass_vertex_shader() -> ShaderRef {
        "prepass.wgsl".into()
    }
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<BugMaterial>>,
) {
    commands.spawn((
        Mesh3d(meshes.add(Cuboid::default())),
        MeshMaterial3d(materials.add(BugMaterial {
            base: StandardMaterial::default(),
            extension: ReadsMaterialInPrepass { offset: Vec4::ZERO },
        })),
    ));

    commands.spawn(Camera3d::default());
    commands.spawn(DirectionalLight {
        shadow_maps_enabled: true,
        ..default()
    });
}

And the shader:

#import bevy_pbr::{
    mesh_functions,
    prepass_io::{Vertex, VertexOutput},
    view_transformations::position_world_to_clip,
}

@group(#{MATERIAL_BIND_GROUP}) @binding(100)
var<uniform> offset: vec4<f32>;

@vertex
fn vertex(vertex: Vertex) -> VertexOutput {
    var out: VertexOutput;
    let model = mesh_functions::get_world_from_local(vertex.instance_index);
    let world = mesh_functions::mesh_position_local_to_world(
        model,
        vec4<f32>(vertex.position + offset.xyz, 1.0),
    );
    return out;
}

I don't know much about the logic behind this, but I did throw the problem at Claude to see if I was crazy, and it claims:

Details Introduced by **#22813 "Batch prepasses that use depth only."** For a depth-only opaque prepass, the material bind group layout is replaced with an empty layout:

crates/bevy_pbr/src/prepass/mod.rs, PrepassPipeline::specialize:

if is_depth_only_opaque_prepass(mesh_key) && !emulate_unclipped_depth {
    bind_group_layouts.push(self.empty_layout.clone());   // <-- no material bindings
} else {
    bind_group_layouts.push(material_properties.material_layout.as_ref().unwrap().clone());
}

with

fn is_depth_only_opaque_prepass(mesh_key: MeshPipelineKey) -> bool {
    mesh_key.intersection(MeshPipelineKey::ALL_PREPASS_BITS) == MeshPipelineKey::DEPTH_PREPASS
}

The PR also added an opt-out flag, MeshPipelineKey::PREPASS_READS_MATERIAL:

  • It is part of ALL_PREPASS_BITS (so setting it makes is_depth_only_opaque_prepass return false, i.e. keeps the material layout).
  • The shadow path checks it in specialize_shadows (crates/bevy_pbr/src/render/light.rs):
    let is_depth_only_opaque = !item.mesh_key
        .intersects(MeshPipelineKey::MAY_DISCARD | MeshPipelineKey::PREPASS_READS_MATERIAL)
        && !emulate_unclipped_depth;

But PREPASS_READS_MATERIAL is never set by any code. git grep PREPASS_READS_MATERIAL on both release-0.19.0 and main shows only its definition, its inclusion in ALL_PREPASS_BITS, and the read in light.rs — there is no write. So there is currently no way (engine-side or user-side) for a material to declare that its prepass reads material data, and such materials hit the layout/shader mismatch above.

There is also no user-side workaround:

  • The shadow mesh_key is built from light_key | mesh.key_bits | <alpha discard bits> in specialize_shadows; it does not include the material's mesh_pipeline_key_bits, so a material cannot inject PREPASS_READS_MATERIAL through its own key.
  • AlphaMode::Mask only sets MAY_DISCARD, which is_depth_only_opaque_prepass (the layout decision) ignores.

Proposed fix

Set PREPASS_READS_MATERIAL when a material overrides the prepass vertex or fragment shader (those are the cases that can read material data in the prepass). For the shadow path, in specialize_shadows where the discard bits are added:

if material.properties.get_shader(PrepassVertexShader).is_some()
    || material.properties.get_shader(PrepassFragmentShader).is_some()
{
    mesh_key |= MeshPipelineKey::PREPASS_READS_MATERIAL;
}

and the equivalent where the camera-prepass material key is built in specialize_prepass_material_meshes.

Alternatively (more explicit/precise), add an opt-in to the Material trait, e.g. fn prepass_reads_material() -> bool { false }, and OR PREPASS_READS_MATERIAL into the key when it returns true. This avoids disabling batching for custom prepass shaders that don't actually touch material data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-RenderingDrawing game state to the screenC-BugAn unexpected or incorrect behaviorP-RegressionFunctionality that used to work but no longer does. Add a test for this!S-Needs-InvestigationThis issue requires detective work to figure out what's going wrong

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions