Armour pipeline
How a weapon, shield, helmet, boot or pair of wings goes from an idea to a
*.compressed.glb the engine attaches to a character. Meshy generates raw shape;
Blender does the fitting and skinning; a few npm scripts turn the Blender export
into a shippable asset and prove it.
Nothing here runs in the game. Meshy is an offline authoring tool: the app never calls
it and no key ships. Only the finished *.compressed.glb files in
public/models/characters/armor/ are committed.
What this pipeline feeds
Section titled “What this pipeline feeds”The GLBs this pipeline produces live in public/models/characters/armor/ and are
declared on an ItemDef through equip: { shape: 'mesh', meshUrl } in
src/game/items/item-defs.ts. Read this before starting a new piece, because the
shipped player and NPC bodies are shared Astra characters, and those draw most worn
clothing from their own fitted gear, not from these GLBs:
- Worn body garments on the shipped bodies (tunic, leggings, boots, gloves and the
rest) come from each body’s published, offline-fitted gear catalogue. That is a
separate pipeline: see character-equipment.md (not published) and
character-clothing-pipeline.md (not published).
characterEquipmentFromInventory(src/character/character-equipment-inventory.ts) keeps only items the body has a fitted visual for and silently skips the rest. - What this pipeline draws in the world on a shared body
(
_syncPlayerEquipmentinsrc/gpu/webgpu-renderer.ts): wings, and amainhandoroffhanditem the body has no fitted visual for (shields, and weapons moved to the other hand). - Dropped items. The mesh-prop catalogue (
src/dynamics/mesh-prop-catalog.ts) uses these GLBs as the ground model for a dropped weapon, helm, belt and so on. - The character preview (
src/character/preview/preview-equipment.ts) bakes rigid pieces and wings from these GLBs with the same fit rows.
So a new wing design, shield or hand-held item goes through this document end to end. A new worn garment for the shipped bodies does not; it goes through the clothing pipeline.
0. One-time setup
Section titled “0. One-time setup”Meshy key. Never write the key into a tracked file, including any MCP server config
you add. The scripts read MESHY_API_KEY from the environment or, failing that, from a
gitignored .env at the repo root (scripts/lib/load-env.mjs; a real environment
variable wins):
MESHY_API_KEY=msy_...If you set it as a Windows user environment variable instead, fully quit VS Code
(every window, and check Task Manager for a stray Code.exe) before relaunching: a
running process never inherits a newly set user variable.
npm run check:meshyWhy that check exists: if you drive Meshy through its MCP server
(@meshy-ai/meshy-mcp-server) and the server fails at startup, Claude Code drops it, so
an unset key, a wrong key and a startup network blip all look the same: the
mcp__meshy__* tools simply do not exist. check:meshy (scripts/check-meshy-mcp.mjs)
spawns that server with cmd /c npx -y @meshy-ai/meshy-mcp-server (so it is
Windows-only as written), speaks MCP to it and tells those cases apart. It prints only
the key’s length and first four characters, never the key.
Blender. 4.1. scripts/build-gear.mjs looks for it at the default Windows install
path; set BLENDER_EXE to point elsewhere. For interactive work through Claude, the
BlenderMCP add-on must be running inside an open Blender.
An uncompressed authoring rig. Every armour piece is fitted in a reference rig’s
armature space. That rig is not part of the product: the author’s copy was auto-rigged
with Mixamo and lives only in a private .gear-work workbench, at
.gear-work/astronaut.raw.glb. build:gear stops with an explanation when it is
missing; point PLANETS_REF_RIG at an uncompressed rig of your own to author against
it instead (Blender cannot read EXT_meshopt_compression). The recipes in
scripts/build-gear.mjs name that rig’s bones with a mixamorig: prefix, so every
socket and limb name below is written that way; with your own rig, use its names.
Nothing of the authoring rig ships. A rigid piece is rebased into socket-local space before it is committed (see step 3), and the gates refuse Mixamo rig data in every file. A weighted piece fitted on that rig cannot be rebased, so it cannot ship either: a weighted piece needs a skeleton of your own.
1. Generate the shape (Meshy)
Section titled “1. Generate the shape (Meshy)”Either ask Claude to use the mcp__meshy__* tools, or run it headless:
npm run meshy:gen -- "a medieval iron gauntlet, riveted steel plates, game asset" --name gauntlet --polys 4000Output goes to art-input/meshy/<name>/ (gitignored): the raw GLB plus the task JSON.
The preview gives shape only; add --refine for the textured pass once the shape looks
right, or --from <previewTaskId> --name <name> to refine an existing preview without
paying for a new one. --polys defaults to 4000 and the script forces
topology: 'triangle', because the engine’s parser wants indexed triangles.
Prompt notes that matter:
- Ask for the isolated object. “A knight wearing a helmet” gives you a knight.
- Ask for a hollow or open interior on anything that goes over a body part, or there is nothing to put the head inside.
- Keep the polycount modest. A generated 300k-triangle boot would dwarf the body wearing it.
- Expect a request for one sub-piece of a set (a single pauldron, lame, sabaton or bracer) to come back as more than you asked for. Generate the set and cut it up in Blender, or model the piece from scratch.
Meshy’s own rig is not the reference skeleton. Anything it produces has to be re-skinned in Blender. That is step 2, and it is not optional.
The refine must be the PBR one. meshy:gen sends enable_pbr: true. A plain
refine bakes lighting into the base colour and crushes it towards white or black,
which is unusable because the engine reads the base colour only. --no-pbr exists as
an escape hatch; do not reach for it.
The PBR retexture returns several maps (base colour, metallic-roughness, normal,
emissive). fit-armor-piece.py keeps only the base colour and scales it down to
--tex pixels (default 512).
1b. Look at it, and measure the wearer, before placing anything
Section titled “1b. Look at it, and measure the wearer, before placing anything”fit-armor-piece.py places a piece by number, but only if you know two things it
cannot infer: which way the source is facing, and how big the body part it has to
cover actually is. Two headless probes answer them, and build-gear.mjs keeps the
answers in one file.
blender --background --python scripts/blender/preview-glb.py -- \ --in art-input/meshy/leather-gloves/leather-gloves.glb --out .gear-work/p.pngSix orthographic views, tiled into one contact sheet, labelled in Blender space (the
space --euler is typed in). --shading texture checks the albedo survived; the
default clay shading is what you want for shape, because dark leather and cloth render
as a black blob with their own texture. Add --ref .gear-work/astronaut.raw.glb --rest --views body to see the piece on the rig, standing up, and --focus LeftHand --span 0.35 to frame one joint (a mirrored pair spans both arms, so framing on its own
bounding box shows almost nothing). --pose renders against a posed rig, which is how
you catch a hood the head turns out of.
One render each is usually enough to find what a source really is. Generated sources can turn out to be a “pair of gloves” that is one long gauntlet, “leggings” that are breeches with feet attached and splayed outward, or “greaves” that are two tubes in a V joined at the apex.
blender --background --python scripts/blender/probe-rig.py -- \ --ref .gear-work/astronaut.raw.glb --girth \ --extent 'Head;Spine+Spine1+Spine2;LeftUpLeg+LeftLeg'--girth prints, per bone, the radius of the body mesh about that bone’s rest segment
(r50, r95, max). --extent prints the axis-aligned box of the vertices a group of bones
owns (groups are ;-separated, bones within a group +-separated), which is what a
--size3 is. Every run also prints socket-local frames (Spine1, Spine2, Neck, LeftHand
and RightHand unless --frames names others). Bone names may omit the
mixamorig: prefix. Size against these numbers, not by eye: a glove sized from the
source’s own proportions instead of the measured hand leaves the fingertips out in the
open.
Use girth, not the box, to compare body parts across poses. A box measured in armature space measures the pose as much as the part; girth is a distance to the bone segment and does not depend on rotation.
The armature frame is not Blender’s Z-up
Section titled “The armature frame is not Blender’s Z-up”UP = -Y FRONT = -Z wearer's LEFT = +Xbone.head_local is armature-local, and this rig’s armature data is not stood up the
way the imported object is: the importer’s Y-up to Z-up conversion lives on the
armature object’s transform, above the space head_local is expressed in.
probe-rig.py re-derives and prints both axes on every run, so a placement is typed
against a number rather than against this paragraph.
Consequences:
- a Meshy source arrives Blender-Z-up, so
--euler 90,0,0is the plain source-to-armature rotation for--limbmode (a garment already standing up in its own file needs0,0,180instead); --size3in limb mode reads (lateral, vertical, front-back);- an
--offsetof0,-0.042,-0.122is “4 cm up, 12 cm forward”.
Keep the recipe, not the shell line
Section titled “Keep the recipe, not the shell line”npm run build:gear -- --listnpm run build:gear -- torch --previewnpm run build:gear -- --allnpm run build:gear -- --all --out .gear-outscripts/build-gear.mjs holds every Meshy-sourced piece’s fit-armor-piece.py flags in
one RECIPES table, with the measurement each number came from written next to it.
Keeping them in one table is how a gear set avoids drifting. --preview also renders a
contact sheet of the result on the rig to .gear-work/preview/<name>.png. A recipe
whose source file is missing is skipped with a message (the sources are gitignored, so
a fresh clone has none), and a recipe may carry a deferred reason: --all skips it
and prints the reason, and --list marks it [DEFERRED]. After a build, run normalize:armor,
rebase every rigid piece, and run verify:armor (step 3).
2. Fit and skin a rigid piece (Blender)
Section titled “2. Fit and skin a rigid piece (Blender)”A rigid piece (a sword, shield, helm, amulet or ring) rides one bone. One command does the whole step: import the reference rig in REST, import and join the source, strip Meshy’s own rig, orient, scale into socket-local metres, skin every vertex to one bone, reduce the material to one base-colour texture, and export.
blender --background --python scripts/blender/fit-armor-piece.py -- \ --ref .gear-work/astronaut.raw.glb \ --in art-input/meshy/war-axe/war-axe.glb \ --socket mixamorig:RightHand \ --size 0.70 --axis x --euler 90,90,0 --offset 0.23,0.13,0.02 \ --out public/models/characters/armor/axe.compressed.glbIt works because of the bake identity: place the mesh at
armature_world @ bone.matrix_local @ p, and p is exactly the socket-local
coordinate the engine sees. So a piece is specified numerically instead of nudged in a
viewport, and the per-body fit row (step 4) is left to do only small corrections.
| flag | meaning |
|---|---|
--size + --axis x/y/z | scale so that axis measures size metres after orientation (--axis defaults to the longest) |
--size3 x,y,z | per-axis target extents instead of one uniform scale |
--euler x,y,z | degrees, applied Z, then Y, then X. Right when the source is axis-aligned |
--pca x/y/z + --flip + --roll d | rotate the mesh’s own longest axis onto a socket axis. Use this when the source is diagonal |
--offset x,y,z | socket-local centre, metres |
--mirror x/y/z + --sep d | duplicate mirrored to make a symmetric pair from one asset. The winding is flipped back; a mirror alone renders the copy inside-out |
--crop 'y>-0.19,z<0.3' | delete faces whose centroid is outside these source-space half-spaces (clauses are ANDed). Runs before --euler |
--half x+ | keep one side of a symmetric pair, split at the source’s origin plane (0 on that axis, by triangle centroid) |
--srcflip x | reflect the source before orientation (turns a right glove into a left one, which no rotation can) |
--tex N | albedo size, default 512 |
--crop is not --half with a number. --half splits a symmetric pair at its
origin plane so one side can be placed and mirrored; --crop removes a part you do not want
at a plane you measured. It is what lets a generated garment scale honestly: feet left
on a pair of generated leggings span the whole source width, so any --size3 that
makes the pair the right width squeezes the legs to slivers.
--size3 exists because one uniform scale sometimes has no good answer. A
generated piece is not in the wearer’s proportions. Squashing belongs in authoring, as
one measured number, not in equip.fit, which exists for per-body correction.
Reason about source axes in Blender space, not glTF. The importer converts Y-up to
Z-up, so glTF (x, y, z) arrives as Blender (x, -z, y): a shield whose long axis is
glTF +Y has its long axis on Blender +Z. The socket-local frame, though, maps one to
one: --offset and --size come out of the engine exactly as typed.
Close the loop with a measurement, every time:
npm run measure:armor -- public/models/characters/armor/axe.compressed.glb --slices 6This prints the socket-local box (every POSITION through IBM_armor[socket]) plus a
cross-section along the longest axis, which is how you tell a blade from its crossguard
or a haft from its head. The shipped sword reads 0.9500 x 0.2032 x 0.0631, blade
along +X; match that frame and a new one-hander needs no grip calibration.
The socket frames on the reference rig (positions of other joints in each socket’s
local space, metres; probe-rig.py --frames prints them):
| socket | +X | +Y | +Z |
|---|---|---|---|
RightHand / LeftHand | lateral | fingers (index at +0.12); elbow at -0.26 | toward the body (hips at +0.51) |
Spine2 | wearer’s left (arms at ±0.171) | up (head +0.206, hips -0.367) | front |
Spine1 | wearer’s left | up (head +0.345, hips -0.227) | front |
A held weapon runs along +X at about y +0.13 (the shipped sword spans x -0.16 to +0.79, y 0.028 to 0.231). The shipped shields sit on the forearm side of the hand: centred at y -0.10, entirely at z < 0.
Authoring from scratch
Section titled “Authoring from scratch”When there is no Meshy mesh to start from, the steps are the same, done by hand.
Import the reference rig, then set the armature to REST position:
arm.data.pose_position = 'REST'Model against the rest pose, never against whatever pose the importer shows. If the imported armature carries a pose and you model against it and then bind, the pose is applied a second time and the piece flies off the body. Every script here sets REST before measuring or placing.
Place the mesh over the body part, leaving a small air gap.
Skin it to the socket bone. One vertex group named exactly after the bone, all weights 1.0, plus an Armature modifier pointing at the rig:
vg = ob.vertex_groups.new(name='mixamorig:Head')vg.add(list(range(len(ob.data.vertices))), 1.0, 'REPLACE')mod = ob.modifiers.new('Armature', 'ARMATURE'); mod.object = armob.parent = armJOINTS_0 and WEIGHTS_0 are mandatory even for a rigid piece: the GPU path reads
them unconditionally.
Export, selection only, with the armature selected too:
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', use_selection=True, export_apply=False, export_skins=True, ...)export_apply must stay False. Applying modifiers bakes the armature deform into
the mesh and destroys the skin.
scripts/blender/build-footman-helmet.py is a complete, headless worked example: a
revolved shell and nasal bar, rigid-skinned to mixamorig:Head. It writes
public/models/characters/armor/helmet.compressed.glb, so running it replaces the
shipped helmet; point OUT_GLB elsewhere when experimenting. It reads the decompressed
rig from .gear-work/p5/astronaut.raw.glb (or from PROP_WORK_DIR when set), not from
the .gear-work/astronaut.raw.glb the other scripts use, so copy the rig there first.
Socket bones by slot
Section titled “Socket bones by slot”SOCKET_BONES in src/animation/equip-sockets.ts is the source of truth. Each slot
lists candidate bone names, and the first one the wearer has wins; the authoring names
are:
| slot | bone | slot | bone |
|---|---|---|---|
head | mixamorig:Head | back, cloak | mixamorig:Spine2 |
shoulders | mixamorig:Spine2 | mainhand | mixamorig:RightHand |
body | mixamorig:Spine1 | offhand | mixamorig:LeftHand |
hands | mixamorig:RightHand | neck | mixamorig:Neck |
belt / legs / feet | mixamorig:Hips | ring1 / ring2 | mixamorig:{Right,Left}HandMiddle1 |
Pass the bone the slot resolves to as --socket. armor-mesh.ts looks the socket up in
the piece’s own skin by name and, on a miss, falls back to the piece’s dominant joint
with a problems line rather than an error.
cloak is a separate slot from back on purpose. back holds wings, and one
inventory slot cannot hold both. A cloak must never declare equip.wing.
Rings ride a finger, and Middle1 is deliberate. The ring slots fall back to
Index1 and then the hand. ring is also the only item slot that resolves to two
equip keys (ring1 if free, else ring2), while an ItemDef declares one equip.socket.
equipVisualServesKey is the rule that lets one band serve both; the bone always comes
from the key, so each hand gets its own finger, and the geometry cache key includes the
slot for exactly that reason.
legs, feet and belt all resolve to Hips. For a weighted piece that is
correct: the socket is only the anchor, and the geometry follows its own weights (next
section). For a rigid piece those slots are a trap: a boot skinned rigidly to Hips
renders at the waist, with no error. test:anim §15 pins that Hips-slot mesh items are
weighted.
2b. Weighted pieces: boots, and anything else spanning a joint
Section titled “2b. Weighted pieces: boots, and anything else spanning a joint”No weighted piece ships. The ones fitted this way were weighted on the Mixamo-rigged authoring rig, which cannot be rebased onto one socket, so they were removed; worn garments on the shipped bodies come from the clothing pipeline instead. A new weighted piece needs a skeleton of your own, and the engine path below still supports it. The boot names in the examples show the shape of the data, not shipped files.
A helm is rigid: one bone owns every vertex, the bake is one matrix, and the result is
exact at every pose. A boot is not. It spans Leg, Foot and ToeBase, and it comes
as a pair, so it also spans both legs.
The engine handles this with equip.skinned: true, which keeps the piece’s own
JOINTS_0/WEIGHTS_0 (remapped onto the wearer by bone name) and bakes each vertex
to the blend of its per-joint matrices. Declare it: nothing infers it, because a
rigid piece that happens to carry stray weights must not silently change how it
attaches. See “How the bake works” for why one matrix is not enough.
Because joints are matched by name, a weighted piece only deforms on a wearer whose skeleton uses the same bone names as the reference rig. Joints the wearer lacks are dropped (next section’s last note).
Authoring one
Section titled “Authoring one”fit-armor-piece.py has a limb mode. A generated pair comes back side by side at a
stance width that is not the rig’s, so any single scale that makes the boots the right
size puts them in the wrong place. Keep one half, place it on one limb, mirror it:
blender --background --python scripts/blender/fit-armor-piece.py -- \ --ref .gear-work/astronaut.raw.glb \ --in art-input/meshy/leather-boots/leather-boots.glb \ --half x+ --euler 90,0,0 --size3 0.13,0.34,0.29 \ --at mixamorig:LeftFoot --offset 0,-0.042,-0.122 --mirrorx \ --limb 'mixamorig:LeftLeg,mixamorig:LeftFoot*2.0,mixamorig:LeftToeBase*1.5,mixamorig:RightLeg,mixamorig:RightFoot*2.0,mixamorig:RightToeBase*1.5' \ --socket mixamorig:Hips \ --out public/models/characters/armor/boots-leather.compressed.glb| flag | what it does |
|---|---|
--half x+ | keep one side of the source pair (cuts by triangle centroid) |
--size3 x,y,z | per-axis target extents in metres, (lateral, vertical, front-back) |
--at <bone> | anchor the box centre at that bone’s head; --offset nudges from there |
--mirrorx | duplicate mirrored across the armature’s x = 0 plane and join |
--limb 'bone[*gain],...' | inverse-distance weighting to these bone segments; *gain biases one at equal distance |
--wpow | falloff exponent, default 4 |
--pin 'bone@y<-0.60:0.12' | force one bone to own a half-space, ramping over the band; a trailing ! also excludes it outside the region |
Each vertex keeps its four strongest influences, the limit of the GPU vertex layout.
Limb mode works in armature space, not socket-local: there is no single bone
whose frame the piece lives in. Every bone’s rest position is a known number there, so
the placement reads like a sentence: “0.13 wide, 0.34 tall, 0.29 long, centred 4 cm
above and 12 cm in front of the left ankle.” Read the numbers off probe-rig.py, and
check the RESULT line’s armature_space block, which reports where the piece landed.
Place the piece over the joint before skinning it. The weights are solved by
distance, so geometry that is not near a bone cannot be assigned to it, and no gain,
--pin or --wpow rescues that.
Not parent_set(type='ARMATURE_AUTO'). Blender’s bone-heat solver needs manifold
geometry and a generated mesh is not (a generated boot pair can arrive as hundreds of
disconnected shells). Heat weighting then errors out or quietly returns garbage for the
shells it could not solve. Inverse distance to the bone segment has no such
precondition and is deterministic.
Distance alone cannot separate parts whose bones are close together: the reference
rig’s Head and Neck bone heads are about 5 cm apart, so a hood’s cowl blends into the
collar and the face shears through the cloth when the head turns. That is what --pin
is for.
The reported weight_share is the check that matters: it should be near-symmetric
left and right, and should include every bone you listed. A missing bone means the
geometry never reached it.
2c. Self-rigged pieces: wings, and anything that animates on its own
Section titled “2c. Self-rigged pieces: wings, and anything that animates on its own”A helm is rigid and a boot deforms, but both ride the wearer’s bone palette, which is why they cost no per-frame CPU work, and why neither can move in a way the wearer’s skeleton does not. No body bone flaps.
A wing therefore carries its own armature inside its own GLB and is posed in code every frame. It takes neither armour bake.
equip: { socket: 'back', shape: 'mesh', meshUrl: 'models/characters/armor/wings-feathered.compressed.glb', alphaTest: true, twoSided: true, wing: { design: 'feathered' }, fit: { /* per-body row, as in step 4, but applied every frame */ } },wing is declared, never inferred from socket: 'back'. A cape must never start
flapping. Same discipline as weapon.
Do not send a self-rigged piece through buildArmorPrim. The rigid path would
discard its skin; the weighted path remaps joints onto the wearer by name, no wearer
has a bone called LeftWing1, so every influence would be dropped and the bake would
return nothing, reported only as a problems line rather than an error.
What the engine reads
Section titled “What the engine reads”offset[j] = W_socket x Fit x P_j(t) x IBM_wing[j]W_socket is the wearer’s socket frame in model space, in socket-local metres (on the
shipped bodies it comes from characterWingSocket, which stands the wing root off the
measured back). At rest every offset[j] collapses to W_socket x Fit, so the piece
renders exactly as authored. Consequences:
Fitis composed per frame, never baked, so wing growth is one matrix rebuild, not a geometry re-bake.- The geometry does not depend on the wearer, so one upload serves every body. The
wing geometry cache is keyed by URL alone (
wing:<meshUrl>). - A wing’s span must stay inside the ±8 m deferred-resolve scissor (
CHAR_R) around the character’s origin.
Cost per winged character: a handful of bones (the shipped wings have 9), each one clip sample and a few matrix multiplies, plus a small bone upload that is skipped when the pose is unchanged.
Authoring one
Section titled “Authoring one”blender --background --python scripts/blender/build-wings.py -- --allnpm run normalize:armor && npm run verify:armorbuild-wings.py builds all four shipped designs (feathered, angel, void,
dragon) from one parameter table, DESIGNS; --design <name> builds one. The rig
contract it emits, which verify:armor checks:
| requirement | why it fails silently otherwise |
|---|---|
| exactly one root bone | two roots are two chains with no shared anchor; one fit row can never place both |
a Left* / Right* prefix on every chain bone | clip mirroring keys off the name; an unprefixed side never moves |
| symmetric chains | one authored clip drives both sides |
| a readable node hierarchy | without parents the chain vanishes and a flap becomes a rigid whole-wing turn, which looks plausible |
at least 2 and at most 24 bones (WING_MAX_BONES) | more than that is almost certainly a character GLB, which wing-rig.ts refuses |
| span at most 8 m | wingtips outside CHAR_R write depth and get no deferred lighting |
Bone orientation is deliberately not load-bearing. Blender bones point along
their own local +Y, so a clip authored as “z = flap” would come out as a twist if
applied in bone space. poseWingRig conjugates every clip rotation through the bone’s
bind rotation (inv(bindRot) x R x bindRot), so a clip angle always means armature
axes: x = twist, y = sweep back, z = flap.
Mirroring: the right side negates euler y and z, and keeps x. For a mirror
D = diag(-1,1,1), D·R(n,θ)·D = R(Dn, -θ), which equals R(n,θ) only when n is the
x axis. test:anim §14 measures mirrored vertex positions rather than restating the
rule.
The shared root bone carries no animation. It is the one bone both sides use, and by the rule above only an x rotation on it would be symmetric: a root flap would raise one wing and drop the other. Pinned by a test.
Clip keys are absolute
Section titled “Clip keys are absolute”A key is where that segment points, not a bend added to its parent.
sampleWingClip derives the delta the chain composes (inv(Q_parent) · Q_j). Written as
per-segment deltas, the angles accumulate: four segments of 42, 30, 22, 14 degrees of
flap put the wingtip at 108°, over the character’s head. test:anim §14 pins the tip
angle of every clip into a band a wing can physically reach.
Authoring the mesh
Section titled “Authoring the mesh”Each of these looks fine in isolation and is wrong on the body:
- Feathers must root at the arm tube’s surface. Rooted on the bone centreline they
start inside the tube and the quills poke through it.
arm_radius()is shared by the tube and the feather roots so they cannot drift apart. - The tube must run to the outermost feather root, not the last bone. Roots are biased outboard and extrapolate past the tip joint, so a tube ending at the joint leaves the last feather attached to nothing.
- Thin surfaces are one layer plus
equip.twoSided, never a shell. A coincident duplicate back face Z-fights in the shadow map; offset it and its dark back layer shows through the front’s alpha stipple. A single layer on its own shades black underneath, because the resolve uses the interpolated normal as is.twoSidedmakes the resolve face the normal towards the viewer (material_idbit 17,MATERIAL_TWO_SIDED), which fixes the underside with half the triangles. Use it only for genuinely open one-layer geometry: on a closed mesh it stops back faces reading as back faces. - The chord profile is what makes a wing end. Holding full chord to the tip leaves the outermost feather long and the wing stops in a blunt edge. The chord has to collapse over the last part of the span, and the outer feathers sweep past straight-back to rake the trailing edge inward.
Per-texel alpha (equip.alphaTest)
Section titled “Per-texel alpha (equip.alphaTest)”A piece can screen-door itself against its texture’s alpha, per texel: feather vanes wispy while the quill stays solid.
Declared, never inferred from the texture having an alpha channel. The skinned
visibility fragment shader runs for every skinned pixel on screen; an unflagged
instance costs one bit test, a flagged one a texture fetch. It rides material_id
bit 16 (MATERIAL_ALPHA_TEST in skinned-mesh-pipeline.ts, mirrored in
skinned-visibility-shader.ts; the two must agree).
The stipple is the same Bayer-4 dither the fade ramp uses, multiplied together, so a half-faded alpha-tested piece composes instead of the two fighting over which pixels survive.
Confine the stipple to the edges, in the texture. Wherever alpha is strictly between 0 and 1 you get a visible screen-door. An alpha map that fades across a whole surface stipples the whole surface, reads as lace, and fights anything semi-transparent behind it (clouds especially). Author a flat 1.0 body with a narrow ramp at the outer edge, and the dither becomes a few-texel fringe that only softens the silhouette.
True blended transparency is not available on this path. A visibility buffer stores one surface per pixel, so blending would need a forward pass after the lighting resolve that sorts overlapping feathers inside one mesh. A hard alpha clip has no pattern but loses the soft edge. The fringe dither is the deliberate middle.
Four places the alpha is silently dropped if you miss one:
| step | what drops it |
|---|---|
| Blender material | the exporter picks RGB or RGBA from the material, not the image: link Alpha and set blend_method = 'BLEND' or the texture ships opaque |
createImageBitmap | the default premultiplies, which darkens RGB wherever alpha < 1; equipment-visuals.ts passes premultiplyAlpha: 'none' |
| the atlas | CHAR_ATLAS_FORMAT is rgba8unorm and the blit passes RGBA through; check it if either changes |
| the WGSL gate | skinned-visibility-shader.ts must stay registered in scripts/compile-wgsl.ts, or “compiled clean” says nothing about the shader every skinned pixel runs through |
Verify the shipped asset rather than trusting the chain: alphaMode should be BLEND
and the WebP chunk VP8X with its alpha bit set.
The shadow pass does not alpha-test. The skinned shadow pipelines have no texture binding (the solid casters are depth-only), so adding it means a new fragment shader plus an atlas binding on the shadow pass. A wispy wing casts a slightly too solid shadow. Accepted, not missed.
Clips are code, not GLBs
Section titled “Clips are code, not GLBs”src/animation/wing-anim.ts holds the named clips (folded, flap, soar, dive) as
keyframe tables over normalised phase, keyed by chain segment so one authored flap
serves every design. The character motion library has no wing motion, so these are
authored in code.
Deploy and retract are a blend towards folded, driven by
CharacterController.isFlying. Read the state, never the key: the manual exit can be
refused, and other paths clear the flag (ground contact, water entry,
resetLocomotionModes on death or respawn, and the NPC stepLite path).
Tuning it in-engine
Section titled “Tuning it in-engine”Dev-console helpers:
| probe | what it does |
|---|---|
__charWings(on) | the charWings runtime flag (default on); false is the A/B lever |
__wear('feathered-wings') | grant and equip |
__wings() | per-wing deploy, clip, phase, growth and fit row |
__equipFit('feathered-wings', {...}) | the same per-body fit row as any mesh piece, but live, since it is not baked |
3. Normalize and verify
Section titled “3. Normalize and verify”npm run normalize:armornode scripts/rebase-armor-glb.mjs public/models/characters/armor/<piece>.compressed.glbnpm run verify:armornormalize:armor (scripts/normalize-armor-glb.mjs) fixes what Blender writes that the
engine cannot read. Blender 4.1 writes WebP textures as EXT_texture_webp with no
fallback textures[i].source, and the parser does js.images[t.source]
unconditionally, so the file throws a TypeError at load. The normalizer rewrites it to a
plain image with an image/webp MIME type.
scripts/rebase-armor-glb.mjs turns a rigid piece into the form that ships. The only
thing a rigid piece’s skeleton contributes to the bake is IBM_armor[socket], the matrix
that puts its vertices in socket-local metres, so the rebase applies it once and drops
the skeleton: POSITION and NORMAL are stored socket-local, the skin is one neutral
joint (PinionfallSocket) with an identity inverse bind, and extras.pinionfall.socket
names the bare bone the piece is framed in (RightHand, Head, Spine1 and so on, as
SOCKET_BONES in src/animation/equip-sockets.ts spells them). It refuses a piece
weighted to more than one joint and prints the joints and their weight shares: a
weighted piece needs a matrix per joint and cannot be flattened into one frame.
verify:armor (scripts/verify-armor-glb.mjs) is the gate. It walks
public/models/characters/armor/ and checks the whole contract. It fails on an empty
directory, on a run that checked no socket-local piece, and on any file that carries a
Mixamo rig (a mixamo name or the Mixamo humanoid joint layout, rule in
scripts/lib/mixamo-rig.mjs). Everything it
checks fails silently otherwise:
- GLB version 2 with a BIN chunk and no extra chunks
- non-interleaved accessors: a strided bufferView parses to
null - everything in
meshes[0].primitives[0]: a second mesh loads invisibly. Join first. POSITIONandNORMALFLOAT, and noKHR_mesh_quantizationJOINTS_0(u8 or u16, not normalized) andWEIGHTS_0either float or a normalized integer- indexed, u16 or u32, not u8
- a skin with
inverseBindMatrices - one embedded
baseColorTexturevia bufferView (WebP, PNG or JPEG), no external URI - filename ends
.compressed.glb, and no query string in the URL, ever - for a socket-local piece: one neutral joint with no transform and an identity inverse
bind, every vertex fully weighted to it,
extras.pinionfall.socketa bare bone name some equip slot resolves to, unit normals, and an envelope in plausible metres - for a self-rigged piece, the wing rig contract in §2c
npm run verify:gearverify:armor walks a directory and never reads item-defs.ts; the test suites read
item-defs.ts and never touch the filesystem. A typo’d meshUrl, a missing
.compressed suffix or an asset that was never exported would otherwise pass every
gate and produce an item that equips for stats and renders nothing.
verify:gear (scripts/verify-gear-wiring.ts) is the join: every declared meshUrl
and every mesh-prop GLB source must exist, and armour GLBs on disk that no ItemDef
references are reported (not an error, but an orphan is how a regenerated asset ends up
unused while the old one keeps shipping). The pure half of the same contract (socket
serves slot, Hips slots are weighted) is test:anim §15.
Do not run gltfpack unless you pass -noq. Its default quantization emits
integer positions that the parser hands straight to a float32x3 buffer: garbage, no
error. Uncompressed is fine; the shipped armour GLBs are roughly 40 to 550 KB each.
4. Tune the fit
Section titled “4. Tune the fit”Each mesh ItemDef can carry a fit table keyed by wearer. The player’s shared Astra
body resolves through the key shared-<rigFamily> and falls back to the all-bodies
'*' row.
Fit is applied in socket-local space (metres, +Y along the bone), between the two inverse-bind matrices, which is why it can be reasoned about geometrically instead of nudged blindly.
Sizing an enclosing shell is a judgement call, and a clearance rule will get it
wrong. “Enclose every head-weighted vertex with N mm of air” sounds right and is not:
that vertex group includes the neck and collar, so a helm sized that way comes out far
wider than the head. The opposite rule (shell width a fixed fraction over head width)
fails on bodies with a crest or an unusual skull. Measure with probe-rig.py, then
decide.
Tune it live and paste the numbers back:
__equipFit('iron-helm', 0.80) // uniform scale, current wearer's row__equipFit('iron-helm', 0.80, 0.18, 0.05) // + socket-local dy, dz__equipFit('iron-helm', 0.80, 0.18, 0.05, '*') // a specific fit-table key__equipFit('iron-sword', { offset: [0.02, 0.13, 0.02], euler: [0, 0, -90] })It patches the ItemDef, evicts the baked geometry, re-attaches, and returns the row to
paste into fit. The GEAR FIT panel (src/ui/gear-fit-panel.ts) is the same loop with
sliders and always shows the complete fit block for the item. The CHARACTER panel’s
body selector previews another shipped body (a debug swap, never saved).
In the world, a fit row only shows on pieces this pipeline actually draws on the current body (see “What this pipeline feeds”); the character preview reads the same rows.
5. Wire it up
Section titled “5. Wire it up”'iron-helm': D({ ... slot: 'head', color: [160, 165, 175], equip: { socket: 'head', shape: 'mesh', meshUrl: 'models/characters/armor/helmet.compressed.glb', fit: { '*': { /* paste the __equipFit / GEAR FIT row */ } } }, dropVariant: MV('helm_iron'), dropHalf: [0.13, 0.11, 0.13],}),shape: 'mesh' is what routes it to the armour loader, and meshUrl is
site-root-relative. shape is geometry only: combat identity comes from
equip.weapon, and an item swings the armed attack clips only if that class is in
ARMED_WEAPON_CLASSES (itemIsBlade). Never give a non-weapon a weapon class.
A weighted piece adds one field:
equip: { socket: 'feet', shape: 'mesh', skinned: true, meshUrl: 'models/characters/armor/boots-leather.compressed.glb' },To give a dropped item the same model on the ground, set dropVariant on the ItemDef
and add a row for it in MESH_PROP_VARIANTS / MESH_PROP_GEOMETRY
(src/dynamics/mesh-prop-catalog.ts).
Tuning it in-engine
Section titled “Tuning it in-engine”| probe | what it does |
|---|---|
__wear('leather-boots') | grant and equip in one call; __wear() alone reports what is worn and whether each piece has a real attachment |
__wearGear() | grant and equip a whole outfit, one piece at a time; __wearGear('leather-greaves') picks a specific candidate. Not __giveAll: the bag is 16 slots, so granting everything first overflows and most items never arrive (they land under didNotFit, which is easy to miss). Equipping moves a piece out of the bag, so one at a time never needs more than one free slot |
__cam(1.8, 0, -45) | park the third-person camera so you can see the piece (only the distance is sticky; the follow camera re-centres yaw and pitch as soon as you move) |
__equipBones('leather-boots') | the joint census of the actual GLB: the keys fit.bones accepts, each with its weight share |
__equipFit('leather-boots', { side: 'Left', offset: [0,0,0.01] }) | place one boot of a pair (rigid motion of that half) |
__equipFit('leather-boots', { piece: true, scale: 1.05 }) | the whole object (rigid) |
__equipFit('leather-boots', { scale: 1.2, bone: 'Leg', pivot: 'centroid' }) | reshape just the shaft |
__equipFitDump('leather-boots') | the whole fit block, pasteable |
The GEAR FIT panel grants for you: picking an item under a slot equips it into
that slot. If the picker snaps back to “none”, the panel says why (a full bag is the
usual reason). It honours the slot you picked under, which matters for rings, where
resolveEquipKey would otherwise fill ring1 first.
Which channel to reach for (the part that costs an hour if you get it wrong):
| you want to | use | why not the others |
|---|---|---|
| place one boot of a pair | sides: { Left: {...} } | a bones row moves only that joint’s share of each vertex, so the boot stays half attached and stretches |
| move the whole object | piece: {...} | the base row acts in each joint’s own frame, and those can be about 100° apart, so it shears |
| reshape one part (a shaft, a toe cap) | bones: { Leg: {...} } | |
| a rigid piece (helm, sword) | the base row | bones/piece/sides are ignored there, and __equipFit says so |
A side-agnostic bones key does not keep a mirrored pair symmetric for every
channel. The two legs’ frames are mirror images, so a shared row preserves the pair
only for channels that commute with the mirror. Measured over the boots’ mirrored vertex
pairs: any scale, offset.Y, offset.Z and euler.X are safe shared; offset.X,
euler.Y and euler.Z break the pair. offset: [0.01, 0, 0] slides both boots the
same way; it does not spread them. Widening a stance and toeing boots out are
anti-symmetric: use sides, or side-specific LeftFoot/RightFoot keys with opposite
signs, or mirror: true, which writes that pair for you.
Pivot. On bones and the base row, euler and scale turn about the bone
head by default. The boot shaft’s centre is about 0.32 m from the knee, so
scale: 1.2 on Leg also translates it about 65 mm. Use pivot: 'centroid' for
anything much past 1.3. sides and piece default to the centroid.
Every form prints the row to paste back into item-defs.ts.
NPCs. An NPC’s kit comes from npcLoadout in src/gpu/equipment-visuals.ts, which
returns loadoutForJob(job, identityKey) or, for a hostile, loadoutForBandit. Add the
item there. Both the NPC attach site and the death-drop site go through npcLoadout
with the same inputs, which is what guarantees an NPC drops exactly the gear it was
given; keep it that way. On the shipped bodies an NPC only shows an item its body has a
fitted visual for, and cloaks are skipped. Wings in a loadout (a questgiver wears
angel-wings) are drawn as a separate self-rigged instance
(src/npc/alien-visual.ts) and are never dropped on death (deathDropLoadout).
6. Prove it
Section titled “6. Prove it”npx tsc --noEmit && npm run test:anim && npm run test:combat && npm run verify:armor && npm run verify:gearIn game: __grantItem('wooden-shield') then __equipItem('wooden-shield') (or
__wear('wooden-shield')). __equipStats() should report 0 failed;
__charArmorMesh(false) is the A/B lever for all mesh pieces.
Watch geomCache against texCache in __equipStats(). geomCache grows once per
wearer body (correct: the bake is per inverse-bind matrix). texCache must stay at
one entry per item: the character texture atlas has a fixed number of layers
(CHAR_ATLAS_LAYERS: 96 with the default charAtlasLayers96 flag, 64 without it) and
never reclaims them, so a texture keyed per item and wearer would exhaust
it and every later piece would render neutral grey.
How the bake works (why any of this is safe)
Section titled “How the bake works (why any of this is safe)”v_wearerBind = inv(IBM_wearer[socket]) x fit x IBM_armor[socket] x v_armorArmour is never authored in a wearer’s bind space. The piece goes through the socket
bone’s local frame instead, which both files define independently and exactly: by glTF
definition, IBM_armor[socket] x v_armor is the vertex in socket-local metres. A shipped
rigid piece is stored after that product (scripts/rebase-armor-glb.mjs), so its
IBM_armor is identity and the bake is inv(IBM_wearer[socket]) x fit x v.
The IBM_armor factor is what makes this exporter-agnostic: Blender’s Z-up to Y-up
conversion, object transforms and armature scale are all one global change of frame,
and a global change cancels in inv(IBM_w) x IBM_a. On the shipped bodies a rigid piece
is baked with an identity wearer matrix and rides the resolved socket bone directly,
so its socket-local coordinates are used as authored.
That one matrix is only right for a single-bone piece. The bake
inv(IBM_wearer[j]) x fit x IBM_armor[j] is the same matrix for every joint j only
when (i) fit is identity and (ii) the wearer’s skeleton equals the authoring rig up to
one global frame change. On a differently proportioned wearer, two joints of the same
piece disagree, and with a non-identity fit it diverges on the authoring rig too, in closed form
|1-s| * |t_foot - t_toe|.
verify:armor does not test this: it checks each file’s own contract, never a bake
against a wearer and never fit. A green verify:armor says nothing about whether a
multi-bone piece lands correctly.
So a weighted piece is re-expressed per wearer by buildWeightedArmorPrim:
v_bind = Σ_j w_j · B_j · v_armor B_j = inv(IBM_wearer[j]) × fit_j × IBM_armor[j]One B_j per joint, blended by the piece’s own weights, and the weights survive into
the output primitive so the GPU does the deformation, still with no per-frame CPU work.
At the bind pose this reproduces the blend exactly (Σ w_j = 1); away from it the residual
is the ordinary linear-blend approximation every skinned vertex already lives with. The
geometry cache key includes the wearer, so the per-wearer bake happens once per body.
Two invariants worth knowing:
- For a piece with one live joint this reduces to the rigid formula exactly.
- On the authoring rig with
fit = Iit also reduces to the rigid result:inv(IBM_w[j]) · IBM_a[j]is then the same global frame change for everyj, so the blend collapses. Divergence appears only where it should: on a differently proportioned wearer, or under a non-identity fit.
fit_j is applied in each joint’s own local frame, not the anchor’s. That is the
literal generalisation of the rigid formula and it is how a part of a piece is reshaped.
It is not how you place a pair: see the channel table in §5, and ArmorFit.sides, which
moves one half rigidly. ArmorFit.bones overrides the base row per joint; keys may be
side-agnostic (Foot) or side-specific (LeftFoot, which wins).
Joints the wearer lacks are dropped and the remaining weights renormalised, never
left summing to zero. getBoneMatrix in skinned-mesh-shader.ts divides by that sum
(guarded with max(sum, 1e-6)), and one NaN vertex would expand the mesh bounds to
everything and blank the whole character out of the shadow and velocity passes. A vertex
whose every joint is dropped is pinned to one joint, preferring the one that carries most of the mesh.
verify:armor does not test any of this. test:anim §12c does: it builds two joints
whose bakes genuinely disagree and asserts the blend, the name remap, the
renormalisation and the zero-influence fallback.
At runtime the wearer’s palette supplies offset = worldMat x IBM_wearer, so the piece
rides the bone with no per-frame CPU work and is safe at any NPC animation stride.
Never decompose an inverse-bind matrix to quaternion plus position: scale is part of
it and is load-bearing.
Known gaps
Section titled “Known gaps”- No clearance solver. Placement is numeric (
fit-armor-piece.pyplusmeasure:armor). A solver that sizes a shell to enclose a body part on differently proportioned bodies does not exist. If you write one, do not cast coverage rays radially from the centre: a helm is open at the bottom and every jaw vertex escapes downward. Test lateral and crown directions and exclude anything below the rim. - A diagonal source needs
--pca, and its sign and roll need an eye.--pcaputs the principal axis on the right socket axis in one step, but which end is the business end, and which way the piece faces, are two bits the pipeline cannot infer. Render it withpreview-glb.pybefore and after. - No normal or ORM maps on these pieces. An armour GLB carries its base colour only,
and the skinned resolve shades it with a fixed roughness of 0.6 (the normal and
roughness-metal maps the resolve can read belong to the shipped bodies’ own character
materials, which these attachments do not use). Meshy’s PBR retexture does return
normal, metallic-roughness and emissive maps, but
fit-armor-piece.pydiscards them. Use the PBR base colour (a true albedo). - The cuirass is rigid.
steel-cuirassridesSpine1alone, so the torso’s own bend is not followed and a deep crouch shows some interpenetration. A weighted version would spanSpinetoSpine2, and sleeved body pieces would also needArmandForeArm. - Weighted retargeting across bodies is a
fitproblem, not a bake problem. A boot authored on the reference rig is shaped for that foot. The blend places it sensibly on a bigger foot but cannot resize it; that is what per-bodyfitrows andArmorFit.bonesoverrides are for. - Weighted pieces need matching bone names. Joints are matched to the wearer by name, so a weighted GLB authored on the reference rig only deforms on a skeleton that uses the same names; everything else is dropped as described above.
Written by release:site-sync from docs/armor-pipeline.md at v2026.09.22. Edit the engine document, not this page.