bevy-shader-plugin 0.2.2

A Bevy plugin for streamlining fullscreen fragment and compute shader development.
Documentation
# 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:
```rust
commands.spawn((Camera3d::default(), Msaa::Off));
```

## Compatibility


| `bevy-shader-plugin` | Bevy | rust-gpu |
|---|---|---|
| 0.2 | 0.19 | 0.10.0 |
| 0.1\* | 0.18 | — |

\* Version 0.1 is a separate crate, published as [`bevy-fragment-shader-plugin`](https://crates.io/crates/bevy-fragment-shader-plugin).

## Setup


Add both crates to your `Cargo.toml`:

```toml
[dependencies]
bevy = "0.19"
bevy-shader-plugin = "0.2"
```

The plugin turns on only the Bevy features it needs itself (rendering, the core pipeline and logging), so it doesn't pull in the rest of Bevy. Your app's own `bevy` dependency decides everything else, such as windowing and input. Bevy's default features cover everything the examples use.

Import everything you need in two lines:

```rust
use bevy::prelude::*;
use bevy_shader_plugin::prelude::*;
```

## Quick start


### 1. Define your Uniform struct


```rust
#[derive(Resource, ShaderType, Clone, Default)]

struct MyUniform {
    resolution: Vec2,
    time: f32,
}
```

Padding to 16 bytes is handled automatically — no `_pad` fields needed.

### 2. Register the plugin and buffer


```rust
App::new()
    .add_plugins(DefaultPlugins)
    .add_plugins(FullscreenFragmentPlugin::new("shaders/my_shader.wgsl"))
    .register_uniform_buffer::<MyUniform>(0, 0)
    .init_resource::<MyUniform>()
    // ...
```

Any changes to the `MyUniform` resource will be reflected in the associated buffer on the next frame.

### 3. Write the shader


```wgsl
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/`](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](#compute-shaders).

Shaders can also be written in Rust instead of WGSL — see [Using rust-gpu shaders](#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 (`read` or `read_write`) in every shader that uses it. wgpu rejects a shader that declares `read` for a binding registered as `read_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_index` values under the same `group_index`) over giving each buffer its own group. Native backends usually allow more, but browsers and some mobile GPUs enforce the limit.

### Uniform buffer


```rust
app.register_uniform_buffer::<MyUniform>(0, 0);
```

The resource is extracted from the main world and uploaded whenever it changes. WGSL: `var<uniform>`.

### Storage buffer


```rust
app.register_storage_buffer::<MyData>(1, 0, false); // 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](#gpu-buffer) instead.

Multiple bindings sharing the same `group_index` are packed into one bind group:

```rust
app.register_storage_buffer::<Red>(1, 0, false)
   .register_storage_buffer::<Green>(1, 1, false)
   .register_storage_buffer::<Blue>(1, 2, false);
```

### 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`:

```rust
struct Colors;
//                          <Tag, Type, Capacity>
app.register_array_buffer::<Colors, Vec4, 64>(1, 0, false);
```

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:

```rust
fn animate(mut changes: ResMut<ArrayBufferChanges<Colors>>, time: Res<Time>) {
    changes.set(0, Vec4::splat(time.elapsed_secs().sin())); // single element
    changes.set_many([(1, Vec4::ONE), (2, Vec4::ZERO)]);    // multiple elements
    changes.set_all(Vec4::ZERO);                            // every element
}
```

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`:

```rust
#[derive(ShaderType, Clone, Copy, Default)]

struct Particle { pos: Vec2, vel: Vec2 }
struct Particles;
//                        <Tag, Type>
app.register_gpu_buffer::<Particles, Particle>(1, 0, 1024); // 1024 elements
```

```wgsl
@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`](#dispatch-sizes)).

### 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:

```rust
app.register_gpu_buffer::<Particles, Particle>(1, 0, 1024)
   .read_back_gpu_buffer::<Particles, Particle>(ReadbackMode::OnRequest);     // → GpuReadback<Particles, Particle>

app.register_array_buffer::<Colors, Vec4, 64>(2, 0, true)
   .read_back_array_buffer::<Colors, Vec4>(ReadbackMode::OnRequest);         // → GpuReadback<Colors, Vec4>

app.register_storage_buffer::<Stats>(1, 1, true)
   .read_back_storage_buffer::<Stats>(ReadbackMode::OnRequest);              // → 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:

```rust
fn request(readback: Res<GpuReadback<Particles, Particle>>) {
    readback.request();
}

fn print(readback: Res<GpuReadback<Particles, Particle>>) {
    if !readback.is_changed() { return; }
    let Some(particles) = readback.latest() else { return }; // &[Particle]
    info!("first particle at {}", particles[0].pos);
}
```

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 `EveryFrame` on 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.

```rust
struct Particles;

App::new()
    .add_plugins(DefaultPlugins)
    .add_plugins((
        ComputeShaderPlugin::new("shaders/particles.wgsl")
            .init_pass("init", Workgroups::over::<Particles>())    // once, when ready
            .pass("update", Workgroups::over::<Particles>()),      // every frame
        FullscreenFragmentPlugin::new("shaders/particles.wgsl"),
    ))
    .register_gpu_buffer::<Particles, Particle>(0, 0, 1024)
    .add_systems(Startup, |mut commands: Commands| {
        commands.spawn((Camera3d::default(), Msaa::Off));
    })
    .run();
```

```wgsl
@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; }
    // ...
}
```

- `pass` runs 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_pass` runs once, on the first frame everything is ready. Every-frame passes wait until all init passes have run. Run one again with `ComputePasses`:

  ```rust
  fn reset(keys: Res<ButtonInput<KeyCode>>, mut passes: ResMut<ComputePasses>) {
      if keys.just_pressed(KeyCode::KeyR) {
          passes.rerun("init");
      }
  }
  ```
- Only one `ComputeShaderPlugin` is supported per app.
- To order your own render systems around this crate's GPU work, use the `ShaderPassSystems` sets (`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/`](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](https://github.com/Rust-GPU/rust-gpu) 0.10.0 instead of WGSL.

The easiest way to start is [bevy-shader-plugin-rust-template](https://github.com/EliDeCo/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:

```toml
bevy-shader-plugin = { version = "0.2", features = ["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:

```rust
FullscreenFragmentPlugin::new("shaders/my_shader.spv").with_entry_point("main_fs")
ComputeShaderPlugin::new("shaders/my_shader.spv").pass("update", Workgroups::over::<Particles>())
```

In the shader, `descriptor_set` is the group index and `binding` is the binding index, so they line up with the registration functions above:

```rust
#[spirv(compute(threads(64)))]

pub fn update(
    #[spirv(global_invocation_id)] id: UVec3,
    #[spirv(uniform, descriptor_set = 0, binding = 0)] params: &SimParams,
    #[spirv(storage_buffer, descriptor_set = 1, binding = 0)] particles: &mut [Particle],
) { /* ... */ }

#[spirv(fragment)]

pub fn main_fs(
    #[spirv(frag_coord)] frag_coord: Vec4,
    #[spirv(storage_buffer, descriptor_set = 1, binding = 0)] particles: &mut [Particle],
    output: &mut Vec4,
) { /* ... */ }
```

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/`]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/`]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](https://github.com/EliDeCo/bevy-shader-plugin-rust-template).