awsm-renderer 0.4.0

awsm-renderer
Documentation

awsm-renderer

WebGPU visibility-buffer deferred renderer for the AWSM engine. Library crate — applications drive it through the AwsmRenderer struct and its subsystems (lights, meshes, materials, shadows, etc).

Materials & specialization

There are two ways to get a material onto a mesh:

  1. First-party materialsPbrMaterial, ToonMaterial, UnlitMaterial, FlipBookMaterial. You just set fields (base color, textures, KHR extensions, …); no shader authoring. This is what most apps and the glTF loader use.
  2. Custom materials — you register your own WGSL shading body at runtime and reference it from Material::Custom. See the quick start below.

Specialize-only (compile-time feature gating)

The renderer is specialize-only: every shader is gated at compile time (Askama {% if pbr_features.X %}) to exactly the features the material uses. There is no "uber" shader — a material with no normal map compiles no normal-map code, a scene with no clearcoat compiles no clearcoat code, etc. The only runtime branches are logically necessary ones (lighting geometry, light loops).

Concretely:

  • PBR specializes per feature-set — the set of present texture slots + KHR extensions (PbrFeatures). Each distinct feature-set gets its own compiled pipeline (a "bucket"); two PBR materials with the same feature-set share one pipeline. Transparent PBR specializes the same way (each transparent material compiles its own pipeline), as does the MSAA edge-resolve pass.
  • Toon / Unlit / FlipBook render as a single canonical bucket each (their shaders have no feature-gateable paths today).
  • Custom materials get one bucket per registration (no feature-set deduping).

This is automatic — feature-sets are derived from the material and resolved to pipelines during the render preamble. You don't configure it and there is no on/off switch; specialization is unconditional.

Prep pass (slim shading)

Specialization keeps each material to the code it uses; the prep pass (render_passes/material_prep/) keeps that code slim by hoisting the material-independent per-pixel work out of it. One compute pass runs before shading and materializes interpolated UV0 / vertex-color and per-light shadow visibility into buffers; the per-material kernel reads them instead of recomputing — and the heavy shadow-sampling code drops out of every module. It's unconditional (no flag, no prep-vs-non-prep variant); the opaque path always preps. (Transparent is forward — no visibility buffer to read prep from — so it recomputes inline; a different model, not a flag.)

