Skip to content

Enhance GpuComponentArrayBuffer so that it can be used to store arbitrary data on mesh instances.#24922

Open
pcwalton wants to merge 1 commit into
bevyengine:mainfrom
pcwalton:gpu-component-array-buffer
Open

Enhance GpuComponentArrayBuffer so that it can be used to store arbitrary data on mesh instances.#24922
pcwalton wants to merge 1 commit into
bevyengine:mainfrom
pcwalton:gpu-component-array-buffer

Conversation

@pcwalton

@pcwalton pcwalton commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Currently, the only way to store arbitrary data that can be accessed in a shader on the mesh is to store it in the material. However, this has overhead, because it duplicates the material for each mesh instance, and updating that material can cause performance problems, as material updates go through the bind group allocator and AsBindGroup machinery. Recognizing this problem, other game engines have developed lightweight mechanisms to store per-instance data, such as Unity's Material Property Blocks and Godot's instance uniforms.

As it turns out, Bevy has a feature called GpuComponentArrayBuffer which was added in 2023 and at a glance looks like it's supposed to be a solution to this problem. However, there are a number of problems with it:

  1. GpuComponentArrayBuffer successfully gathers up the components and transfers them to the GPU, but it doesn't actually provide any mechanism to associate an instance ID with the data, making it difficult and/or impossible to use in practice unless there's only one object in the scene that uses the shader (and if there is only one such object, there's little reason to use GpuComponentArrayBuffer in the first place, since a UBO on the material suffices).

  2. GpuComponentArrayBuffer requires that the component be encase-compatible, which essentially requires that the component be POD. There's no mechanism to convert a CPU-side representation of a component to a suitable GPU representation.

  3. GpuComponentArrayBuffer extracts all components from scratch every frame. This isn't considered a best practice anymore in modern Bevy; modern Bevy rendering prefers to retain objects between frames and use fine-grained change detection.

This PR fixes all of these issues:

  1. The GpuComponentArrayBuffer now uses the MeshTag to associate each mesh instance with its extracted component. The MeshTag is per-instance data that's accessible in the shader, so it's easy for a shader to look up its associated data.

  2. GpuComponentArrayBuffer is now a trait (following the established Rust naming convention, which prefers to avoid suffixes like -able). By implementing this trait for a component, the application can customize the mapping of the CPU-side component to its GPU-side representation. The application registers a GpuComponentArrayBuffer implementation by adding the GpuComponentArrayBufferPlugin, following the existing pattern established by ExtractComponentPlugin and so forth.

  3. The GpuComponentArrayBuffer now caches data from frame to frame. It uses a Changed query filter to avoid re-extracting data that hasn't changed. The buffer remains tightly packed even when data is removed. (Thus tags aren't stable from frame to frame. As GpuComponentArrayBuffer is a general-purpose mechanism for the application to use, and I can't assume what the allocation and deallocation patterns are for a specific allocation, I figured avoiding fragmentation in the buffer was more important than maintaining stable tags.)

The intended use case for GpuComponentArrayBuffer is to move frequently updated SpriteMaterial data from the material to an array buffer. Updating a material is a relatively expensive operation because it requires going through the asset re-extraction and bind group allocation process. Moreover, having to allocate a different material for every frame of a sprite's flipbook animation is inelegant; this would remain true even if we made updating materials much faster. So GpuComponentArrayBuffer is a desirable abstraction not only in terms of performance, but also conceptually.

A new example, gpu_component_array_buffer, has been added. This example demonstrates how to associate arbitrary data with mesh instances and to access it in a shader. The custom material in that example can operate in bindless or non-bindless mode. The example spawns and despawns objects randomly in order to test that the buffer properly maintains the indices into the data buffer.

arbitrary data on mesh instances.

Currently, the only way to store arbitrary data that can be accessed in
a shader on the mesh is to store it in the material. However, this has
overhead, because it duplicates the material for each mesh instance, and
updating that material can cause performance problems, as material
updates go through the bind group allocator and `AsBindGroup` machinery.
Recognizing this problem, other game engines have developed lightweight
mechanisms to store per-instance data, such as Unity's [Material
Property Blocks] and Godot's [instance uniforms].

As it turns out, Bevy has a feature called `GpuComponentArrayBuffer`
which was added in 2023 and at a glance looks like it's supposed to be a
solution to this problem. However, there are a number of problems with
it:

1. `GpuComponentArrayBuffer` successfully gathers up the components and
   transfers them to the GPU, but it doesn't actually provide any
   mechanism to associate an instance ID with the data, making it
   difficult and/or impossible to use in practice unless there's only
   one object in the scene that uses the shader (and if there is only
   one such object, there's little reason to use
   `GpuComponentArrayBuffer` in the first place, since a UBO on the
   material suffices).

