bevy/release-content/migration-guides/composable_specialization.md
Emerson Coskey bdd3ef71b8
Composable Pipeline Specialization (#17373)
Currently, our specialization API works through a series of wrapper
structs and traits, which make things confusing to follow and difficult
to generalize.

This pr takes a different approach, where "specializers" (types that
implement `Specialize`) are composable, but "flat" rather than composed
of a series of wrappers. The key is that specializers don't *produce*
pipeline descriptors, but instead *modify* existing ones:

```rs
pub trait Specialize<T: Specializable> {
    type Key: SpecializeKey;
    
    fn specialize(
        &self, 
        key: Self::Key, 
        descriptor: &mut T::Descriptor
    ) -> Result<Canonical<Self::Key>, BevyError>;
}
```

This lets us use some derive magic to stick multiple specializers
together:

```rs
pub struct A;
pub struct B;

impl Specialize<RenderPipeline> for A { ... }
impl Specialize<RenderPipeline> for A { ... }

#[derive(Specialize)]
#[specialize(RenderPipeline)]
struct C {
    // specialization is applied in struct field order
    applied_first: A,
    applied_second: B,
}

type C::Key = (A::Key, B::Key);

```

This approach is much easier to understand, IMO, and also lets us
separate concerns better. Specializers can be placed in fully separate
crates/modules, and key computation can be shared as well.

The only real breaking change here is that since specializers only
modify descriptors, we need a "base" descriptor to work off of. This can
either be manually supplied when constructing a `Specializer` (the new
collection replacing `Specialized[Render/Compute]Pipelines`), or
supplied by implementing `HasBaseDescriptor` on a specializer. See
`examples/shader/custom_phase_item.rs` for an example implementation.

## Testing

- Did some simple manual testing of the derive macro, it seems robust.

---

## Showcase

```rs
#[derive(Specialize, HasBaseDescriptor)]
#[specialize(RenderPipeline)]
pub struct SpecializeMeshMaterial<M: Material> {
    // set mesh bind group layout and shader defs
    mesh: SpecializeMesh,
    // set view bind group layout and shader defs
    view: SpecializeView,
    // since type SpecializeMaterial::Key = (), 
    // we can hide it from the wrapper's external API
    #[key(default)]
    // defer to the GetBaseDescriptor impl of SpecializeMaterial, 
    // since it carries the vertex and fragment handles
    #[base_descriptor]
    // set material bind group layout, etc
    material: SpecializeMaterial<M>,
}

// implementation generated by the derive macro
impl <M: Material> Specialize<RenderPipeline> for SpecializeMeshMaterial<M> {
    type Key = (MeshKey, ViewKey);

    fn specialize(
        &self, 
        key: Self::Key, 
        descriptor: &mut RenderPipelineDescriptor
    ) -> Result<Canonical<Self::Key>, BevyError>  {
        let mesh_key = self.mesh.specialize(key.0, descriptor)?;
        let view_key = self.view.specialize(key.1, descriptor)?;
        let _ = self.material.specialize((), descriptor)?;
        Ok((mesh_key, view_key));
    }
}

impl <M: Material> HasBaseDescriptor<RenderPipeline> for SpecializeMeshMaterial<M> {
    fn base_descriptor(&self) -> RenderPipelineDescriptor {
        self.material.base_descriptor()
    }
}
```

---------

Co-authored-by: Tim Overbeek <158390905+Bleachfuel@users.noreply.github.com>
2025-07-01 01:32:44 +00:00

154 lines
4.9 KiB
Markdown

---
title: Composable Specialization
pull_requests: [17373]
---
The existing pipeline specialization APIs (`SpecializedRenderPipeline` etc.) have
been replaced with a single `Specializer` trait and `SpecializedCache` collection:
```rs
pub trait Specializer<T: Specializable>: Send + Sync + 'static {
type Key: SpecializerKey;
fn specialize(
&self,
key: Self::Key,
descriptor: &mut T::Descriptor,
) -> Result<Canonical<Self::Key>, BevyError>;
}
pub struct SpecializedCache<T: Specializable, S: Specializer<T>>{ ... };
```
The main difference is the change from *producing* a pipeline descriptor to
*mutating* one based on a key. The "base descriptor" that the `SpecializedCache`
passes to the `Specializer` can either be specified manually with `Specializer::new`
or by implementing `GetBaseDescriptor`. There's also a new trait for specialization
keys, `SpecializeKey`, that can be derived with the included macro in most cases.
Composing multiple different specializers together with the `derive(Specializer)`
macro can be a lot more powerful (see the `Specialize` docs), but migrating
individual specializers is fairly simple. All static parts of the pipeline
should be specified in the base descriptor, while the `Specializer` impl
should mutate the key as little as necessary to match the key.
```rs
pub struct MySpecializer {
layout: BindGroupLayout,
layout_msaa: BindGroupLayout,
vertex: Handle<Shader>,
fragment: Handle<Shader>,
}
// before
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
// after
#[derive(Clone, Copy, PartialEq, Eq, Hash, SpecializerKey)]
pub struct MyKey {
blend_state: BlendState,
msaa: Msaa,
}
impl FromWorld for MySpecializer {
fn from_world(&mut World) -> Self {
...
}
}
// before
impl SpecializedRenderPipeline for MySpecializer {
type Key = MyKey;
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
RenderPipelineDescriptor {
label: Some("my_pipeline".into()),
layout: vec![
if key.msaa.samples() > 0 {
self.layout_msaa.clone()
} else {
self.layout.clone()
}
],
push_constant_ranges: vec![],
vertex: VertexState {
shader: self.vertex.clone(),
shader_defs: vec![],
entry_point: "vertex".into(),
buffers: vec![],
},
primitive: Default::default(),
depth_stencil: None,
multisample: MultisampleState {
count: key.msaa.samples(),
..Default::default()
},
fragment: Some(FragmentState {
shader: self.fragment.clone(),
shader_defs: vec![],
entry_point: "fragment".into(),
targets: vec![Some(ColorTargetState {
format: TextureFormat::Rgba8Unorm,
blend: Some(key.blend_state),
write_mask: ColorWrites::all(),
})],
}),
zero_initialize_workgroup_memory: false,
},
}
}
app.init_resource::<SpecializedRenderPipelines<MySpecializer>>();
// after
impl Specializer<RenderPipeline> for MySpecializer {
type Key = MyKey;
fn specialize(
&self,
key: Self::Key,
descriptor: &mut RenderPipeline,
) -> Result<Canonical<Self::Key>, BevyError> {
descriptor.multisample.count = key.msaa.samples();
descriptor.layout[0] = if key.msaa.samples() > 0 {
self.layout_msaa.clone()
} else {
self.layout.clone()
};
descriptor.fragment.targets[0].as_mut().unwrap().blend_mode = key.blend_state;
Ok(key)
}
}
impl GetBaseDescriptor for MySpecializer {
fn get_base_descriptor(&self) -> RenderPipelineDescriptor {
RenderPipelineDescriptor {
label: Some("my_pipeline".into()),
layout: vec![self.layout.clone()],
push_constant_ranges: vec![],
vertex: VertexState {
shader: self.vertex.clone(),
shader_defs: vec![],
entry_point: "vertex".into(),
buffers: vec![],
},
primitive: Default::default(),
depth_stencil: None,
multisample: MultiSampleState::default(),
fragment: Some(FragmentState {
shader: self.fragment.clone(),
shader_defs: vec![],
entry_point: "fragment".into(),
targets: vec![Some(ColorTargetState {
format: TextureFormat::Rgba8Unorm,
blend: None,
write_mask: ColorWrites::all(),
})],
}),
zero_initialize_workgroup_memory: false,
},
}
}
app.init_resource::<SpecializedCache<RenderPipeline, MySpecializer>>();
```