What gets prepped is a deliberate cost trade-off — prep the expensive common work, re-derive the trivially-cheap work. Shadows are prepped (the sampling block is expensive and caching it evicts ~50 KB from the MSAA module); world-position and MSAA edge-sample UV/vertex-color are recomputed (cheaper than a buffer's write + read + VRAM). None of this is visible to material authors — you call input.world_position / material_uv(input, set) and the accessor picks the source. Full rationale in docs/SHADER_GUIDELINES.md.

Bucket cap

The number of co-resident buckets (distinct first-party feature-sets + custom registrations) is bounded by a runtime-configurable cap. It defaults to 32 and can be raised up to 65534 on the builder:

use awsm_renderer::dynamic_materials::BucketConfig;

let renderer = AwsmRendererBuilder::new(gpu)
    .with_bucket_config(BucketConfig { max_bucket_entries: 1024 })
    // … other builder options …
    .build()
    .await?;

The cap sizes nothing per frame: every GPU encoding width is a pure function of the live bucket count, not the configured cap. The classify pass uses ceil(live / 32) u32 tile-mask words, and the MSAA edge pass packs an 8-bit bucket id per sample while the live count fits in 254, widening to 16 bits automatically past that (up to the 65534 ceiling). So a typical (< 32 material) scene pays exactly what it did before, and raising the cap costs nothing until you actually register more materials.

Exceeding the configured cap is a hard error (AwsmDynamicMaterialError::BucketCapExceeded), on both the custom-material registration path and the first-party render-loop reconcile — there is no silent fallback to a wrong/generic shader. Raise max_bucket_entries to admit more.

Dynamic materials quick start

Register your own WGSL fragment at runtime, get back a MaterialShaderId, and reference it from a Material::Custom. The renderer compiles the per-pass pipelines asynchronously through its pipeline-readiness scheduler; meshes that reference a not-yet-ready material are silently skipped for the frames in which the compile is still in flight, then "pop in" on the frame after Ready. No synchronous wait is required for steady-state use; tests / cold-boot flows can opt into wait_for_pipelines_ready to drain the scheduler.

use awsm_renderer::{
    AwsmRenderer,
    dynamic_materials::registration::MaterialRegistration,
    materials::Material,
};
use awsm_scene_schema::dynamic_material::MaterialDefinition;
use awsm_scene_schema::material::MaterialAlphaMode;

// 1. Build the registration. `definition` carries the public param
//    surface (uniforms / textures / buffers) per the schema crate;
//    `wgsl_fragment` is the author's shading-stage body, with the
//    `input.material.<field>` accessors generated from `definition`.
let definition = MaterialDefinition {
    name: "scanline".into(),
    version: 1,
    alpha_mode: MaterialAlphaMode::Opaque,
    double_sided: false,
    uniforms: vec![/* … see docs/dynamic-materials/contract-opaque.md */],
    textures: vec![],
    buffers: vec![],
};
let wgsl_fragment = std::fs::read_to_string("assets/materials/scanline/shader.wgsl")?;
let registration = MaterialRegistration::new(definition, wgsl_fragment);

// 2. Register. Returns immediately — compile is queued and will
//    transition Pending → Ready on a later render-frame preamble.
let shader_id = renderer.register_material(registration)?;

// 3. Build a Material::Custom that points at the registered shader.
//    `per_instance` carries the author-defined uniforms (color tints,
//    floats, etc.) keyed by the `definition.uniforms[*].name` declared
//    above.
let material = Material::Custom(/* see crates/renderer/examples/dynamic_material.rs */);
let material_key = renderer.add_material(material)?;

// 4. Add a mesh referencing that material. The mesh enters the
//    scene immediately; the first 1–N frames render without it
//    (until the pipeline-readiness scheduler resolves), then it
//    appears on the frame after Ready.
let mesh_key = renderer.add_mesh(/**/)?;

// 5. Render normally. No `prewarm` await needed.
renderer.render(None)?;

// Optional: for cold-boot / test flows where you want the scene to
// be paint-complete before the first render, drain the scheduler:
renderer.wait_for_pipelines_ready().await?;

Registration is transactional

register_material (above) is a single-item wrapper around register_materials(Vec<MaterialRegistration>), which is all-or-nothing: the whole batch is validated against the final bucket layout before any side effects, so if one entry fails — duplicate name, a reserved field name, a WGSL compile error, or exceeding the bucket cap — the entire batch is rejected with the relevant AwsmDynamicMaterialError and nothing is registered. Fix the offending entry and re-submit. Re-registering an identical (name, layout, wgsl) is idempotent (returns the existing shader_id).

The full author-facing WGSL contract — what symbols are in scope, what OpaqueShadingInput / OpaqueShadingOutput look like, how to read input.material.<field>, how to sample the texture pool, and how to use buffer slots via the extras pool — lives in docs/dynamic-materials/contract-opaque.md and docs/dynamic-materials/contract-transparent.md.

A fully worked end-to-end example, including buffer slots and a per-instance override, is at crates/renderer/examples/dynamic_material.rs.

Shadows quick start

Shadows are off-by-default per light. To turn them on, register LightShadowParams against an inserted LightKey. The descriptor buffer + cascade fit + sampling all light up automatically.

use awsm_renderer::{
    lights::Light,
    shadows::{LightShadowParams, LightShadowHardness, MeshShadowFlags},
};

// 1. Insert a directional light. Pass `None` for shadow params (no
//    shadow); pass `Some(LightShadowParams { cast: true, .. })` to
//    enable shadows in the same call.
let sun = renderer.insert_light(
    Light::Directional {
        color: [1.0, 0.95, 0.9],
        intensity: 3.0,
        direction: [0.3, -1.0, 0.3],
    },
    None,
)?;

// 2. Enable shadows on it. `cast: false` keeps the light but skips
//    its shadow pass.
renderer.set_light_shadow_params(sun, LightShadowParams {
    cast: true,
    hardness: LightShadowHardness::Soft,
    cascade_count: 4,
    resolution: 2048,
    ..LightShadowParams::default()
})?;

// 3. (Optional) Override per-mesh defaults. Opaque meshes
//    automatically cast + receive; transparent / sprite / particle
//    meshes default to neither.
renderer.set_mesh_shadow_flags(some_mesh_key, MeshShadowFlags {
    cast: false,
    receive: true,
})?;

// 4. Render. The render graph short-circuits shadow generation when
//    `renderer.shadows.any_active()` is `false`.
renderer.render(None)?;

Filter modes

LightShadowHardness chooses the sample kernel:

  • Hard — 1-tap textureSampleCompareLevel. Crispest, cheapest.
  • Soft — fixed 3×3 PCF.
  • Pcss — Percentage-Closer Soft Shadows (blocker search + variable- kernel PCF). Contact-hardening; reserve for hero lights.

Cascaded directional shadows

Directional lights use up to 4 cascades, split via PSSM with a tunable cascade_split_lambda. Each cascade halves the previous cascade's resolution (per-cascade shadow LOD). The far cascade can be temporally throttled via FarCascadeUpdateRate to skip its render pass every 2/4/8 frames.

Point + spot light shadows

Light::Point uses a texture_depth_cube_array slot pool; capacity defaults to 8 cube slots (configurable via ShadowsConfig::max_point_shadows). Light::Spot packs a perspective shadow map into the same 2D atlas as directional cascades.

Screen-space contact shadows

A short screen-space ray-march refines the directional shadow term to catch micro-occlusion the cascade resolution misses (gaps under feet, hair, etc). Global toggle: ShadowsConfig::sscs_enabled.

Schema → runtime conversion

scene_schema::LightShadowConfig and MeshShadowConfig are the on-disk shapes; the scene-editor's renderer_bridge::node_sync is the only place in the codebase that converts them to the renderer's runtime LightShadowParams / MeshShadowFlags. A non-editor consumer (game runtime, model-tests frontend, standalone tool) skips the schema crate entirely and constructs LightShadowParams directly.