2. `GpuComponentArrayBuffer` requires that the component be
   `encase`-compatible, which essentially requires that the component be
   POD. There's no mechanism to convert a CPU-side representation of a
   component to a suitable GPU representation.

3. `GpuComponentArrayBuffer` extracts all components from scratch every
   frame. This isn't considered a best practice anymore in modern Bevy;
   modern Bevy rendering prefers to retain objects between frames and
   use fine-grained change detection.

This PR fixes all of these issues:

1. The `GpuComponentArrayBuffer` now uses the `MeshTag` to associate each mesh
   instance with its extracted component. The `MeshTag` is per-instance
   data that's accessible in the shader, so it's easy for a shader to
   look up its associated data.

2. `GpuComponentArrayBuffer` is now a trait (following the established
   Rust naming convention, which prefers to avoid suffixes like
   *-able*). By implementing this trait for a component, the application
   can customize the mapping of the CPU-side component to its GPU-side
   representation. The application registers a `GpuComponentArrayBuffer`
   implementation by adding the `GpuComponentArrayBufferPlugin`,
   following the existing pattern established by
   `ExtractComponentPlugin` and so forth.

3. The `GpuComponentArrayBuffer` now caches data from frame to frame. It
   uses a `Changed` query filter to avoid re-extracting data that hasn't
   changed. The buffer remains tightly packed even when data is removed.
   (Thus tags aren't stable from frame to frame. As
   `GpuComponentArrayBuffer` is a general-purpose mechanism for the
   application to use, and I can't assume what the allocation and
   deallocation patterns are for a specific allocation, I figured
   avoiding fragmentation in the buffer was more important than
   maintaining stable tags.)

The intended use case for `GpuComponentArrayBuffer` is to move
frequently updated `SpriteMaterial` data from the material to an array
buffer. Updating a material is a relatively expensive operation because
it requires going through the asset re-extraction and bind group
allocation process. Moreover, having to allocate a different material
for every frame of a sprite's flipbook animation is inelegant; this
would remain true even if we made updating materials much faster. So
`GpuComponentArrayBuffer` is a desirable abstraction not only in terms
of performance, but also conceptually.

A new example, `gpu_component_array_buffer`, has been added. This
example demonstrates how to associate arbitrary data with mesh instances
and to access it in a shader. The custom material in that example can
operate in bindless or non-bindless mode. The example spawns and
despawns objects randomly in order to test that the buffer properly
maintains the indices into the data buffer.

[Material Property Blocks]: https://docs.unity3d.com/ScriptReference/MaterialPropertyBlock.html

[instance uniforms]: https://godotengine.org/article/godot-40-gets-global-and-instance-shader-uniforms/
@pcwalton
pcwalton requested review from IceSentry and JMS55 July 9, 2026 05:20
@pcwalton pcwalton added the A-ECS Entities, components, systems, and events label Jul 9, 2026
@pcwalton pcwalton added the A-Rendering Drawing game state to the screen label Jul 9, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in ECS Jul 9, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Rendering Jul 9, 2026
@pcwalton pcwalton added S-Needs-Review Needs reviewer attention (from anyone!) to move forward C-Feature A new feature, making something new possible C-Performance A change motivated by improving speed, memory usage or compile times labels Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

The generated examples/README.md is out of sync with the example metadata in Cargo.toml or the example readme template. Please run cargo run -p build-templated-pages -- update examples to update it, and commit the file change.

@beicause beicause left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't work on webgpu:

WGPU_SETTINGS_PRIO="webgpu" cargo r --example gpu_component_array_buffer
2026-07-09T07:05:23.376130Z ERROR naga::proc::typifier: Access sub-type Struct { members: [StructMember { name: Some("data_array"), ty: [42], binding: None, offset: 0 }], span: 16 }
2026-07-09T07:05:23.439397Z ERROR bevy_render::render_resource::pipeline_cache: failed to process shader error:
error: Invalid sub-access into type [43], indexed: false
 = Invalid sub-access into type [43], indexed: false

Might be an issue with bindless, but theoretically this should be supported because it just needs storage buffers.

/// An optional filter to apply to the query.
type QueryFilter: QueryFilter;
/// The packed GPU data.
type Out: Pod + ShaderType + Default;

@beicause beicause Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
type Out: Pod + ShaderType + Default;
type Out: NoUninit + AnyBitPattern;

Relaxs the trait requirements.

/// This is copied byte-by-byte to the GPU, not processed through
/// [`ShaderType`]. Consequently, we must insert all padding ourselves. We pad
/// the value out to 16 bytes, which is a good conservative practice.
#[derive(Clone, Copy, Default, ShaderType, Pod, Zeroable)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[derive(Clone, Copy, Default, ShaderType, Pod, Zeroable)]
#[derive(Clone, Copy, Pod, Zeroable)]

