Skip to content

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.


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 (_syncPlayerEquipment in src/gpu/webgpu-renderer.ts): wings, and a mainhand or offhand item 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.


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:meshy

Why 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.


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 4000

Output 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.png

Six 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 = +X

bone.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,0 is the plain source-to-armature rotation for --limb mode (a garment already standing up in its own file needs 0,0,180 instead);
  • --size3 in limb mode reads (lateral, vertical, front-back);
  • an --offset of 0,-0.042,-0.122 is “4 cm up, 12 cm forward”.
npm run build:gear -- --list
npm run build:gear -- torch --preview
npm run build:gear -- --all
npm run build:gear -- --all --out .gear-out

scripts/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).


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.glb

It 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.

flagmeaning
--size + --axis x/y/zscale so that axis measures size metres after orientation (--axis defaults to the longest)
--size3 x,y,zper-axis target extents instead of one uniform scale
--euler x,y,zdegrees, applied Z, then Y, then X. Right when the source is axis-aligned
--pca x/y/z + --flip + --roll drotate the mesh’s own longest axis onto a socket axis. Use this when the source is diagonal
--offset x,y,zsocket-local centre, metres
--mirror x/y/z + --sep dduplicate 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 xreflect the source before orientation (turns a right glove into a left one, which no rotation can)
--tex Nalbedo 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 6

This 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 / LeftHandlateralfingers (index at +0.12); elbow at -0.26toward the body (hips at +0.51)
Spine2wearer’s left (arms at ±0.171)up (head +0.206, hips -0.367)front
Spine1wearer’s leftup (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.

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 = arm
ob.parent = arm

JOINTS_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 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:

slotboneslotbone
headmixamorig:Headback, cloakmixamorig:Spine2
shouldersmixamorig:Spine2mainhandmixamorig:RightHand
bodymixamorig:Spine1offhandmixamorig:LeftHand
handsmixamorig:RightHandneckmixamorig:Neck
belt / legs / feetmixamorig:Hipsring1 / ring2mixamorig:{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).

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
flagwhat it does
--half x+keep one side of the source pair (cuts by triangle centroid)
--size3 x,y,zper-axis target extents in metres, (lateral, vertical, front-back)
--at <bone>anchor the box centre at that bone’s head; --offset nudges from there
--mirrorxduplicate 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
--wpowfalloff 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.

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:

  • Fit is 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.

blender --background --python scripts/blender/build-wings.py -- --all
npm run normalize:armor && npm run verify:armor

build-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:

requirementwhy it fails silently otherwise
exactly one root bonetwo roots are two chains with no shared anchor; one fit row can never place both
a Left* / Right* prefix on every chain boneclip mirroring keys off the name; an unprefixed side never moves
symmetric chainsone authored clip drives both sides
a readable node hierarchywithout 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 mwingtips 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.

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.

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. twoSided makes the resolve face the normal towards the viewer (material_id bit 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.

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:

stepwhat drops it
Blender materialthe exporter picks RGB or RGBA from the material, not the image: link Alpha and set blend_method = 'BLEND' or the texture ships opaque
createImageBitmapthe default premultiplies, which darkens RGB wherever alpha < 1; equipment-visuals.ts passes premultiplyAlpha: 'none'
the atlasCHAR_ATLAS_FORMAT is rgba8unorm and the blit passes RGBA through; check it if either changes
the WGSL gateskinned-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.

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).

Dev-console helpers:

probewhat 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

npm run normalize:armor
node scripts/rebase-armor-glb.mjs public/models/characters/armor/<piece>.compressed.glb
npm run verify:armor

normalize: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.
  • POSITION and NORMAL FLOAT, and no KHR_mesh_quantization
  • JOINTS_0 (u8 or u16, not normalized) and WEIGHTS_0 either float or a normalized integer
  • indexed, u16 or u32, not u8
  • a skin with inverseBindMatrices
  • one embedded baseColorTexture via 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.socket a 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:gear

verify: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.


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.


'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).

probewhat 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 tousewhy not the others
place one boot of a pairsides: { Left: {...} }a bones row moves only that joint’s share of each vertex, so the boot stays half attached and stretches
move the whole objectpiece: {...}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 rowbones/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).


npx tsc --noEmit && npm run test:anim && npm run test:combat && npm run verify:armor && npm run verify:gear

In 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_armor

Armour 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 = I it also reduces to the rigid result: inv(IBM_w[j]) · IBM_a[j] is then the same global frame change for every j, 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.


  • No clearance solver. Placement is numeric (fit-armor-piece.py plus measure: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. --pca puts 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 with preview-glb.py before 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.py discards them. Use the PBR base colour (a true albedo).
  • The cuirass is rigid. steel-cuirass rides Spine1 alone, so the torso’s own bend is not followed and a deep crouch shows some interpenetration. A weighted version would span Spine to Spine2, and sleeved body pieces would also need Arm and ForeArm.
  • Weighted retargeting across bodies is a fit problem, 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-body fit rows and ArmorFit.bones overrides 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.