bevy_shader_plugin
A Bevy plugin for fullscreen fragment shaders and the compute shaders that feed them. Handles render scheduling, pipeline creation, and buffer management.
The plugin renders through Bevy's 3D pipeline, so it needs a Camera3d. It is not compatible with MSAA, make sure to disable it on all cameras:
commands.spawn;
Compatibility
bevy-shader-plugin |
Bevy | rust-gpu |
|---|---|---|
| 0.2 | 0.19 | 0.10.0 |
| 0.1 | 0.18 | — |
Version 0.1 was published as bevy-fragment-shader-plugin.
Setup
Import everything you need in two lines:
use *;
use *;
Quick start
1. Define your Uniform struct
Padding to 16 bytes is handled automatically — no _pad fields needed.
2. Register the plugin and buffer
new
.add_plugins
.add_plugins
.
.
// ...
Any changes to the MyUniform resource will be reflected in the associated buffer on the next frame.
3. Write the shader
struct MyUniform { resolution: vec2<f32>, time: f32 }
@group(0) @binding(0) var<uniform> u: MyUniform;
@fragment
fn frag_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / u.resolution;
return vec4(uv, 0.5 + 0.5 * sin(u.time), 1.0);
}
See examples/solar_system/ for a complete example combining uniform, storage, and array buffers.
To run a simulation on the GPU and draw it, see Compute shaders.
Shaders can also be written in Rust instead of WGSL — see Using rust-gpu shaders.
Buffer registration
All registration methods are on the ShaderAppExt trait (included in the prelude). The group_index and binding_index arguments map directly to @group(n) @binding(n) in WGSL. Every registered buffer is visible to both the fragment shader and the compute shader.
- Call them after adding
DefaultPlugins. - Group indices must start at 0 with no gaps (e.g. 0, 1, 2 — not 1, 2 or 0, 2).
- Declare each binding with the same access mode (
readorread_write) in every shader that uses it. wgpu rejects a shader that declaresreadfor a binding registered asread_write. - WebGPU only guarantees 4 bind groups per pipeline, counting registered groups and manual extra groups together. Prefer putting several bindings in one group (different
binding_indexvalues under the samegroup_index) over giving each buffer its own group. Native backends usually allow more, but browsers and some mobile GPUs enforce the limit.
Uniform buffer
app.;
The resource is extracted from the main world and uploaded whenever it changes. WGSL: var<uniform>.
Storage buffer
app.; // false = read-only, true = read_write
Like uniforms, the resource is uploaded whenever it changes, into a buffer that persists between frames. With read_write, anything a shader writes stays until the resource next changes, then the CPU upload replaces the whole buffer. For data only the GPU updates, use a GPU buffer instead.
Multiple bindings sharing the same group_index are packed into one bind group:
app.
.
.;
Fixed-size array buffer
For fixed-length arrays that benefit from per-element updates (rather than resending the whole array whenever one element changes), use register_array_buffer:
;
// <Tag, Type, Capacity>
app.;
Update elements each frame via ArrayBufferChanges<Tag> using set, set_many, or set_all. Only changed elements are uploaded, batched into contiguous write_buffer runs:
Values must be the Type given at registration (Vec4 above). This isn't checked.
GPU buffer
For data that lives on the GPU, such as simulation state that compute passes update every frame, use register_gpu_buffer:
;
// <Tag, Type>
app.; // 1024 elements
@group(1) @binding(0) var<storage, read_write> particles: array<Particle>;
The buffer starts filled with Type::default() and is never uploaded from the CPU again, so whatever shaders write persists across frames. It is always read_write. The tag names the buffer and sizes compute dispatches (see Workgroups::over).
Reading buffers back
Storage, array and GPU buffers can be copied back to the CPU, e.g. to read results a shader wrote. Enable readback after registering the buffer, using the same tag and type:
app.
.; // → GpuReadback<Particles, Particle>
app.
.; // → GpuReadback<Colors, Vec4>
app.
.; // → StorageReadback<Stats>
Array and GPU buffers read back as a slice of elements (latest() returns &[Type]). A storage buffer reads back as the resource type it was registered with (latest() returns &Stats), which can itself contain arrays.
With ReadbackMode::OnRequest, call request() on the readback resource to ask for a copy. The resource only changes when new data comes in:
With ReadbackMode::EveryFrame, you don't need to call request() or check is_changed(). Just read latest(), which is replaced with a new copy about once per frame.
- Results arrive 1–3 frames after the copy, and show the buffer as it was at the end of that frame. Use them for UI, stats or logging, not for logic that needs the exact current GPU state.
- Every readback copies the whole buffer. That's negligible for thousands of elements, but
EveryFrameon very large buffers has a real cost. - If a storage type is registered at several bindings, the first storage registration is read. Uniform buffers can't be read back.
Compute shaders
ComputeShaderPlugin runs @compute entry points once per frame, before any camera renders. Compute and fragment shaders share every registered buffer, so a compute pass can update a GPU buffer and the fragment shader can draw it in the same frame. Both plugins can load the same .wgsl file.
;
new
.add_plugins
.add_plugins
.
.add_systems
.run;
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
@compute @workgroup_size(64)
fn update(@builtin(global_invocation_id) id: vec3<u32>) {
if id.x >= arrayLength(&particles) { return; }
// ...
}
-
passruns every frame. Passes run in the order they're added, and each one sees what the passes before it wrote. The same entry point can be added several times, e.g. for simulation substeps. -
init_passruns once, on the first frame everything is ready. Every-frame passes wait until all init passes have run. Run one again withComputePasses: -
Only one
ComputeShaderPluginis supported per app. -
To order your own render systems around this crate's GPU work, use the
ShaderPassSystemssets (Compute,Fullscreen,ReadbackCopy).
Dispatch sizes
Workgroups says how much work a pass covers. The plugin reads each entry point's @workgroup_size from the WGSL source and works out the workgroup count, so the size is only written in the shader.
| Threads | |
|---|---|
Workgroups::over::<Tag>() |
One per element of a GPU buffer, along x. Use global_invocation_id.x as the index and a 1D @workgroup_size(N). |
Workgroups::window() |
One per physical pixel of the primary window, on x and y. |
Workgroups::threads(UVec3) |
An explicit thread count. |
Workgroups::exact(UVec3) |
Raw workgroup counts, with no reflection. |
Reflection only supports plain WGSL. Shaders that use Bevy's #import, or a @workgroup_size built from override constants, can't be reflected; use Workgroups::exact for those passes.
See examples/particles/ for a complete simulation: gravity and collisions computed on the GPU, drawn by the fragment shader.
Using rust-gpu shaders
Fragment and compute shaders can be written in Rust with rust-gpu 0.10.0 instead of WGSL.
The easiest way to start is bevy-shader-plugin-rust-template. Clone it and cargo run. It's a minimal app with the nightly toolchain and a build.rs already set up, so shaders are recompiled automatically whenever they change.
To use rust-gpu shaders in your own project, enable the spirv feature. It lets Bevy load .spv shaders and lets Workgroups read workgroup sizes from them:
= { = "0.2", = ["spirv"] }
Point the plugins at a .spv and name entry points after their Rust functions. The fragment entry point only needs naming if the module has more than one entry point, which it does whenever it also contains compute passes:
new.with_entry_point
new.pass
In the shader, descriptor_set is the group index and binding is the binding index, so they line up with the registration functions above:
A buffer's access mode comes from its parameter type: & is read and &mut is read_write. As with WGSL, every entry point that uses a binding must match how it was registered. GPU buffers and read_write storage buffers must be taken as &mut everywhere, even in entry points that only read them.
Not supported on web — leave the feature off for wasm builds.
Buffer layout
Stick to Vec2, Vec4, f32, u32 and arrays of them. Avoid Vec3. Keep arrays out of uniform buffers unless the element is 16 bytes — use a storage buffer instead. Mark shared structs #[repr(C)].
Examples
Each example has its app (CPU) and shader (GPU) code in both WGSL and Rust, and a README with a screenshot of what it should look like. They're reference code to copy into a project, not runnable from this repository. Each README explains where the files go.
examples/solar_system/: orbiting planets drawn with all three CPU buffer types, plus a compute pass whose result is read back every frame.examples/particles/: a particle simulation run entirely in compute passes, with CPU readback (press R to respawn).
The Rust versions drop straight into bevy-shader-plugin-rust-template.