Materials & Hook Shaders
Awaken has two material tiers: a preset PBR surface driven by the MeshRenderer fields, and authored hook-based materials that splice small WGSL snippets into the standard shader without you rewriting the whole thing.
Tier 1 - preset PBR (MeshRenderer)
Most objects need no material asset at all. A MeshRenderer component (packages/render/src/components.ts) carries the surface parameters directly, and they are packed straight into the per-object instance record:
| Field | Default | Meaning |
|---|---|---|
mesh | "cube" | Mesh asset id |
color | [0.8, 0.8, 0.85] | Base albedo (linear RGB) |
metallic | 0.0 | 0 = dielectric, 1 = metal |
roughness | 0.6 | 0 = mirror-smooth, 1 = fully rough |
texture | "" | Albedo texture asset id (empty = untextured white) |
opacity | 1.0 | < 1 → alpha-blended (glass) |
transparent | false | Force the blended pass even at opacity 1 (texture-alpha) |
These feed the Cook-Torrance model on PBR & Lighting. When a texture is bound, its RGB multiplies the base colour and per-vertex colour is ignored (so vertex-colour wind/AO data from imported FBX doesn't tint a real albedo); untextured meshes use the base colour times any per-vertex colour. Texture-alpha < 0.5 fragments are discarded, giving free alpha-cutout foliage.
This tier is edited entirely in the Inspector - no shader authoring.
Tier 2 - hook-based Material assets
When you need behaviour the presets can't express - vertex displacement (wind, water), a procedural surface colour, normal perturbation, or custom clipping - you author a Material asset. Rather than replace the shader, a material provides small WGSL snippets that are spliced into the standard mesh shader at fixed points. You inherit all the lighting, shadows, fog, and tonemapping for free and only override what you want.
The four hooks
The standard shader (MESH_WGSL) contains four //#HOOK <name> markers. A material supplies a body for any subset. Listed here in execution order - the order they appear in the composed shader, and the order the Shader Editor lists them:
| Hook | Stage | Mutates | What it can do |
|---|---|---|---|
vertexPosition | vertex | world (vec4<f32>, world space) | Displace the vertex - wind sway, waves. pos / normal / uv are read-only object-space inputs; the model matrix has already been applied by the time the hook runs, so what you move is world. |
discard | fragment | - (early exit) | The first statement of fs: if (…) { discard; } for a custom cutout. It runs before the albedo texture is sampled, so texel is not in scope - only in.*, params.* and globals.*. |
surfaceNormal | fragment | N (vec3<f32>, world space, normalized) | Perturb the shading normal - bumps, ripples, wave gradients. |
surfaceColor | fragment | albedo (vec3<f32>, linear rgb) and outAlpha (f32) | Override or modulate the surface colour before lighting, and drive the material's own transparency. Runs after the texture sample, so texel is in scope here. |
You do not need a discard hook for texture cutout: the template already discards fragments with texel.a < 0.5 for every material, hooked or not.
toMaterial() (packages/render/src/shader.ts) derives the material-enabled template from MESH_WGSL - it adds the globals / materialParams bindings, exposes params to both stages, makes N and albedo mutable, and introduces outAlpha (defaulting to the instance opacity, so a material with no surfaceColor hook behaves identically). composeShader() (packages/render/src/material.ts) then replaces each marker with its hook body (or nothing). A material with no hooks yields a shader byte-for-byte identical to the default - so an empty material has zero cost and zero visual difference.
composeShaderMapped() is the same splice, but it also reports which composed line range each hook body landed on. That span map is what lets the Shader Editor route the WGSL compiler's composed-line diagnostics back to a squiggle on the right line of the right hook.
Parameters and the 16-float budget
A material declares typed params (float, vec2/3/4, color, bool, texture). Non-texture params are laid out into a generated MaterialParams WGSL struct, and each object gets its own 64-byte (16-float) slot in a per-object storage buffer (MATERIAL_PARAM_FLOATS = 16). Params marked per-object can be overridden per instance via a MeshMaterial component's overrides; shared params always take the material default.
The layout walk (slotLayout) is the single source of truth used by both the struct generator and the packer, so their field offsets can never drift - trailing padding makes every slot exactly 16 floats so materialParams[k] reads the right bytes for object k. If a schema's params exceed the 16-float budget, packing throws; the renderer catches that and leaves the object's slot zeroed rather than killing the frame, and materialParamFloats() lets the Shader Editor warn you before you hit it.
Per-object parameter values
MeshMaterial.overrides is a declared, authorable, persisted component field (type: "materialParams" in packages/core/src/serialize.ts), edited from the Inspector for whichever params the material marks perObject. One material can therefore serve many objects with different values - one Water asset, a different tint and wave height per pond - instead of duplicating the material.
The map is sparse: a key exists only where that object diverges from the asset default, and packMaterialParams falls back to p.default for every absent key. So the Inspector's reset deletes the key rather than writing the default back - the object keeps tracking the material asset, and retuning the default later moves every un-overridden object with it. Serialization sanitises the map to plain number | number[] | boolean on both save and load, and ship compaction drops an empty map entirely (the common case).
Alongside params, a Globals { time, anim } uniform is bound: time is a material-animation clock and anim is 0 in the editor's edit mode (so time-based effects rest at their default pose) and 1 while playing or in a shipped game (so they animate). Multiply time-based displacement by anim and your wind will be still in-editor and moving on Play.
A scenePos texture is also bound - the opaque scene's world-position prepass - so a depth-aware material can sample the geometry behind it (shoreline foam, depth fade). The renderer only runs that prepass when a material's surfaceColor hook actually mentions scenePos; otherwise a 1×1 dummy is bound and nothing is paid for.
Hooks reach the main pass only
Both MaterialCache instances (opaque and transparent) are built over MESH_MATERIAL_WGSL - the material-enabled mesh template. Hooks are never composed into the shadow pass, the static-batch pass, or the SSAO world-position prepass; those shaders carry the //#HOOK markers only as inert comments.
Two consequences follow directly:
- A vertex-displacing material must have
castShadowturned off. The shadow pass draws the mesh flat (novertexPositionhook) while the main pass draws it displaced, so the surface self-shadows against its own undisplaced silhouette - flickering shadow acne across the whole surface. Untick Cast Shadow on the object'sMeshRendererand the problem disappears; the surface still receives shadows from the geometry around it. The scene importers already do this for surfaces they flag as animated (a Unity/Godot water plane arrives withcastShadow: false). - A material object is never static-batched. The static merge path is skipped for any object with a resolved material, so a hook material and static batching are mutually exclusive on the same object.
Assignment, dedup, async compile, and fallback
- Assign a material to an object with a MeshMaterial component (
material= asset id, optionaloverrides). - Composed pipelines are deduplicated by composed-shader hash - two objects using the same material share one pipeline. Two variants exist per material (opaque and transparent) mirroring the default mesh pipelines.
- Pipelines compile asynchronously (
createRenderPipelineAsync) so a compile never stalls the frame. - If the authored WGSL fails to compile, the material cache falls back to the matching default pipeline - a bad hook produces the plain PBR look, never a crash or a white screen.
Material data rides in bind group 1 (bindings 3 and 4) rather than a fifth bind group, so material pipelines stay within WebGPU's 4-bind-group default and remain byte-for-byte compatible with the default pipeline layout.
📸 Screenshot - save as img/render-material-hooks.png
The Shader Editor with a wind shader: the vertexPosition hook body in its WGSL editor, a couple of exposed params (strength, frequency), and the Viewport behind it showing the foliage swaying.
Where materials come from
The engine ships none. There is no built-in material registry: every hook material in a project is either authored in the Shader Editor or imported from a .awakenmat file, and the runtime player seeds nothing either - a material only reaches a shipped game because the export carried the asset.
Two ready-made materials exist as starter content (starter-content/materials/), imported like any other asset via Assets → Import or drag-drop:
| File | Id | What it does |
|---|---|---|
Water.awakenmat | water | Stylized water: summed-sine swell displaces the vertices, the analytic wave gradient re-normals each fragment so sun glints ride the waves, and fresnel blends a deep→shallow body colour with a sky reflection plus depth-based shoreline foam and opacity fade. |
Wind.awakenmat | builtin:wind (a legacy id, kept so existing projects' references keep resolving) | Vegetation sway, with a per-object phase so a row of identical plants doesn't wave in lock-step. |
Importing a scene never attaches one - importers must not reference optional content - so an imported water plane arrives plainly shaded until you pick the material on its MeshMaterial yourself. (The importer does flag such planes so they don't cast shadows or get static-batched, which keeps them ready for it.)
Which tier should I use?
- Reach for MeshRenderer presets for essentially all normal opaque and glass surfaces.
- Author a Material only when you need vertex motion, a procedural/animated surface, normal detail, or custom clipping.
See also
- Shader Editor - authoring hooks, params, and compiling them
- Shader Graph - the visual node editor and full node reference
- PBR & Lighting - the lighting the hooks feed into
- Mesh Format - the instance record that carries preset material fields
- Inspector - editing
MeshRendererandMeshMaterial - Components API - the
MeshRenderer/MeshMaterial/Materialshapes