Give an NPC a face
Goal
The spawn-character / set-expression / look-at commands a game already emits stop being recorded no-ops and start moving a real GNM splat head: the NPC smiles when the dialogue system says smile, and turns to look at whoever it is talking to. Nothing in the guest changes — this is entirely a host decision.
Files you will edit
src/main.tssrc/assets.json
Steps
Bake a rig pack and serve it. A GNM head is a
.aosrigpack: a neutral mesh, an expression basis, a skeleton and the skinning. It is licensed source data, so no template ships one — bake it and drop it inpublic/, which Vite copies verbatim:shnpm run gen:pack -w examples/character-showcase mkdir -p templates/third-person/public/generated cp examples/character-showcase/public/generated/myra_head.e64.aosrig \ templates/third-person/public/generated/gen-gnm-pack.mjswrites two packs. Thee64one keeps the model's own 64-coefficient reduced view and is 7.8 MB against the full pack's 42 MB; Preview a rig without decoders has what the truncation costs in millimetres. Addpublic/generated/to.gitignore: a 7.8 MB binary does not belong in a scaffold.Point the manifest entry at it. A
characterentry'srigblock says which backend drives the head and, forgnm, which file holds it.packis resolved like any othersrc— absolute from the site root here, or relative to the entry's own directory. Insrc/assets.json:json{ "id": "char.guide", "type": "character", "src": "characters/guide/scene.json", "tags": ["character"], "rig": { "backend": "gnm", "pack": "/generated/myra_head.e64.aosrig" } }Give the adapter a character bridge. The rig stack — the backend, the animator, the animated splat — is the optional half of
@aosengine/wasm-host, so a game with no characters never downloads it. Import it dynamically and hand it tocreateEngineAdapter. Insrc/main.ts, where the adapter is built:tsimport type { CharacterBridge } from '@aosengine/wasm-host/characters'; const { createCharacterBridge } = await import('@aosengine/wasm-host/characters'); const characters: CharacterBridge = createCharacterBridge({ engine, renderer: engine.renderer, scene: engine.scene, // An entity's transform is its physics centre; this is where its neck is. headOffset: [0, 0.78, 0], }); const adapter = createEngineAdapter(engine, { modules, characters });That is the whole wiring. The bridge resolves the bundle, fetches the pack, builds an
AnimatedSplatand aGnmRigBackendunder the entity's own object, keeps anAnimatorper character, and drops the placeholder capsule the moment the head is on screen. On the WebGL fallback it warns once and leaves every capsule where it was, so a player without WebGPU still gets a playable game rather than an exception.
Verify
sh
npm run dev -w templates/third-personWalk up to the guide. The placeholder capsule is gone and in its place, at head height, is a head-shaped cloud of points tinted by skinning joint. Two things about that are deliberate: it is a head rig, so there is no body to draw — GNM bakes a head and a neck, not a skeleton with limbs — and it is the debug renderer, one gaussian per rig vertex, because the geometry and appearance decoders for GNM topology do not exist yet. Press E: the face moves on the lines the script marks smile, and the head turns towards the hero and eases back when the conversation ends.
Nothing on screen means the pack did not load. The bridge never throws — look in the console for the GNM rig for "char.guide" … did not load, which carries the URL it asked for and the reason. A warning about no GNM rig pack instead means step 2 did not take effect.
The template ships this as ?gnm=1 rather than as the default, so npm run dev -w templates/third-person -- --open '/?gnm=1' is the same thing without editing anything, and tests/e2e/third-person.spec.ts asserts it — skipping itself when there is no pack on the machine.
See also
- Characters — the pipeline, and the host integration
- Add NPC dialogue — where the expressions come from
- Preview a rig without decoders — the same rig, no game
- Animation — head aim, gaze and the expression layer