pub enum ShaderBufferData {
/// The buffer will be uninitialized when created and has the given size in
/// bytes.
Uninitialized(usize),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Uninitialized(usize),
Uninitialized(wgpu_types::BufferAddress),

I think this should match the type of wgpu::BufferDescriptor::size

Comment on lines +162 to 172
pub fn resize_in_place(&mut self, new_size: u64) {
match self.data {
ShaderBufferData::Initialized(ref mut data) => {
data.resize(new_size as usize, 0);
}
ShaderBufferData::Uninitialized(ref mut size) => {
*size = new_size as usize;
self.copy_on_resize = true;
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same as resize and the behavior doesn't match the comment, and implicitly changing copy_on_resize is confusing.

Should we only keep one resize and make it not change copy_on_resize?

created with `ShaderBufferData::Initialized`"
);
};
&bytemuck::cast_slice(data_buffer.as_slice())[tag as usize]

@beicause beicause Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is problematic. ShaderBuffer's data is Vec<u8> aligned to 1 byte, but C::Out may have larger alignment, thus this can fail.
Same for the other cast_slice/cast_slice_mut

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use https://docs.rs/bytemuck/latest/bytemuck/fn.pod_read_unaligned.html to read and copy_from_slice(bytes_of(&val)) to write, which don't require alignment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I included some changes of ShaderBuffer in #24938 and attempted to improve this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, let's wait for a decision on #24938 before landing my PR.

@pcwalton pcwalton added the S-Blocked This cannot move forward until something else changes label Jul 14, 2026
@pcwalton

Copy link
Copy Markdown
Contributor Author

Blocked on #24938

pcwalton added a commit to pcwalton/bevy that referenced this pull request Jul 18, 2026
changed.

At the moment, whenever a material changes, we free and recreate the
bind group in the material bind group allocator. This is necessary in
general when one or more GPU resources (buffers, textures, samplers)
change, because it's possible for changing one of those to cause a
material to overflow the slab. However, when only POD (plain old data)
changes, for example a color, this constitutes needless overhead.

This patch improves the performance in this case by adding an early
check and fast path to material asset preparation for bindless
materials. With this PR, before we free and reallocate a material, we
walk through the list of all GPU resources, checking to see if the new
version of the material has the same bindings. If so, we keep the
material as-is in the bind group allocator and only update the data
buffers.

Additionally, this PR removes the allocations in the
`unprepared_bind_group` function by introducing an `BindGroupBuilder`
abstraction that allows reusing allocations between frames. The
`BindGroupBuilder::binding_resources` vector and the
`BindGroupBuilder::data_buffer` are now temporary staging buffers that
can be reused from bind group to bind group.

Note that we currently still perform allocations for non-bindless
materials. This can be improved, but because non-bindless materials
upload their data later than bindless materials do, reusing the data
buffers isn't as straightforward.

Importantly, this PR opens the door to parallel material preparation in
the future. This is because neither the fast path nor the check to make
sure we can take the fast path mutate the `MaterialBindGroupAllocator`,
outside of the data buffer itself. Therefore, in the future, we can
migrate the material data buffer to `AtomicSparseBufferVec` and update
it in parallel just as we do for mesh instance uniforms. Note however
that parallel material preparation needs some more work in the assets
infrastructure, and I suspect that work will collide with
assets-as-entities, so I opted to leave that for a follow-up (and also
to prevent this patch from growing too large).

Future work could involve adding a special fast path for materials that
are *only* POD to avoid most of the material bind group allocator
machinery entirely. Note that, if updating shader-accessible instance
data is performance-critical, applications may find
[`GpuComponentArrayBuffer`] to be a better fit than the material system.

On `many_cubes --vary-material-data-per-instance --animate-materials
--instance-count 16000`, this PR improves the performance of
`prepare_erased_assets` on `StandardMaterial` from 33.0 ms/frame to
21.7 ms/frame, a 1.51× speedup.

[`GpuComponentArrayBuffer`]: bevyengine#24922
data: Some(data.to_vec()),
pub fn new(data: Vec<u8>, asset_usage: RenderAssetUsages) -> Self {
ShaderBuffer {
data: ShaderBufferData::Initialized(data.to_vec()),

@goodartistscopy goodartistscopy Jul 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is to_vec() still necessary since we're consuming data ?

Suggested change
data: ShaderBufferData::Initialized(data.to_vec()),
data: ShaderBufferData::Initialized(data),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-ECS Entities, components, systems, and events A-Rendering Drawing game state to the screen C-Feature A new feature, making something new possible C-Performance A change motivated by improving speed, memory usage or compile times S-Blocked This cannot move forward until something else changes S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage
Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

3 participants