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:
- First-party materials —
PbrMaterial,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. - 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 BucketConfig;
let renderer = new
.with_bucket_config
// … 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 ;
use MaterialDefinition;
use 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 ;
let wgsl_fragment = read_to_string?;
let registration = new;
// 2. Register. Returns immediately — compile is queued and will
// transition Pending → Ready on a later render-frame preamble.
let shader_id = renderer.register_material?;
// 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 = Custom;
let material_key = renderer.add_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?;
// 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 ;
// 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?;
// 2. Enable shadows on it. `cast: false` keeps the light but skips
// its shadow pass.
renderer.set_light_shadow_params?;
// 3. (Optional) Override per-mesh defaults. Opaque meshes
// automatically cast + receive; transparent / sprite / particle
// meshes default to neither.
renderer.set_mesh_shadow_flags?;
// 4. Render. The render graph short-circuits shadow generation when
// `renderer.shadows.any_active()` is `false`.
renderer.render?;
Filter modes
LightShadowHardness chooses the sample kernel:
Hard— 1-taptextureSampleCompareLevel. 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.