pebble-engine 3.0.0

A modular, ECS-style graphics/app framework for Rust.
Documentation
# Material/Compute Params (derive)

`#[derive(MaterialParams)]`/`#[derive(ComputeParams)]` (from `pebble-derive`, re-exported from `Material`/`Compute`'s own modules) turn a plain struct's fields into the `.texture(...)`/`.uniform_value(...)`/etc. chain from [Materials](./materials.md)/[Compute Pipelines](./compute-pipelines.md) — so the struct's shape *is* the bind group, instead of a chain you write and keep in sync with it by hand:

```rust,ignore
use pebble::graphics::pipeline::material::{Material, MaterialParams};

#[derive(MaterialParams)]
struct EnemyMaterialParams {
    #[uniform(0)]
    tint: Vec4,
    #[uniform(0)]
    emissive: f32,          // same index as `tint` → packed into one generated buffer together
    #[texture(1)]
    albedo: Handle<Texture>,
    #[sampler(2)]
    sampler: SamplerKind,
}

let mat = EnemyMaterialParams { tint: RED, emissive: 2.0, albedo, sampler: SamplerKind::LinearRepeat }
    .into_material(Material::standard(ENEMY_SHADER))
    .build_asset("enemy_red", &mut materials);
```

The derive generates one method, `into_material(self, base: Material, ...) -> Material` (`into_compute`/`Compute` for `#[derive(ComputeParams)]`) — it doesn't replace `Material`/`Compute`, it just writes the builder chain for you and hands back the same `Material` you'd have built by hand, ready for `.build_asset(...)`.

## Field attributes

`#[uniform(N)]`, `#[storage(N)]`, `#[texture(N)]`, `#[texture_array(N)]`, `#[cubemap(N)]`, `#[sampler(N)]` — every field needs exactly one. `N` is the real WGSL `@binding(N)` index, same as `.with_entry_at`'s.

**Grouping.** Several `#[uniform(N)]`/`#[storage(N)]` fields sharing the same `N` pack into one generated buffer together (named after the first field in the group) — matching how a WGSL `uniform`/`storage` block is one binding no matter how many members it has. Every other kind needs a binding to itself; two fields sharing a `#[texture(N)]`/etc. index is a compile error.

**Type checking.** `#[texture(N)]`/`#[texture_array(N)]`/`#[cubemap(N)]`/`#[sampler(N)]` fields are checked against the shape they're expected to be, on a best-effort basis: a field type that's recognizably wrong (`#[texture(1)] foo: Handle<Cubemap>`) is a clear compile error pointing at the field; anything not confidently recognized (a type alias, an unusual path) is silently left to rustc's own type error at the generated call site, same as if the check didn't exist. `#[uniform]`/`#[storage]` fields can be any type implementing `encase::ShaderType` (see [Materials](./materials.md#typed-uniforms-with-encase)) — there's no fixed shape to check there.

## Visibility

Defaults to `FRAGMENT` for `MaterialParams`, always exactly `COMPUTE` for `ComputeParams` (a compute bind group entry can't be anything else — `#[derive(ComputeParams)]` rejects any override attempt with a compile error). Override per field on a `MaterialParams` struct with a second attribute argument:

```rust,ignore
#[derive(MaterialParams)]
struct SkinnedMaterialParams {
    #[uniform(0, vertex)]
    joint_matrices: JointMatrices,
    #[texture(1, vertex_fragment)]
    displacement_map: Handle<Texture>,
    #[texture(2)]   // no override — stays FRAGMENT
    albedo: Handle<Texture>,
}
```

`vertex`, `fragment`, or `vertex_fragment`. Every field sharing a grouped `#[uniform(N)]`/`#[storage(N)]` index must agree on the same visibility (explicit or all-default) — a compile error otherwise.

## Optional textures

A `#[texture(N)]`/`#[texture_array(N)]`/`#[cubemap(N)]` field typed `Option<Handle<T>>` instead of `Handle<T>` binds a fallback texture when the value is `None` — the WGSL binding always exists regardless of whether a given instance has a value, so `into_material`/`into_compute` gains one extra `{field}_fallback: Handle<T>` parameter per optional field:

```rust,ignore
#[derive(MaterialParams)]
struct EnemyMaterialParams {
    #[texture(0)]
    albedo: Option<Handle<Texture>>,
}

let mat = EnemyMaterialParams { albedo: enemy.custom_skin }   // Option<Handle<Texture>>
    .into_material(Material::standard(SHADER), default_skin_texture)   // used only if albedo is None
    .build_asset("enemy", &mut materials);
```

## `#[layout(...)]`: a bind group beyond your own

Repeatable struct attribute, appends group 1 and up (beyond the struct's own group 0), in the order written:

- `#[layout("name")]` — always `GroupEntry::Global("name")` (see [Bind Groups and Layouts](./bind-groups.md#sharing-a-layout-across-pipelines)), no extra parameter.
- `#[layout(param)]` — the caller supplies the `GroupEntry` at the call site instead — any variant, including `GroupEntry::Layout(...)` for a standalone layout that was never registered in `GlobalLayoutPool`. `into_material`/`into_compute` gains one `GroupEntry`-typed parameter per `param` occurrence, named `extra_group_0`, `extra_group_1`, ... in declaration order among the `param` occurrences specifically (fixed `#[layout("name")]` entries don't consume a slot).

```rust,ignore
#[derive(MaterialParams)]
#[layout("day_night")]              // always the shared "day_night" layout
struct TerrainMaterialParams {
    #[texture(0)]
    albedo: Handle<Texture>,
}

#[derive(MaterialParams)]
#[layout(param)]                    // caller decides — could be Global or a one-off Layout
struct EnemyMaterialParams {
    #[texture(0)]
    albedo: Handle<Texture>,
}

let terrain = TerrainMaterialParams { albedo }.into_material(Material::standard(SHADER));
let enemy = EnemyMaterialParams { albedo }.into_material(Material::standard(SHADER), GroupEntry::Global("lighting"));
```

## Parameter order

On the generated method: `base`, then one `{field}_fallback` per optional-texture-kind field (ascending binding index), then one `extra_group_N` per `#[layout(param)]` (declaration order).

## `ComputeParams`

Identical shape, targeting `Compute`'s streamlined methods instead — `.into_compute(self, base: Compute, ...) -> Compute`, visibility always `COMPUTE`, `#[storage(N)]` defaults to read-write (a compute pass binding a storage buffer usually means to write it — use `.with_entry`/`.with_storage` by hand for a read-only one):

```rust,ignore
use pebble::graphics::pipeline::compute::{Compute, ComputeParams};

#[derive(ComputeParams)]
struct BlurParams {
    #[uniform(0)]
    radius: f32,
    #[texture(1)]
    src: Handle<Texture>,
}
```

Note that, unlike `.storage(name, Vec<u8>)` on `Compute` itself, a `#[storage(N)]`/`#[uniform(N)]` *field* always goes through the typed `encase` path (same as `.storage_value`/`.uniform_value`) — its type needs to implement `encase::ShaderType`, not be raw bytes.