Rebuilds character rigging, animation and the surrounding tooling toward an
AAA shooter workflow: characters that arrive as separable parts and know what
those parts are, a rig that can be adjusted by eye instead of by constant, and
one UI theme across every screen.
43 commits, 168 files.
Characters are separable, and now they say so
The pipeline already refused to join meshes, so a character arrived as the
eighteen pieces the artist authored — but nothing recorded what they were, so
every system downstream re-guessed from the material. That guess ("untextured
and nearly black means ink") had already rendered the mannequin's flat yellow
body as a black silhouette once.
tools/surface_map.py answers it once, at build time, three ways in descending
order of trust: the material name (formal on VRoid exports), the weights (a
surface pulled by the skirt chain is a skirt whatever it is called), and the
material flags (which catch the model's own line-work). The answer goes in the
rig sidecar and SkinSurfaces reads it.
All 18 of Taila's surfaces and every surface of the other five skins resolve
from the table with zero falling through to the heuristic. The heuristic
stays as the fallback, which is the one job it was ever right for.
That buys per-class art direction, impossible while every surface took numbers
calibrated on skin: hair takes a much thinner outline — at the body's 5 mm each
strand's hull swallows its neighbour and the head reads as a solid dark cap —
cloth a heavier one and a crisper terminator, accessories the heaviest. body
is unchanged on purpose. That required moving the outline from the instance to
the surface, since Miku's body, face and hair are three surfaces of one mesh.
The rig is adjustable by eye
debug/rig_lab.tscn. Pick a character, a weapon, a pose or a single clip.
Anchors are draggable. The coloured markers are the points the hands are
solved onto, and they are handles now. The one under the mouse swells and
draws through the body, because at any useful framing the hands occlude all
three.
Hands move off the barrel line.gun_fore and gun_stock are distances along the weapon, so both hands could slide up and down its axis and nowhere
else — no use for a handguard below the bore or an angled foregrip. grip_shift / fore_shift are in the gun's own frame, so a sideways nudge
stays sideways as the weapon pitches.
Wrists rotate in three axes, and rotate the hand rather than the gun.
The weapon is a child of a BoneAttachment3D on the trigger hand, so the two
were welded: every degree the wrist turned swung the barrel the same degree
and took every control that could correct it along with it. The pose layer now
publishes the exact counter-rotation and the mount applies it.
Sliders belong to the pose on screen. The hold is per pose and the panel
rebuilds when you change it. Two poses, not four, because the runtime blends
between exactly two on ads — Running and Crouched use the low-ready hold and
the heading says so. pitch exists at low ready only: down the sights the
muzzle follows the camera, so a slider there would do nothing.
Surface classes can be isolated. Click hair; anything else still
standing was misclassified.
TuningStore holds the layering (defaults → character → subject) behind both
files. An absent file means "use what the code derives", so none of this is
required for the game to run.
One UI theme, and character selection where it belongs
The theme was a comic one. It is now a charged one — near-black violet, hot
papaya, and a lightning yellow spent nowhere except the instant a button is
pressed. Chips lean on a diagonal; BoltRule draws the motif.
The pause menu was 900 lines that never referenced UITheme at all and rendered
in Godot's default grey. It does now — applied to its own root Control, not only
the Window, because a Control inherits from its nearest Control ancestor and
this screen hangs off a CanvasLayer.
And it has a Character screen: the roster on the left, the character
themselves on the right, turning. A real SkinnedPlayerModel, so it shows
exactly what will spawn — same cel look, same per-class outlines, same cloth and
hair on springs. Selecting applies immediately.
Verification
Eight headless checks, all green, plus screenshot tools:
Check
Asserts
spawn_smoke_test
spawn, skins, anim tree, camera, state cycling
surface_class_check
every surface resolves from the sidecar, not the fallback
character_picker_check
the roster loads, classifies, and the pose MOVES
rig_anchor_check
a grip anchor moves the weapon, and clears
anchor_shift_check
the hand anchors move in the GUN's frame, both poses
anchor_drag_check
a drag writes the knob the mouse asked for (0.00–0.01 mm)
hold_pose_check
no pose shows another's knobs; all 12 wrist axes turn
wrist_gun_check
the wrist turns the hand ~28° and the gun < 1°
Cloth unchanged at 18.4 mm idle; stride 2.4° off travel.
Several of these exist because the obvious check passes on a broken system.
Asking a model which clip it is playing reads a variable it sets on itself and
says "Idle" just as happily when nothing is ticking. An anchor system is easy to
build so the sliders move, the file saves and the gun does not budge. And
measuring anything through a pose layer from a SceneTree script reports the animation — Godot restores every bone's local pose after the modifier pass,
which cost a cycle here again despite the repo having a reference section headed
READ THIS FIRST about exactly that.
Four of the last five real defects came from looking at a PNG, not from an
assertion — a preview showing the back of the character's head, a turntable that
carried on from the previous character, an unstyled list, and momo's idle pose. roster_capture.gd and ui_capture.gd exist for that.
Known and not fixed
momo's idle pose is wrong — arms overhead, pinched waist. Diagnosed: Root_001–Root_007 are her hair roots and are in driven_bones, so the
animation keys bones the cloth solver should own. The name-based fix would
cost a rig whose real root is called Root its hips; the structural fix needs
a Blender re-run and re-verification of all six characters.
A stray 42-vertex Icosphere ships inside every character GLB, from the
animation library. Harmless, now skipped by construction rather than by name.
Pre-existing: peak cloth clipping ~95 mm in run/slide/dash, no foot IK, no
strafe or backpedal clips.
Rebuilds character rigging, animation and the surrounding tooling toward an
AAA shooter workflow: characters that arrive as separable parts and know what
those parts are, a rig that can be adjusted by eye instead of by constant, and
one UI theme across every screen.
43 commits, 168 files.
## Characters are separable, and now they say so
The pipeline already refused to join meshes, so a character arrived as the
eighteen pieces the artist authored — but nothing recorded *what* they were, so
every system downstream re-guessed from the material. That guess ("untextured
and nearly black means ink") had already rendered the mannequin's flat yellow
body as a black silhouette once.
`tools/surface_map.py` answers it once, at build time, three ways in descending
order of trust: the material name (formal on VRoid exports), the weights (a
surface pulled by the skirt chain is a skirt whatever it is called), and the
material flags (which catch the model's own line-work). The answer goes in the
rig sidecar and `SkinSurfaces` reads it.
All 18 of Taila's surfaces and every surface of the other five skins resolve
from the table with **zero** falling through to the heuristic. The heuristic
stays as the fallback, which is the one job it was ever right for.
That buys per-class art direction, impossible while every surface took numbers
calibrated on skin: hair takes a much thinner outline — at the body's 5 mm each
strand's hull swallows its neighbour and the head reads as a solid dark cap —
cloth a heavier one and a crisper terminator, accessories the heaviest. `body`
is unchanged on purpose. That required moving the outline from the instance to
the surface, since Miku's body, face and hair are three surfaces of one mesh.
## The rig is adjustable by eye
`debug/rig_lab.tscn`. Pick a character, a weapon, a pose or a single clip.
- **Anchors are draggable.** The coloured markers *are* the points the hands are
solved onto, and they are handles now. The one under the mouse swells and
draws through the body, because at any useful framing the hands occlude all
three.
- **Hands move off the barrel line.** `gun_fore` and `gun_stock` are distances
*along* the weapon, so both hands could slide up and down its axis and nowhere
else — no use for a handguard below the bore or an angled foregrip.
`grip_shift` / `fore_shift` are in the gun's own frame, so a sideways nudge
stays sideways as the weapon pitches.
- **Wrists rotate in three axes**, and rotate the **hand** rather than the gun.
The weapon is a child of a BoneAttachment3D on the trigger hand, so the two
were welded: every degree the wrist turned swung the barrel the same degree
and took every control that could correct it along with it. The pose layer now
publishes the exact counter-rotation and the mount applies it.
- **Sliders belong to the pose on screen.** The hold is per pose and the panel
rebuilds when you change it. Two poses, not four, because the runtime blends
between exactly two on `ads` — Running and Crouched use the low-ready hold and
the heading says so. `pitch` exists at low ready only: down the sights the
muzzle follows the camera, so a slider there would do nothing.
- **Surface classes can be isolated.** Click `hair`; anything else still
standing was misclassified.
`TuningStore` holds the layering (`defaults` → character → subject) behind both
files. An absent file means "use what the code derives", so none of this is
required for the game to run.
## One UI theme, and character selection where it belongs
The theme was a comic one. It is now a charged one — near-black violet, hot
papaya, and a lightning yellow spent nowhere except the instant a button is
pressed. Chips lean on a diagonal; `BoltRule` draws the motif.
The pause menu was 900 lines that never referenced `UITheme` at all and rendered
in Godot's default grey. It does now — applied to its own root Control, not only
the Window, because a Control inherits from its nearest Control *ancestor* and
this screen hangs off a CanvasLayer.
And it has a **Character** screen: the roster on the left, the character
themselves on the right, turning. A real `SkinnedPlayerModel`, so it shows
exactly what will spawn — same cel look, same per-class outlines, same cloth and
hair on springs. Selecting applies immediately.
## Verification
Eight headless checks, all green, plus screenshot tools:
| Check | Asserts |
|---|---|
| `spawn_smoke_test` | spawn, skins, anim tree, camera, state cycling |
| `surface_class_check` | every surface resolves from the sidecar, not the fallback |
| `character_picker_check` | the roster loads, classifies, and the pose MOVES |
| `rig_anchor_check` | a grip anchor moves the weapon, and clears |
| `anchor_shift_check` | the hand anchors move in the GUN's frame, both poses |
| `anchor_drag_check` | a drag writes the knob the mouse asked for (0.00–0.01 mm) |
| `hold_pose_check` | no pose shows another's knobs; all 12 wrist axes turn |
| `wrist_gun_check` | the wrist turns the hand ~28° and the gun < 1° |
Cloth unchanged at 18.4 mm idle; stride 2.4° off travel.
Several of these exist because the obvious check passes on a broken system.
Asking a model which clip it is playing reads a variable it sets on itself and
says "Idle" just as happily when nothing is ticking. An anchor system is easy to
build so the sliders move, the file saves and the gun does not budge. And
measuring anything through a pose layer from a SceneTree script reports the
**animation** — Godot restores every bone's local pose after the modifier pass,
which cost a cycle here again despite the repo having a reference section headed
READ THIS FIRST about exactly that.
Four of the last five real defects came from looking at a PNG, not from an
assertion — a preview showing the back of the character's head, a turntable that
carried on from the previous character, an unstyled list, and momo's idle pose.
`roster_capture.gd` and `ui_capture.gd` exist for that.
## Known and not fixed
- **momo's idle pose is wrong** — arms overhead, pinched waist. Diagnosed:
`Root_001`–`Root_007` are her hair roots and are in `driven_bones`, so the
animation keys bones the cloth solver should own. The name-based fix would
cost a rig whose real root is called `Root` its hips; the structural fix needs
a Blender re-run and re-verification of all six characters.
- **A stray 42-vertex `Icosphere`** ships inside every character GLB, from the
animation library. Harmless, now skipped by construction rather than by name.
- Pre-existing: peak cloth clipping ~95 mm in run/slide/dash, no foot IK, no
strafe or backpedal clips.
Outline: the inverted-hull width was tuned when the look was heavier and
now reads as a thick marker stroke. Cut every call site to roughly a third
(characters 0.012 -> 0.004, level geometry 0.015 -> 0.005, Neon Alley's
size-scaled clamp 0.02..0.05 -> 0.007..0.018) plus the shader default, so
the ink line stays readable without fattening every silhouette.
Third-person weapon on HumanoidModel: the gun was parented to root_pivot at
a fixed (-0.15, 1.0, 0.4) — floating 40 cm off the chest while both arms
posed at it from a distance. Give the rig a real right-hand node at the end
of the forearm and hang the weapon there, so it travels with the arm. Its
aim is re-derived from the body each frame (hand_r_pivot's global basis is
copied from root_pivot), otherwise the barrel would swing around with the
arm animation. The hold pose is retuned to match: right elbow tucked at the
ribs with a level forearm, left arm crossing to the handguard.
_seat_weapon_in_hand() slides each weapon along its own barrel axis so a
plausible grip lands in the fist regardless of where the artist left the
model origin. It measures with MeshInstance3D.get_aabb(), not
mesh.get_aabb() — the FBX gun parts are skinned, so the Mesh resource still
carries bind-pose bounds tens of metres across.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two separate causes behind "too glossy with a thin white outline":
1. The model carries its OWN cel line-work as extra, UNTEXTURED surfaces —
an inverted-hull outline shell plus eye-line/highlight cards (Taila names
them FullBlack / EyesFullBlack / EyesInvL / EyesHL). They are authored to
read as flat black, but the glTF import hands them a default near-white
albedo, and apply_toon_recursive then LIT them. That is the thin white
rim: a pale, toon-shaded outline shell tracing every hair strand. They now
render unshaded flat ink (flat white for the "HL" highlight card).
2. The skin textures already have cel shading painted in. Stacking our hard
3-tone break on top produced a bright stripe that slid across the hair as
the camera moved — the "gloss". Characters now get a soft terminator and
an almost-invisible second step, so the painted shading carries the form.
Both live in a new LevelMaterials.apply_character_look(), called only for
imported character GLBs, so props and level geometry keep exactly the crisp
banding they were calibrated with. The one shared change is that the toon
shader's second-step darkness is now the mid_tone uniform, defaulting to the
0.82 that was hardcoded — no change for existing callers.
Character shadow tint (0.64, 0.56, 0.72) is measured off the Sketchfab
reference render rather than guessed: sampling the hair there, shadow/midtone
lands near (0.63, 0.53, 0.70). Green drops hardest, which is what keeps
copper hair copper instead of washing it to brown.
debug/character_look_capture.gd renders the raw import beside our treatment
under matched lighting — the comparison this was tuned against.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Three animation complaints, measured before changing anything.
There is NO squash-and-stretch. Instrumented every bone every frame: the
worst deviation of a pose basis from a pure rotation is 0.00000. Every clip's
position tracks match the skeleton's rest pose to within a millimetre (only
DEF-hips translates, as it should) and no scale key deviates from 1. The
"squishing" is a deep BEND, not a scale: the shared library's PistolReload was
authored for someone watching their own hands, and it dives the head 33° with
the whole neck chain at 67°. At that depth Taila's head/hair weights pinch and
the skull changes shape. Dropping neck+head from the upper-body one-shot's
filter leaves them on the locomotion layer: head dive is now 14° and the neck
chain 48°, and the character keeps looking downrange through a reload, which
is what a shooter wants anyway. Trade-off: Throw/Hit no longer swing the head.
The ankle cuffs are not linked by the rig or the clips — the foot bones swing
independently (left/right Z correlation -0.94). They are linked by SKIN
WEIGHTS: 16 vertices belonging to the LEFT cuff, sitting at x = -0.002 just
across the centre line, are weighted to the RIGHT leg, so the far foot drags a
band of cuff across the gap. Position alone cannot classify them (the cuff
they belong to spans x = 0.00 .. 0.05), so SkinWeightRepair decides by
CONNECTIVITY: a vertex that disagrees with 80%+ of the vertices it shares
triangles with gets its leg influences mirrored. Ankle-height cross-leg
triangles go 24 -> 0 on Taila; the pass is conservative enough that Miku needs
exactly 1 vertex and her face/hair/skirt are untouched (only leg bones are
ever considered). Meshes with blend shapes are skipped rather than rebuilt.
Blending: nothing cuts hard any more. Base cross-fade 0.15 -> 0.22, locomotion
0.28-0.30 (Idle/Walk/Run/Sprint switch constantly as speed drifts across their
thresholds, which is where the snapping showed most), reaction moves raised
off their near-instant values but still snappy, and the upper-body one-shot's
own fades widened to 0.14 in / 0.22 out.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
1. Ankle cuffs. The previous pass re-weighted the offending vertices to the
nearer leg, which was the wrong call: the strip between the ankles is REAL
geometry spanning the gap (~10 cm of ClothCAndW crossing x = -0.05 .. +0.05
at ankle height), so re-weighting only tore it in half — a visible seam that
still stretched. A triangle with one corner weighted to each leg has no
correct pose; it must stretch the moment the legs separate. SkinMeshRepair
now deletes those triangles instead (48 on Taila: the cloth band plus its
outline shell). The cut is limited to BELOW THE KNEE, taken from the
skeleton's own rest pose rather than a hardcoded height, because above the
knee cross-leg geometry is legitimate — the skirt and shorts genuinely span
left-thigh to right-thigh weights at the crotch.
2. The M4 floated because _measure_weapon derived the grip from mesh AABBs, and
the FBX guns report bind-pose bounds tens of metres across — it measured the
M4 as 24 m long and pushed the gun 7.5 m in front of the character. Bounding
boxes are simply not trustworthy for these meshes. The grip was already
authored elsewhere: WeaponManager places the first-person viewmodel's hands
at fixed points in weapon space, and every weapon marks its barrel tip with
muzzle_flash.position. Those move to WeaponGrips (dependency-free, so both
the weapon system and the character models can use it without dragging each
other's load order along) and third person now reaches for exactly the
points the viewmodel uses. Every weapon's grip now lands 0.073 m from the
hand — the M4 included, down from 2.32 m — with barrel lengths that match
the models (0.30 m for the MP7, 0.68 m for the DMR).
3. Muzzle flash and tracers were spawned off the viewmodel's muzzle. The
viewmodel is parented to the camera, so its muzzle sits inside the player's
head — in third person the flash appeared by the character's shoulder.
world_muzzle() returns the muzzle of the gun actually in the character's
hands whenever the character is what the viewer sees, and the networked
fire-effect RPC now sends that position too, so remote players stop seeing
tracers leave the shooter's head. Measured: the third-person origin sits at
the held gun, 0.54 m below the head, instead of on the camera.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The "linked ankle cuffs" and the "squashed legs" were the same thing, and it
was never the cloth or the weights: it is the model's OWN outline hull.
Isolating it took rendering the raw GLB in a bright scene and toggling one
surface group at a time. The hull ("FullBlack" and "material", the untextured
surfaces that trace the body and hair silhouettes) is a duplicated shell whose
skin weights do not track the base mesh through a deep bend. During a run it
tears into spikes and stretches sheets across the ankles. Hiding just those
surfaces makes the legs render perfectly clean, with no other change — so the
two previous attempts here were both treating symptoms:
* re-weighting stray ankle vertices only tore the cloth (reverted last time)
* deleting cross-leg triangles removed real geometry for no benefit
(SkinMeshRepair is deleted in this commit — the clean render above was
produced WITHOUT it)
The hull is also redundant: characters already get an inverted-hull overlay
from apply_toon_recursive AND the screen-space ink_edge pass, so dropping it
costs nothing visually. The EYE cards (EyesFullBlack lashes, EyesInvL, EyesHL
highlight) are real facial features rather than a hull, so those are kept, flat
as before. They are told apart by name prefix, not by guesswork about geometry.
Worth recording: no bone is EVER scaled, in any clip. Measured again here
across the whole Run cycle — worst deviation of a bone's pose basis from a pure
rotation is 0.00000. There is no squash-and-stretch in this rig; it only ever
looked that way because of the shell.
Backpedalling now plays the locomotion cycle in reverse (negative TimeScale)
instead of moon-walking with the forward clip — the library ships no authored
backward run. Directional lean is raised from 0.18/0.30 to 0.30/0.42 rad: with
one forward cycle and no strafe clips, the lean is the only cue for which way
the character is travelling, so it has to be legible rather than subtle.
Blends raised again: base 0.22 -> 0.32, locomotion 0.28 -> 0.40. Verified the
cross-fade genuinely applies rather than snapping — instrumented an idle->run
switch and the thigh ramps gradually over the window instead of stepping on
frame one, with xfade_time reading 0.400 s on the Transition node.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Found the real cause of the leg stretching, after three wrong guesses.
720 vertices on Taila are weighted to BOTH legs at once below the knee — 262 in
the boot/cuff surface, 208 in the outline shell, 176 in the body, the worst at a
dead-even 49/51 split. A vertex pulled equally by both feet sits halfway between
them and STAYS there while the legs separate, stretching every triangle around
it. That is the band between the ankles and the elongated boot that reads as the
legs being squashed. SkinLegRepair snaps each below-knee vertex to whichever leg
already dominates it, renormalises, then drops the few triangles still spanning
the two legs (48 on Taila, 0 on Miku, which needs 214 vertices snapped).
What finally located it was reproducing the artefact through the FULL runtime
stack in a bright scene. Every previous "clean" render had bypassed the game's
animation stack — playing the clip straight off the GLB's AnimationPlayer with
no AnimationTree and no pose modifier — so it could never show the bug. With the
real stack the boot visibly stretches, and freezing the AnimationTree at the
rest pose renders it perfectly, which proves the pose is fine and the skinning
is not. Confirming measurements, all through the runtime stack during a run:
no bone's pose basis deviates from a pure rotation by more than 0.00001, no
bone's length drifts from its rest offset by more than 0.0000 m, and no
below-knee vertex is influenced by any non-leg bone. There is no squash-and-
stretch in this rig; there never was.
The two earlier attempts at this are both superseded: re-weighting by
triangle-neighbour majority (tore the cloth) and deleting cross-leg triangles
(too narrow — 24 triangles against 720 bad vertices). Hiding the model's own
outline hull, from the last commit, stays: that hull is genuinely broken and
redundant, it just was not the whole story.
Weapon support hand: gun_fore was a single constant 0.35 m for every weapon, so
on a short gun it hung the off hand out past the muzzle with nothing to hold —
that is the "not supported by the arms" case. It is now clamped to 80% of the
weapon's own grip-to-muzzle distance, which only bites on the short guns (MP7
0.35 -> 0.20). All six weapons now sit in the shoulder with both hands on them.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Legs: the previous pass deliberately stopped at the knee, on the reasoning that
cross-leg weighting above it is legitimate for skirts. It is — but the LEGS
themselves are up there too, and 122 vertices on Taila's thigh mesh carry up to
35% of the opposite leg. Running and jumping are exactly what splits the legs
far enough for that to show, which is why the cuffs came good while the legs
kept squashing.
The repair now runs above the knee as well, but only on vertices that belong to
a leg. Limb membership is decided by distance to that leg's own bone chain, not
by height and not by surface name: a thigh vertex hugs its bone, while a skirt
vertex hangs clear of both chains and is left blended so it can still drape over
the two legs. The radius is half the gap between the thigh bones, so it scales
with the character rather than being a magic number. Taila now snaps 1217
vertices (was 720), Miku 370 (was 214).
Weapons: at low-ready the stock was dropped 0.20 m below the shoulder joint down
to the ribs, with the muzzle pitched 22 degrees down. That pulls the whole
weapon off the shoulder and out of the supporting arm — it read as dangling
from the hands rather than being carried. Low-ready now stays in the shoulder
pocket and merely relaxes out of it (0.07 m drop), with a shallow 9 degree
muzzle tilt. All six weapons now sit in the shoulder with both hands on them.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds debug/limb_deform_check.gd, which skins the leg vertices by hand and
compares them against the rest pose across a sweep of run, walk, jump, fall,
slide and dash. It reports lengthwise stretch (bending a knee can only shrink
this, so growth is genuine stretching) and cross-section loss (the linear-blend
"candy wrapper" collapse at a bent joint).
No gameplay or character code changes in this commit. Current readings on
Taila, through the full runtime stack:
lengthwise stretch 1.05 worst (Body, ClothB, ClothCAndW), 1.03 outline shell
cross-section 0.93 knee, 0.84 hip, 0.91 ankle
Sanity check on the method: the measured rest span of the leg vertex cloud is
0.818 m against 0.843 m of thigh+shin bone length, so the skinning maths is
producing real positions rather than plausible-looking noise.
That is ordinary linear-blend skinning. Combined with the earlier findings — no
bone pose basis deviating from a pure rotation by more than 0.00001, and no
bone length drifting by more than 0.0000 m — the leg geometry is not squashing
or stretching in any measurable way, so there is nothing here to fix blind.
Committing the instrument so the next round starts from a number.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The measurement was comparing skinned lengths against BIND-POSE lengths. A
character is never in bind pose, so ordinary posing registered as deformation:
with the animation frozen it still reported 0.42x-2.56x edge ratios, and during
a run it reported edges collapsing to 0.08x and blowing out to 5.2x. Those
numbers were an artefact of the metric, not the model.
The baseline is now built from the skeleton's REST transforms, so every ratio
reads exactly 1.00 on an unposed model and the tool validates itself. Corrected
readings on Taila through the full runtime stack, worst over run, walk, jump,
fall, slide and dash:
lengthwise stretch 1.07 Body, 1.08 ClothB, 1.09 ClothCAndW, 1.03 shell
cross-section 0.97-0.99 knee, 0.85 hip, 0.92-0.94 ankle
That is ordinary linear-blend skinning.
Also records, in the header, the two ways this measurement was previously wrong,
because both nearly produced a bad "fix":
* the bind-pose baseline above;
* flagging vertices weighted across "non-adjacent" leg bones with
max_slot - min_slot >= 2, which flags shin+foot+toe — a perfectly normal
contiguous run. A repair pass built on that was written and reverted before
it shipped; it was stripping legitimate toe and shin influences.
Checks that did come back clean and are worth not repeating: bone pose bases are
pure rotations to 0.00001, bone lengths never drift from their rest offsets, and
of 4019 coincident-vertex groups not one carries mismatched weights, so there
are no split seams tearing at the joints.
No character or gameplay code changed. skin_leg_repair.gd is byte-identical in
behaviour to the last commit; only its header comment gained a pointer to this
tool.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Hiding the skirt and jacket and rendering the legs alone, side-on, finally
showed where the deformation actually is: not the knee, the HIP. The top of the
thigh flattens into a wedge at full stride. That matches the worst number the
corrected measurement reports — cross-section 0.85 at the hip, against
0.97-0.99 at the knee — which had been dismissed as ordinary.
Cause is the usual linear-blend failure at a big rotation: upper-thigh vertices
are weighted between the near-static hips and a thigh swung far out, and the
average of those two transforms collapses the top of the leg. SkinLegRepair now
caps torso (hips/spine) influence on LEG vertices at 15% and hands the excess to
the leg bone that already dominates them. Only vertices within the limb radius
of a leg bone chain are touched, so the skirt keeps swinging from the hips.
Hip cross-section 0.85 -> 0.88, and the flattened wedge is visibly fuller in the
side-on render. This is a real improvement but a modest one; the legs are slim
low-poly geometry and some collapse at a 60 degree hip rotation is inherent to
linear-blend skinning.
Taila now snaps 1217+ vertices, Miku 434.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This is the leg squashing. It was at the KNEE, and every previous measurement
missed it because they averaged a ring of vertices, which hides a collapse that
only affects part of the ring.
Measured per vertex instead, against an exact rest-transform baseline: 532 of
Taila's leg vertices lose more than 20% of their distance from the bone axis and
the worst lose 40%, all at y=0.54 — exactly the knee — and worst in the air pose.
That is "squashing while running or jumping, easy to see from the side".
Cause is inherent to linear-blend skinning: it averages TRANSFORMS, not shapes.
A vertex half on the thigh and half on the shin is placed at the average of two
rotations, and at a hard bend that average falls well inside the leg, pinching
it to a ribbon.
SkinKneeHelper adds a bone at each knee, parented to the thigh and holding the
shin's rest transform, driven every frame to HALF the shin's rotation from
inside the skeleton's modification pass. Blend-zone vertices are re-weighted off
the thigh/shin pair and onto the helper, taking equal weight from each side and
reusing the slot that falls to zero — so no extra influence slot is needed and
the weights still sum to exactly 1. The sharpest blend any vertex now sees is
half the knee angle, and halving the angle roughly quarters the collapse.
Result: vertices losing more than 20% of their radius go from 532 to 0 on the
body. The worst anywhere is now 0.79, on the outline shell, which is hidden.
Verified visually too, legs rendered bare from the side through rest, run and
jump: knees hold their volume and the bind is intact.
FSM tests 11/11, spawn smoke test 0 failures, Miku unaffected.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
It was the SKIRT, not the legs. Every leg check I ran hid the skirt to see the
thighs, so I was never looking at what is actually on screen. Rendering the full
model side-on shows it immediately: at rest the skirt is a flared shape with
real volume, and the moment the legs split for a run or a jump it collapses flat
against the body. The lower-body silhouette loses its shape, which reads as the
legs squashing.
Cause: the skirt is weighted to BOTH thighs. At a split stride the two thighs
pull it in opposite directions and linear-blend skinning averages them, so the
skirt is dragged inward and flattened. The earlier repair deliberately left it
that way, on the reasoning that a skirt should be free to drape across both
legs — correct in principle, wrong at these stride angles.
Draping cloth now hangs from the hips: vertices that clear both leg bone chains
keep at most 20% leg influence and the rest goes to DEF-hips, so the skirt holds
its shape and swings as a unit. Limb vertices are still snapped to one leg, so
the two are now modelled differently on purpose.
Bounded ABOVE THE KNEE. The first attempt used distance from the bone chain
alone, and chunky boots sit further from the ankle than the limb radius, so they
read as "drape", lost their leg weight and trailed off the hips — the legs came
out far worse than before. A skirt hem is above the knee; boots and stockings
are not.
Legs unchanged by this: knee cross-section stays 0.99, and the knee helper work
from the previous commit is intact. Taila snaps 4024 vertices, Miku 670.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The previous commit stiffened the skirt onto the hips to stop it flattening at a
wide stride. That was the wrong trade: it made the clothes read as a rigid shell
welded to the pelvis, which is a worse look than the flattening it fixed, and it
broke the model more than the original bug did. Reverted in full.
What stays is the work that makes the HUMANOID hold its shape — below-knee side
snapping, the above-knee limb pass, the torso cap, the half-angle knee helpers,
and the cross-leg triangle drop. Those only ever touch vertices hugging a leg's
own bone chain.
The header now states the rule the passes are actually built around, because it
is what keeps getting violated: limb vertices are cleaned up hard and must not
deform; drape vertices are left alone and must be free to move. It also records
the skirt-stiffening attempt so it does not get retried.
Measured split now, from debug/limb_deform_check.gd: the body holds 0.98 of its
cross-section at the knee while the skirt sits at 0.88 — rigid limbs, moving
cloth. Taila snaps 1404 vertices (down from 4024 with the skirt pass), Miku 434.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Renames SkinKneeHelper to SkinJointHelper and generalises it to a list of
[parent, child] joints, so the half-angle joint technique is stated once and can
be pointed at any joint. It stays pointed at the KNEES only, and the header now
says why, because "add more joints" is the obvious next idea and it is wrong
here.
Three things were tried this round and measured, on the thigh, per vertex,
against the rest transforms:
* hip and ankle helpers as well as knees: the knee got WORSE, 0.80 -> 0.50,
and the skirt went 0.80 -> 0.72. The hip mostly swings rather than folding,
and linear-blend skinning handles a swing far better than a fold; the helper
just added another blend for nothing. Reverted.
* gating the re-weight to vertices near the leg bone chain, to keep the hip
helper off the skirt: that also excludes the OUTSIDE of the knee, which sits
further from the bone axis than the limb radius and is exactly the geometry
that needs helping. Knee went back to 0.50. Removed — with knees only there
is no skirt to protect against.
* one joint per vertex (break after the first match): kept, since a second
job would read the bones the first just rewrote.
Net result on the thigh, worst per height bucket during a jump:
y=0.60 0.80 -> 0.83 y=0.75 0.84 -> 0.84
y=0.65 0.81 -> 0.84 y=0.85 0.90 -> 0.90
and the skirt is untouched at 0.80/0.84, still free to move.
FSM tests 11/11, spawn smoke test 0 failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Tried widening the helper bone to cancel the joint collapse. The geometry is
right — blending two rotations θ apart thins anything perpendicular to the bend
by exactly cos(θ/2), which is |q.w| of the pose rotation, so scaling the helper
across the bone by the reciprocal is the exact correction.
It does not work, because a BONE scale is the wrong granularity. It fattens
every vertex weighted to the helper whether or not that vertex was collapsing:
parts of the thigh ballooned to 167% while the worst-collapsing vertices, which
are not on the helper at all, stayed at 0.83. The idea needs per-vertex
correction and a bone cannot express it. Removed, with the reasoning kept in the
file so it is not retried.
Hip joints were also retested, since two other variables had changed since the
first attempt (one-joint-per-vertex, and the limb gate removed). Same verdict a
second time: upper thigh 0.90 -> 0.84, and the skirt 0.80 -> 0.72. Knees only.
Shipped state is unchanged from the previous commit; this is the reasoning and a
revert of an experiment.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The thighs going thin was never the knee bend. It was inner-thigh vertices
keeping ~20% of the weight of the OPPOSITE leg, so a split stride tore them
across the body. Printing the weight composition of the worst-collapsing
vertices is what finally showed it: a right-thigh vertex reading
DEF-thigh.R=0.58 DEF-shin.R=0.21 DEF-thigh.L=0.13 DEF-shin.L=0.08.
Every earlier pass had a guard that let these through. The below-knee pass does
not reach them; the above-knee pass demanded one leg chain be 1.25x nearer than
the other, which excludes everything near the centre line — precisely where the
damage was; and the limb-radius gates are measured from the bone AXIS, so a
vertex on the front or back of a thigh clears them easily.
SkinLegRepair now ends with an unconditional pass: any vertex within a generous
radius of either leg chain is forced onto the leg that actually drives it, at
any height. It runs last so none of the staged rules can reintroduce the
problem.
Measured per vertex, across jump, fall, run and dash:
before after
Body below 0.80 70 0
Body worst 0.46 0.82
ClothCAndW below 0.80 107 0
ClothCAndW worst 0.66 0.85
ClothB worst 0.81 0.87
No vertex anywhere on the legs now loses more than 18% of its thickness, down
from 54%. The skirt still deforms freely (0.87-0.88) — clothes flow, limbs hold.
Also fixed in SkinJointHelper along the way: it picked the FIRST joint chain
that matched a vertex rather than the one holding most of its weight, which
bound right-thigh vertices with stray left-leg weight to the LEFT knee's helpers
and dragged them across the body. It now picks the dominant joint and requires
the joint to own at least half the vertex.
Taila snaps 2021 vertices, Miku 659. FSM tests 11/11, spawn smoke test 0
failures.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The skirt glitched when the character moved and the thigh still came
through it. Both came from the same place: the solver integrated one
spring per bone and then ran four more passes behind it — resolve the
collision against the target, resolve it again against the answer, relax
the cross-panel links and rebuild every pose from the corrected tips,
then walk a separate ancestor "lift" — each writing bone poses the next
read back and partly undid. The lift wrote poses that were never fed back
into the spring state at all, so every frame began by pulling against a
pose the springs did not know about.
Replaced with one position-based solve, the shape Magica Cloth 2's
BoneCloth uses. Every JOINT is a particle, so a bone's head can move;
predict with inertia in the anchor's frame; relax length, bend, backstop,
the cross-panel links and the colliders together; convert to rotations
once at the end. A contact with no rotational leverage is now resolved by
the panel moving, which is what a bodily chain push, an ancestor lift and
a drape weight were each approximating separately.
Measured, at a dead-still idle and over a movement sweep:
idle jitter (skirt) 0.53 -> 0.025 deg/frame, worst 24 -> 1.9
settling after a dash 103 -> 18 mm of leg left inside the skirt
fall / air / walk 82 -> 40, 96 -> 75, 72 -> 76 mm
run / slide / dash unchanged, ~95 mm
The idle buzz and the failure to come home after a hard move are gone —
those were the "glitches out". Peak clipping in a run, a slide and a dash
is NOT fixed and is still around 95 mm.
Four things this turned up on the way:
- debug/cloth_clip_check.gd was measuring the animation, not the render.
Godot restores bone poses after the modifier pass, so reading them with
force_update_all_bone_transforms() afterwards sees nothing any modifier
did. It reported the same ~95 mm with collision fully enabled and with
it commented out. It now observes from inside the pass. Every number
ever taken from this tool before now was measuring the wrong pose.
- The collision hulls came from ten farthest-point samples per bone,
which describe a panel's corners and hem and leave its MIDDLE unsampled
— exactly where a thigh comes through. Built from the real mesh at load
time instead.
- The drape term is gone. It was there to move a panel the old solver
could not, and once the solver could, it was worse in every state but a
walk and cost 20x in stability: its target sat inside the leg the
collision was pushing out of, so the two ran against each other forever.
- Cost was 10.9 ms per character. The inner loop rebuilt every capsule
and reallocated the hull array for every (bone, collider, pass). Now
2.6 ms at full quality with a distance LOD behind it.
Co-Authored-By: Claude Opus 5 <[email protected]>
The clip library has one forward locomotion cycle and no strafe or
backpedal clips, so a character sidestepping ran forwards on the spot
while sliding sideways. Nothing in the animation said which way they were
travelling and a body lean was carrying the whole burden of telling the
player.
Yaw the HIPS onto the travel direction and unwind it up the spine. The
legs hang off the hips, so the whole stride turns with them and it costs
no new animation; the chest keeps facing roughly where the player aims.
Past about a right angle the hips cannot follow, so the cycle plays in
reverse and the legs point the other way — a real backpedal instead of a
moonwalk. The regime is hysteretic and the yaw is eased, so crossing
between them reads as a pivot, which is what a person does there.
The lean moved into the travel frame with it. Leaning "forward" along the
facing while the legs run off to one side leans them sideways relative to
their own stride, which is what being dragged rather than running feels
like. It is also driven by how hard the character is moving rather than
by signed forward input, so a sidestep leans into its own stride instead
of standing straight up.
debug/travel_dir_check.gd measures it. How far the stride points from the
actual direction of travel:
forward 9° fwd-diagonals 5-6° backpedal 2° back-diagonal 2°
pure sidestep 39-42°, which is the deliberate hip cap
Two things worth knowing about that tool. It reads bone poses from inside
the modifier pass, because Godot restores them afterwards and anything
read later is the animation with the pose layer missing. And it averages
over a full stride: a run cycle twists the torso against the hips by tens
of degrees twice per stride, so a single-frame sample measures the clip,
not the layer.
Co-Authored-By: Claude Opus 5 <[email protected]>
Everything about getting an anime-styled character into the game — import,
stylization, the body/garment/hair separation, rigging, retargeting, cloth
and hair physics, and how to measure any of it — collected into
.claude/skills/character-pipeline/.
Organised around the principle the Hoyoverse-class pipelines are built on
and that every failure in this project traced back to: a character is not
one object. It is a body, a set of garments and hair, authored and rigged
separately and moved by different systems. The body is skinned and
animated; the garments and hair are bone chains the animation never
touches and physics moves. The skill's non-negotiables are the four ways
that separation has been destroyed here before — joining meshes, keying
cosmetic bones, skinning cloth to a leg, and running the auto-rig repair
on authored weights.
references/verification.md leads with the trap that invalidated every
cloth measurement ever taken in this repo: Godot restores bone poses after
the modifier pass, so a tool that reads them afterwards measures the
animation and never sees what any modifier did.
Also records what is known-unsolved, with numbers: peak cloth clipping in
a run/slide/dash, no foot IK, no strafe clips.
Co-Authored-By: Claude Opus 5 <[email protected]>
The two shipped characters are a controlled comparison: Taila's source
arrived rigged (35 cloth chains, 8 twist bones, 18 meshes, artist
weights), Miku's did not (5 meshes, 0 joints), so Miku was auto-rigged
into one mesh with nearest-bone weights and no cloth chains at all. Her
twin tails and skirt are dead geometry and the destructive load-time
weight repair runs on her every spawn.
None of that is recoverable downstream, which makes picking a source that
already has skirt and hair bones the highest-leverage decision in the
pipeline. Added the no-Blender check for vetting a candidate.
Co-Authored-By: Claude Opus 5 <[email protected]>
Miku shipped with 0 cloth chains against Taila's 35, so her twin tails
hung off her skull like a helmet. Nothing downstream could fix it: the
spring solver simulates cloth BONES, and a garment with none is welded to
whatever body bone it was weighted to. Every auto-rigged model is in that
state, and her source was an unrigged mesh.
tools/cloth_bones.py builds them, which is the job a technical artist does
by hand on a model like this. It finds the geometry by MATERIAL SLOT — the
artist already answered which surface is hair, and on a joined mesh (what
the auto-rig leaves behind) the slot is the only separation left. Hair is
split into connected islands, because a strand is a connected piece of
surface and clustering by position would merge two ponytails passing near
each other. A skirt is split into radial wedges instead, because a skirt
is ONE connected surface and islands would return the whole thing as a
single piece — the bell-shaped failure. Each clump gets a polyline fitted
down its middle by binning vertices by distance and taking centroids, so
the chain follows the piece's own curve rather than cutting the corner on
a bend, and vertices are re-weighted onto it while the first 22% keeps its
original body weight so the scalp stays on the skull.
On Miku: 19 chains, 57 bones from one `hair` slot. Sidecar 0 -> 19 chains.
Idle stability 0.007-0.018 deg/frame. Mesh intact, verified by render.
Opt-in, via `pipeline.py --grow-cloth`, and run before the retarget so
describe_rig() finds the chains by name exactly as it would an artist's.
Known limits, recorded in the skill: it cannot find a garment sharing a
material with the body (Miku's skirt is on her `body` slot, so she got
hair and no skirt), and grown chains are a fallback — an artist's chains
carry intent that no geometric fit recovers.
Co-Authored-By: Claude Opus 5 <[email protected]>
A third playable character, built with the pipeline skill from a source
that was already in the repo: the animation library ships a rigged
Mannequin mesh on the exact 53-joint reference skeleton, CC0, so it needed
no download and retargets perfectly. 18 clips, 0.3% cross-leg bleed, 7% of
verts at four influences — a clean authored-weight import. Licence
recorded in mannequin.license.json as the other skins do.
It has no cloth chains, correctly: it is a mannequin and has neither hair
nor clothes.
Importing it turned up two real bugs, both of which would have hit any
flat-coloured or single-piece model:
- LevelMaterials.apply_character_look treated ANY untextured surface on a
character as the model's own outline shell and hid it, so the mannequin
rendered as a solid black silhouette — its body and joint materials are
untextured flat colours, not ink. _is_line_work() now asks whether the
surface is named eyes*, is drawn front-face-culled (the inverted-hull
setup), or is near-black. Taila and Miku are unaffected: their materials
are textured and never reach that branch. Verified by render.
- verify_character.py failed the build for having one mesh. That check
cannot tell "the pipeline joined them" from "the artist authored one
mesh" — Quaternius' mannequin is one piece on purpose. It is advisory
now; the join path's two unambiguous signatures, cross-leg bleed and the
4-influences-everywhere spread, are still hard checks.
Also restored Miku's description, which the re-import had blanked.
3 GLB skins selectable (6 with the built-in colour skins). Smoke 0
failures, 11/11 movement tests, cloth idle 0.024-0.078 deg/frame.
Co-Authored-By: Claude Opus 5 <[email protected]>
Kiyoko, Hikari, Aria and Momo, all CC-BY from Sketchfab, licences recorded
beside each skin. Seven GLB characters selectable now.
kiyoko 13 meshes 20 cloth chains (62 bones) 0.0% cross-leg bleed
aria 15 meshes 15 cloth chains (37 bones) 0.0%
momo 5 meshes 9 cloth chains (35 bones) 0.0%
hikari 13 meshes 10 cloth chains (37 bones) 0.2%, 12 twist bones
These are the first characters imported that were NOT authored against the
library's own bone spelling, and every one of them broke something that
had been quietly wrong all along. All four failures were in code that
guesses anatomy from names, which is exactly what tools/rig_map.py exists
to stop doing:
- LIMB ROLES went to the first role in LIMB_ORDER that matched at all, so
shin's catch-all "leg" claimed UpperLeg before thigh's exact "upperleg"
was ever consulted, and the thigh went unassigned. The result depended
on the order bones arrived in. Claims are now granted longest-stem
first. This also broke Mixamo (LeftUpLeg/LeftLeg) and had simply never
been hit, because every character so far used Rigify DEF- names.
- Names cannot settle thigh-vs-shin at all. A bare "leg" is the SHIN on
Mixamo and the THIGH on a rig whose shin is "knee" — both common, same
token, opposite bones. RigRoles now walks the leg from the foot upward
and fills in whatever the names could not, stepping over twist bones.
- verify_character.py looked for legs by the substrings "thigh"/"shin",
which VRoid spells UpperLeg/LowerLeg. It declared every locomotion clip
static while the legs animated perfectly, and the cross-leg bleed check
found no leg vertex groups at all and passed vacuously. Two green-
looking lies from one missing lookup; both now read the sidecar's
resolved roles.
- The cosmetic/spring classifier matched whole tokens only, so Momo's
HairFL / HairFR / HairF_Top tokenised to "hairfl" and matched nothing.
She imported with six chains, all bust, and no hair. Both classifiers
now share one rule that also accepts a two-character positional suffix,
which is short enough that "forearm" and "earring" are still untouched.
Two more pipeline fixes:
- A source model's own clips leaked into the export. Clearing bpy.data
.actions before the library import is not enough — hikari carried two on
a second armature's NLA tracks, and NLA_TRACKS export mode ships
anything in a track anywhere in the file. They export as rest-pose
statues. Now everything not retargeted is stripped from every object.
- verify_character.py gained a check for cloth chains with no measurable
extent. A chain whose bones are zero-length is dropped by the runtime
and simulates nothing, while the sidecar still cheerfully reports it.
Known: hikari's ten cloth chains are all zero-length and her collider fit
found nothing, so her costume does not simulate — her rig has been through
two toolchains and its cosmetic bones are empty terminators. She animates
correctly otherwise. The new check now reports this instead of hiding it.
tools/retarget.py also carries local working-tree changes that predate
this session.
Co-Authored-By: Claude Opus 5 <[email protected]>
Names lie and anatomy does not — the four VRoid imports each broke role
resolution differently, and the lesson generalises: anything guessing
anatomy from a bone name needs a structural fallback. Also lists what is
shipping and which characters have chains that do not simulate.
Co-Authored-By: Claude Opus 5 <[email protected]>
Seven characters shipped "All checks passed" and four were visibly broken
in game — lying on their backs at seven times scale, facing backwards, or
holding a gun that floated near their chest. Nothing in the suite was
wrong; it just never asked the questions that mattered. That distinction
is the whole lesson, and it is now written down in the skill as
references/failure-modes.md, generalised per failure.
Two root causes are measured and certain:
- flatten_and_scale() normalises the bounding box along Blender Z because
Z is up. For a model that arrives lying along Y that measures the
character's THICKNESS, so it scales by ~7 and leaves them on their back.
One assumption, both symptoms. The trap is that the normalised number
always comes out right — the exporter maps Blender Z to glTF Y, so "is
the height 1.75" passes on a character who is 7.5 m tall lying down.
All three casualties are VRM files that went through a Blender
round-trip and came back with a baked axis rotation.
- SkinnedPlayerModel.set_weapon() finds the hand with three hardcoded
spellings, which match none of the four non-Rigify rigs — their hands
resolve perfectly in the sidecar as "Right wrist" and "J_Bip_R_Hand".
When it misses, the weapon is parented to the model root at a fixed
chest offset, so it is not attached to the character at all. Same class
of bug as the leg check that name-matched thigh/shin. rig_map.py exists
so nothing downstream has to guess a bone name; only some consumers read
the roles it publishes.
Three new hard checks, none needing more than the vertices and the
sidecar:
character stands up in world space — against WORLD up, not against the
model's own proportions. "Is the spine the longest axis?" catches
nothing: a model rotated as a whole is internally consistent and
passes it comfortably.
character is a plausible size / height
every role the runtime needs is resolved
They separate the four good characters from the three broken ones on the
first run. Also fixed the posture measurement to read vertices rather than
object.bound_box, which is cached and still stale right after an import —
it reported a 1.75 m character as 1.18 m tall.
Recorded but not yet fixed: kiyoko faces backwards (facing is inferred by
two independent mechanisms and verified by neither), miku's grown hair
chains stretch under animation (generated chains are never validated
against the geometry they drive), and the mannequin's rifle hold does not
convince despite resolving correctly.
Co-Authored-By: Claude Opus 5 <[email protected]>
Fixes the two root causes behind four of the seven reported breakages, and
rejects the source that cannot be fixed.
STAND UP FIRST. flatten_and_scale() now derives the up axis from the
skeleton and rotates the model upright before measuring anything. Aria and
Momo are correct: 1.75 m tall, 1.43 x 0.41 and 1.52 x 0.39 across, verified
by render.
The up vector is measured from the FEET to the HIPS, not from the hips to
the head. The head is not a reliable landmark — the spine walk ends on the
last non-cosmetic bone in the chain, which on a rig with a facial skeleton
can sit BELOW the hips. Momo's did, so the first cut of this fix stood her
neatly on her head: right size, right proportions, upside down. Feet cannot
be mistaken.
REACH BONES BY ROLE. SkinnedPlayerModel gained _role_bone(), and set_weapon
uses it. Four characters could not hold a gun because one hardcoded lookup
knew three spellings and their hands are called "Right wrist" and
"J_Bip_R_Hand" — both resolved perfectly in the sidecar the whole time.
HIKARI IS REJECTED. She now fails the gate: her feet and spine disagree
about which way is up, so the stand-up correction cannot resolve her
either, on top of zero-length cosmetic bones and a second armature that was
smuggling its own clips into the export. That is not a tuning problem, it
is a file that has been through two toolchains. De-registered and removed
rather than shipped broken — which is what the gate is for.
Six GLB skins remain, all passing. Smoke 0 failures, 11/11 movement tests.
Still open, recorded in the skill: kiyoko faces backwards, miku's grown
hair stretches under animation, the mannequin's rifle hold does not
convince, and taila's front skirt clipping.
Co-Authored-By: Claude Opus 5 <[email protected]>
WEAPON MOUNT, all models. set_weapon() seated the gun with a fixed
rotation_degrees = (0, 90, -90). A bone attachment is expressed in the
BONE's axes and no two rigs agree on those, so one constant mounted the
weapon differently on every character. It never needed to be right: the
pose layer aims the gun by rotating the wrist until the weapon's forward
lies on the aim line, so the identity means "forward is the hand bone's
-Z", which is true on any rig, and the wrist absorbs the roll. The grip
now sits at the bone origin, so the gun is in the hand rather than at an
offset from a differently-oriented bone. Verified by render on kiyoko:
rifle shouldered, both hands on it.
FACING. flatten_and_scale() now measures toes-versus-ankles and snaps the
character to face Blender -Y, the convention the runtime's blanket flip is
built around. Kiyoko was 180 degrees off. Snapped to the nearest quarter
turn so splayed feet in a rest pose are not read as a turned character.
MIKU. Re-imported without --grow-cloth. Her grown hair chains were the
cause of the stretching: cloth_bones.py clears a vertex's body weights and
re-assigns it to the fitted polyline, so a poor fit does not degrade to
"stiff", it degrades to "torn". She now has no hair simulation — stiff but
correct — until that tool blends against the weights it replaces instead
of destroying them.
ARIA'S SKIRT IS NOT SIMULATED. Worth recording plainly, because it looks
better than Taila's and the obvious conclusion is the wrong one: aria has
ZERO cloth chains. Her skirt never clips because it is rigidly skinned and
follows the legs it is weighted to. There is nothing to port to Taila
except switching her simulation off.
Co-Authored-By: Claude Opus 5 <[email protected]>
The hold was wrong on every character in three independent ways, all of
them a constant standing where a measurement belonged.
MOUNT. set_weapon seated the weapon with rotation_degrees = (0, 90, -90).
A bone attachment is expressed in the BONE's axes and no two rigs agree on
those, so one constant mounted the gun differently on every model. It never
needed to be right — the pose layer aims by rotating the wrist until the
weapon's forward lies on the aim line, so the identity means "forward is
the hand bone's -Z", true on any rig, and the wrist absorbs the roll.
SIZE. The set is modelled at real-world scale; an M4 is 0.84 m butt to
muzzle and these characters have 0.47 m arms against an adult 0.52. That
put the handguard 0.66 m from the support shoulder, 0.2 m past reach, so
the support hand was slid back down the weapon until it fitted — on Taila
from an authored 0.35 m to 0.083 m, which puts both fists together at the
grip. Two hands on a pistol, not a rifle.
Fixed by solving the support arm's triangle rather than picking a factor:
its hand must reach stock+fore ahead of the pocket from a shoulder half a
shoulder-width off the axis, so scale the gun to the largest that keeps the
handguard inside that reach. Taila and Kiyoko now come out at their own
scales (0.217 and 0.213 m of hand separation) with the support hand at its
FULL authored handguard distance and the slide-back loop never firing. The
loop also has a floor now: a straight support arm beats no handguard hold.
FINGERS. Nothing posed them — every hand was flat and open, which is the
loudest possible tell that a character is not holding anything. They close
now, about an axis derived from each hand's own anatomy in the rest pose:
along = wrist to middle knuckle, palm = middle knuckle to thumb tip (the
thumb opposes the fingers, so it marks the palm side by construction), curl
= along x palm. The trigger finger gets a much shallower curl than the
rest, because it lies along the trigger.
Finger bones resolve by ROLE across all three naming families met so far —
Rigify DEF-f_index.01.L, VRoid J_Bip_L_Index1, Blender IndexFinger1_L —
ordered by depth below the hand rather than by the number in the name,
which is not consistent between them.
Verified by render on Taila and by measurement on Kiyoko: stock at the
shoulder, trigger hand on the grip, support hand out on the handguard,
fingers wrapped, arms not crossing. Smoke 0 failures.
Co-Authored-By: Claude Opus 5 <[email protected]>
The finger roles only reach the runtime through <model>.rig.json, so a
character whose sidecar predates them keeps flat, open hands however good
the pose layer is. Aria, Momo, Miku and the Mannequin re-imported; all six
skins now carry 10 finger chains.
Verified on the Mannequin, which is untextured and so the clearest
diagnostic of the three checked: stock at the shoulder, trigger hand on the
grip, support hand out on the handguard, both fists closed, arms not
crossing. Its arm is a full 0.524 m against Taila's 0.468 and it scales the
weapon independently — 0.231 m of hand separation against her 0.217 — with
the slide-back loop never firing on either.
Known remaining: the support hand's wrist roll leaves the hand hanging a
little under the handguard rather than wrapping it squarely. The grip
POSITION is right on every character; it is the roll about the barrel that
wants another pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
The support hand was upside down on the handguard. Its rotation was built
as a shortest arc from the hand's forearm line to the barrel, plus a
constant 0.5 rad twist. A shortest arc is the MINIMAL rotation between two
directions and says nothing at all about roll, so the entire roll of that
hand came from the constant — and a constant is right only for the one rig
it was tuned against.
Orienting a hand onto something it grips is a frame-to-frame problem, and
saying it that way leaves nothing free to guess. A hand wrapping a cylinder
has its curl axis along that cylinder, or the fingers close across the
handguard rather than around it; and its palm faces the cylinder, which for
a hand supporting from underneath means up. The third axis falls out of the
other two. Map the hand's rest anatomical frame onto that target and the
roll is determined rather than chosen.
The frame is the same one the finger curl already uses — along, palm, curl
— now stored whole instead of just its curl axis.
Verified by render on the mannequin (Rigify names, 0.524 m arm) and Kiyoko
(VRoid names, 0.470 m): fingers wrap the handguard from below and over the
top, stock at the shoulder, arms not crossing, consistent across idle, ADS
and run. L_HAND_TWIST is deleted rather than retuned — it was the bug.
Smoke 0 failures.
Co-Authored-By: Claude Opus 5 <[email protected]>
I have been guessing at numbers that want an eye on them. This is the
environment to set them instead.
godot --path . res://debug/weapon_lab.tscn
Pick a character, pick a weapon, pick a pose, drag sliders, press Save.
Thirteen knobs — weapon size, support-hand distance along the barrel, grip
to buttstock, muzzle pitch at low ready, both wrist rolls, three finger
curl amounts, the stock pocket for low-ready and for aiming, and both elbow
poles. Three markers show the points being solved for: red trigger grip,
green support hand, blue buttstock. If a hand is not ON its marker the IK
could not reach it, which is a different problem from the marker being in
the wrong place, and the two used to be indistinguishable.
Results land in assets/characters/weapon_holds.json, resolved in layers so
a number can be set once and contradicted where it matters:
defaults every character, every weapon
skins.<skin>._all this character, every weapon
skins.<skin>.<weapon> this character, this weapon
An empty file means "use what the code derives", so the game runs exactly
as before until something is actually tuned. The knobs that CAN be derived
from the skeleton still are — mount rotation, wrist frame, weapon size —
and their sliders read "auto" at zero rather than silently overriding.
Two things this had to get right to be honest rather than merely present.
Slider defaults come from the same table as the code defaults, because a
slider parked at 0 beside a code default of 1.0 means the first touch of
that slider silently switches finger curl off. And the markers are
depth-tested: drawn through the body they look like they are floating in
front of the chest when they are really behind an arm, which is the exact
wrong impression for judging whether a hand is on its target.
`-- shot <path> [skin] [weapon]` renders one framed close-up and quits, so
the lab can be checked without a human at the controls — which is how both
of those bugs were caught.
Co-Authored-By: Claude Opus 5 <[email protected]>
A character has arrived as separate body, garment and hair meshes since the
pipeline stopped joining them — but nothing recorded which was which, so every
system downstream re-guessed from the material. That guess ("untextured and
nearly black means ink") had already rendered the mannequin's flat yellow body
as a black silhouette once.
The question is answerable once, at build time, where the mesh, the weights and
the skeleton are all in hand. tools/surface_map.py answers it three ways, in
order of how much it trusts them: the material name, which on VRoid exports is
formal and on hand-authored models is still explicit; the weights, which are
decisive when the name says nothing — a surface pulled by the skirt chain is a
skirt whatever it is called; and the material flags, which catch the model's own
line-work. The answer goes in the rig sidecar next to the roles and the chains,
and SkinSurfaces reads it.
All eighteen of Taila's surfaces, and every surface of the other five skins,
now resolve from the table with nothing falling through to the heuristic
(debug/surface_class_check.gd). The heuristic stays as the fallback, which is
the one job it was ever right for.
What that buys immediately is per-class art direction, which was impossible
while every surface had to take numbers calibrated on skin. Hair takes a much
thinner line — at the body's 5 mm each strand's hull swallows its neighbour and
the head reads as a solid dark cap. Cloth takes a heavier line and a crisper
terminator, because a garment's silhouette is most of what separates a character
from the background at range. Accessories take the heaviest. `body` is unchanged
on purpose, so the look this was all calibrated against does not move.
That required moving the outline from the instance to the surface: Miku's body,
face and hair are three surfaces of ONE mesh, so an instance-wide overlay could
only ever give all three the same weight.
Two things found on the way, fixed here because they are one line each: the
surface classifier skips meshes with no vertex groups, which drops the stray
42-vertex Icosphere that rides inside every shipped skin — two older tools
already skipped it by spelling its name — and load_model now clears _rig_info,
which a model with no skeleton used to inherit from the last character loaded.
Co-Authored-By: Claude Opus 5 <[email protected]>
The theme was a comic one — cream paper, ink borders, papaya. It is now a
charged one: near-black violet, hot papaya, and a lightning yellow spent
nowhere except the instant a button is pressed. Chips are cut with two sharp
corners and two round ones on a diagonal, which is as close to a skew as a
StyleBoxFlat gets and is the difference between a button that reads calm and
one that reads fast. BoltRule draws the motif itself, struck a third of the way
along its rule rather than centred, so it reads as something that HIT the line.
The pause menu was 900 lines of hand-rolled UI that never referenced UITheme at
all, so it rendered in Godot's default grey. It now applies the theme — and
applies it to its own root Control, not only to the Window, because a Control
inherits from its nearest Control ANCESTOR and this screen hangs off a
CanvasLayer, which is not one. That was invisible at first: the parts built with
UITheme.title() carry their own overrides and looked right next to a list and a
button that did not.
And it now has a Character screen. The roster on the left, the character
themselves on the right, turning — a name in a dropdown is not a character
selection screen. The preview is a real SkinnedPlayerModel in its own world, so
it shows exactly what will spawn: the same cel look, the same per-class
outlines, the same cloth and hair on springs. Selecting applies immediately;
there is nothing destructive to confirm, and applying on selection means the
character behind the menu changes as you arrow the list, which IS the
comparison.
PlayerMovementController.set_skin() is the supported way in. Both halves of a
skin change are easy to do by halves — `synced_skin_id` is what REMOTE peers
rebuild from, and only their _process watches it, so setting the property alone
would change everyone else's view of you and not your own.
Checked rather than asserted. debug/character_picker_check.gd walks the whole
roster and proves each entry loads a skeleton, animations, a surface table and
body surfaces — and that the skeleton is MOVING, because the clip name is a
variable this class sets on itself and reads "Idle" just as happily when nothing
is ticking. debug/ui_capture.gd and debug/roster_capture.gd photograph the
screens and every character, which is how three things were found that no
assertion could see: the theme break above, a preview showing the back of the
character's head, and a turntable that carried on from the last character so the
third one you looked at was side-on.
Known and not fixed here: momo's idle pose is wrong — arms overhead and a
pinched waist. Her rig, not the picker; every other character is correct.
Co-Authored-By: Claude Opus 5 <[email protected]>
The weapon lab could tune how a character HOLDS a gun. It could not tune where
the gun sits in the hand, and that is a different question with a different
scope: a hold is per character and weapon, an anchor is a fact about the hand.
RigAnchors adds it. A hand bone's origin is the WRIST, not the palm, and how far
down the palm a grip should sit — and how the gun rolls in the fingers — depends
on how big that character's hand is and how the artist posed the thumb. It
cannot be derived, it differs per character, and it is small. So it is an offset
that defaults to identity, and identity means exactly what the code derived
before anchors existed: an untuned character is bit-for-bit unchanged.
Deliberately NOT a fixed rotation on the mount. There used to be one, and a bone
attachment is expressed in the BONE's axes, which no two rigs agree on — that
constant is why the hand mount points were wrong on every character. The derived
mount stays derived; the anchor is a nudge on top of it, and nothing here spells
a bone name.
The layering, the JSON round trip and the res://-then-user:// write are now
TuningStore, because none of that was ever specific to weapons and two copies of
it would mean two places for "an exported build's tuning pass is silently
discarded" to come back. WeaponHoldTuning is built on it with its file format
unchanged.
The lab is a rig lab now, and it grew three things:
ANCHORS a second knob group. It cost a spec table — everything in the lab is
written against "which specs, which file, keyed on what" rather than
twice against the two groups, so anchors got sliders, live preview,
reset, save and clipboard for free. A third group is a third row in
GROUPS.
CLIP audition one animation on its own. The four pose buttons are the
states the game drives; watching a whole clip end to end is how you
see where a retarget went wrong, and there was no way to do it.
SURFACES what the importer decided each surface IS, and a click to isolate a
class. Isolating is how the decision gets CHECKED: click `hair` and
anything else still standing was misclassified. Hidden by swapping
in a transparent material rather than hiding the node, because a
mesh is not one class — Miku's body, face and hair are three
surfaces of one mesh.
And it takes the game's theme, applied to its own panel rather than only to the
Window, for the same reason the pause menu needed it: a CanvasLayer is not a
Control, so theme inheritance stops at one.
debug/rig_anchor_check.gd asserts the physical consequence rather than the
plumbing — an anchor system is easy to build so that the sliders move, the file
saves, the JSON round-trips and the gun does not budge. All six characters move
their weapon by exactly the offset asked for, measured in the attachment's frame
because a world-space delta on an animating skeleton is mostly the idle
animation, and all six return exactly to the derived mount when it is cleared.
Also fixes BoltRule, whose zigzag was self-intersecting: draw_colored_polygon
triangulates, and a crossing outline fails triangulation and draws nothing at
all except a console full of "Invalid polygon data". Same silhouette, walked as
a closed loop, with the ink edge as a polyline rather than a grown polygon —
growing a concave shape from its centroid reintroduces the crossing.
Co-Authored-By: Claude Opus 5 <[email protected]>
Five screen titles in the main menu were white on a black outline and the HUD
used Color.BLACK outlines and a hardcoded grey. That was the old comic theme's
contrast trick, and next to a papaya wordmark it read as a different game's
menu. They take UITheme.PAPAYA, INK and PAPER_DIM now, so every screen —
main menu, pause menu, character picker, settings, HUD and rig lab — is one
palette with one source.
Co-Authored-By: Claude Opus 5 <[email protected]>
The skill is this project's own instructions, and three commits made it wrong:
it described a runtime that re-guessed what every surface was, a lab that only
tuned weapon holds, and a suite that could not see any of the last five defects.
It also still listed a seventh character that is not in skins.json.
Records what is new — the surface table and how it is decided, the layered
tuning files and why a fixed rotation on the weapon mount is the wrong answer,
the rig lab — and what the work taught:
* assert the CONSEQUENCE. Asking a model which clip it is playing reads a
variable it set on itself, and says "Idle" just as happily when nothing is
ticking. Asking whether an anchor saved says nothing about whether the gun
moved.
* four of the last five real defects came from looking at a PNG. Every one of
them passed every assertion.
* the line-work rule now exists twice, at build time and as the runtime
fallback, and they must be changed together or a model with a surface table
starts rendering differently from one without.
Also records momo's broken idle and the stray Icosphere under known-unsolved,
with what is worth suspecting first in each case, since neither is fixed.
Co-Authored-By: Claude Opus 5 <[email protected]>
momo's idle plays with her arms overhead and her waist pinched, and every
assertion in the suite passes on her.
The first suspicion was the retarget's rest-relative delta: it applies "what the
clip does to the LIBRARY's rest" to THIS rig's rest, which quietly assumes the
two rests are alike. rest_pose_check.gd tests that, and disproves it — miku's
arms rest 41° off the library's and taila's 32°, and both animate correctly. The
delta retarget handles a rest-pose difference, which is what it is for. Recorded
in the reference so nobody spends that hour again.
Writing the tool reproduced this project's own recurring mistake in miniature.
Measuring "the direction from a bone to its first child" reported kiyoko's and
aria's legs 71° off the library — because a thigh's first child is as likely to
be a skirt bone as a shin, and it was measuring the hang of a skirt panel.
Pointing it at the next limb BY ROLE dropped both to 1°.
What momo actually has: `Root_001` through `Root_007` are in her driven_bones,
and they are her HAIR roots — Hair_A is dominated by Root_001_001, Root_007 and
Root_005. The animation is keying bones the spring solver is supposed to own,
which is non-negotiable #2 broken by the role resolver rather than by a clip.
The surface table corroborates it: her hair surfaces report 1.8% and 9.4% chain
share, because most of their vertices belong to bones in no chain at all.
Not fixed here. `Root_00N` matches no COSMETIC stem, and adding "root" to the
stems would cost a rig whose actual root is called `Root` its hips. The
structural fix is that a bone whose geometry is dominated by a mesh classified
`hair` is a hair bone whatever it is called — which the surface table makes
answerable at build time, and did not when momo was imported. It needs a Blender
re-run and re-verification of all six characters.
Co-Authored-By: Claude Opus 5 <[email protected]>
The lab could move the GUN and not the anchor points the hands are solved onto.
`grip_offset` slides the weapon around inside the fist; `gun_fore` and
`gun_stock` are distances ALONG the barrel, so the trigger and support hands
could travel up and down the weapon's own axis and nowhere else. Nothing could
take a hand off that axis, which is what a handguard below the bore, an angled
foregrip, or a pistol whose grip is nowhere near its barrel line all need.
Two things fix that.
`grip_shift` and `fore_shift` give the two hand anchors real three-dimensional
freedom, expressed in the GUN's own across/up/along frame so a sideways nudge
stays sideways as the weapon pitches between low ready and ADS. Zero is exactly
the old behaviour. Their z overlaps the along-axis distances, which is redundant
and deliberate: keeping those separate is what lets the reach solver slide the
support hand back down the handguard without undoing a considered sideways
offset.
And the markers are now draggable. They already showed the anchors; now they
are handles. The one under the mouse swells and draws through the body — depth
testing is right for judging whether a hand reached its target and wrong for a
handle, because at any useful framing the hands occlude all three.
Verified three ways, and each one had to be rebuilt once:
anchor_shift_check first compared absolute positions and reported a 3.5 mm
error that was the character BREATHING — there is a sin() on the muzzle pitch,
so no anchor is ever in the same place twice. Measuring each anchor relative
to the one it hangs off, rotated into the current gun basis, cancels the
breathing, the ADS blend and the recoil exactly. 48 checks, six characters,
both poses.
anchor_drag_check asserts the drag writes the knob the MOUSE asked for,
derived independently from the camera: 0.00-0.01 mm on all three. It does not
assert the marker lands under the cursor, because it does not — the anchors
hang off the shoulder and the arm chasing them moves the shoulder, so a drag
settles at 0.77x-1.13x. Small enough to ignore interactively.
That feedback first read as 1.5x-1.8x, because the cases were compounding on
each other, and waiting LONGER for the pose to settle made it worse rather
than better — which is the opposite of how a settling error behaves and is
what gave it away.
The buttstock case also failed for a while on a bug entirely in the test: it
read an absent knob as zero when `pocket_hip` defaults to (30, -70, 60) mm. The
lab has a note about that trap in `_reset`. It is just as easy to walk into from
a test, and now has one there too.
Co-Authored-By: Claude Opus 5 <[email protected]>
Three things the lab could not do.
WRISTS. Each hand had one scalar, a twist about the barrel. That is the only
axis a hand wrapping a cylinder is free in ONCE the arc onto the barrel is
solved — which is true of the support hand, was never true of the trigger hand,
and in neither case left a way to cock a wrist forward or break it inward. Both
now take pitch, yaw and roll, applied in the GUN's frame so the three sliders
mean the same thing whether the muzzle is down at low ready or level down the
sights. Zero is exactly the old behaviour, since the roll term defaulted to zero
too.
HAND POINTS. `gun_stock` and `gun_fore` are the distances along the weapon at
which each hand sits, and they were labelled by what they measure rather than by
whose hand it is. They now say TRIGGER and SUPPORT, next to the off-barrel
shifts for the same two hands, so the four controls that place a hand read as
four controls that place a hand.
POSES. The hold's knobs are now per pose, and the lab shows one pose's at a
time. Half of them mean something different at low ready than down the sights;
showing both sets at once meant every slider on screen was for one of two poses
with nothing saying which. Selecting a pose rebuilds the panel.
Two poses, not four, and deliberately: the runtime blends between exactly two
holds on `ads`. Running and Crouched are locomotion states that still use the
low-ready hold, so they edit the same numbers — and the heading says so, rather
than letting someone tune "Running" and wonder why standing still changed.
Offering four independent tunings would be inventing a capability the code does
not have, and the fourth would silently do nothing.
`pitch` is the case that forced the design: down the sights the muzzle follows
the CAMERA, so there is nothing there to tune. It exists at low ready and
nowhere else, and a spec table where a knob names the poses it applies to is
what lets that be said instead of shipping a control that does nothing.
hold_pose_check asserts both halves — that no pose shows another's knobs, that
aiming offers no muzzle pitch, that the heading names the hold being edited, and
that all twelve wrist axes turn the hand they name.
Its first version reported every wrist axis as moving the hand by 0.0 degrees,
which is precisely the answer it would have given if the wrists had never been
implemented: it read `get_bone_pose_rotation` from a SceneTree script, and Godot
restores every bone's local pose after the modifier pass. The repo has a
reference section about exactly this and it still cost a cycle. Measured through
a PoseProbe, every axis turns its hand ~20 degrees.
Co-Authored-By: Claude Opus 5 <[email protected]>
The weapon is a child of a BoneAttachment3D on the trigger hand, so the two were
welded by construction. Every degree `wrist_r` turned swung the barrel the same
degree off the aim line — and took with it every control that could have
corrected for it, because they are all expressed relative to that same hand.
There was no combination of sliders that aligned a hand to a gun, which is the
one thing the knob exists for.
Both outcomes are now computed where the hand's local pose is set: the rotation
the hand would take without the wrist offset, and the one it takes with it. The
hand gets the second; the difference between them is exactly the counter-rotation
the weapon mount needs, in the hand's own local frame, and
`SkinnedPlayerModel._hold_weapon_still` applies it to the mount each frame. The
gun ends up precisely where the solver put it.
That also gives the two controls a clean split, which is what makes them usable
together:
TRIGGER / SUPPORT WRIST (hold) turns the HAND, gun stays on the aim line
Grip roll / pitch / yaw (anchors) turns the GUN inside the hand
Identity when `wrist_r` is untuned, so a character nobody has tuned mounts its
weapon exactly as before.
Applied in `_process` rather than inside the modifier pass on purpose. The gun's
mount is not something the skeleton owns, and the compensated value only changes
when a slider moves or the ADS blend travels, so one frame of lag is a fraction
of a degree; reaching into the modifier to touch a scene node would be worse.
wrist_gun_check asserts both halves, because only asserting the first is how
this shipped broken: the hand must TURN, or the knob does nothing, and the gun
must NOT, or the knob cannot be used. Across both poses and all three axes the
hand turns 28.2-28.7 degrees for a 0.5 rad knob and the gun moves 0.1-0.6 —
against the ~28 it would move if it were still following the wrist. The residue
is the arm's own IK settling, since the hand's rotation feeds the chain that
places the shoulder.
Co-Authored-By: Claude Opus 5 <[email protected]>
The first tuning pass saved through the rig lab, and the first content in
assets/characters/weapon_holds.json. Aria holding the AK-47 at low ready: weapon
scaled to 0.53, both wrists set, both elbow poles placed, and both hands moved
along and off the barrel line.
Authored by hand at the sliders, not derived — which is the whole point of the
file. Every value here is one the code cannot work out from the skeleton, and
the layering means it applies to this character and this weapon only; every
other character still gets what the code derives.
Also picks up the .uid Godot generated for debug/wrist_gun_check.gd, which was
committed a moment before its companion existed.
Co-Authored-By: Claude Opus 5 <[email protected]>
Dotts
merged commit f0b0d19847 into main2026-07-27 23:22:53 -07:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Rebuilds character rigging, animation and the surrounding tooling toward an
AAA shooter workflow: characters that arrive as separable parts and know what
those parts are, a rig that can be adjusted by eye instead of by constant, and
one UI theme across every screen.
43 commits, 168 files.
Characters are separable, and now they say so
The pipeline already refused to join meshes, so a character arrived as the
eighteen pieces the artist authored — but nothing recorded what they were, so
every system downstream re-guessed from the material. That guess ("untextured
and nearly black means ink") had already rendered the mannequin's flat yellow
body as a black silhouette once.
tools/surface_map.pyanswers it once, at build time, three ways in descendingorder of trust: the material name (formal on VRoid exports), the weights (a
surface pulled by the skirt chain is a skirt whatever it is called), and the
material flags (which catch the model's own line-work). The answer goes in the
rig sidecar and
SkinSurfacesreads it.All 18 of Taila's surfaces and every surface of the other five skins resolve
from the table with zero falling through to the heuristic. The heuristic
stays as the fallback, which is the one job it was ever right for.
That buys per-class art direction, impossible while every surface took numbers
calibrated on skin: hair takes a much thinner outline — at the body's 5 mm each
strand's hull swallows its neighbour and the head reads as a solid dark cap —
cloth a heavier one and a crisper terminator, accessories the heaviest.
bodyis unchanged on purpose. That required moving the outline from the instance to
the surface, since Miku's body, face and hair are three surfaces of one mesh.
The rig is adjustable by eye
debug/rig_lab.tscn. Pick a character, a weapon, a pose or a single clip.solved onto, and they are handles now. The one under the mouse swells and
draws through the body, because at any useful framing the hands occlude all
three.
gun_foreandgun_stockare distancesalong the weapon, so both hands could slide up and down its axis and nowhere
else — no use for a handguard below the bore or an angled foregrip.
grip_shift/fore_shiftare in the gun's own frame, so a sideways nudgestays sideways as the weapon pitches.
The weapon is a child of a BoneAttachment3D on the trigger hand, so the two
were welded: every degree the wrist turned swung the barrel the same degree
and took every control that could correct it along with it. The pose layer now
publishes the exact counter-rotation and the mount applies it.
rebuilds when you change it. Two poses, not four, because the runtime blends
between exactly two on
ads— Running and Crouched use the low-ready hold andthe heading says so.
pitchexists at low ready only: down the sights themuzzle follows the camera, so a slider there would do nothing.
hair; anything else stillstanding was misclassified.
TuningStoreholds the layering (defaults→ character → subject) behind bothfiles. An absent file means "use what the code derives", so none of this is
required for the game to run.
One UI theme, and character selection where it belongs
The theme was a comic one. It is now a charged one — near-black violet, hot
papaya, and a lightning yellow spent nowhere except the instant a button is
pressed. Chips lean on a diagonal;
BoltRuledraws the motif.The pause menu was 900 lines that never referenced
UIThemeat all and renderedin Godot's default grey. It does now — applied to its own root Control, not only
the Window, because a Control inherits from its nearest Control ancestor and
this screen hangs off a CanvasLayer.
And it has a Character screen: the roster on the left, the character
themselves on the right, turning. A real
SkinnedPlayerModel, so it showsexactly what will spawn — same cel look, same per-class outlines, same cloth and
hair on springs. Selecting applies immediately.
Verification
Eight headless checks, all green, plus screenshot tools:
spawn_smoke_testsurface_class_checkcharacter_picker_checkrig_anchor_checkanchor_shift_checkanchor_drag_checkhold_pose_checkwrist_gun_checkCloth unchanged at 18.4 mm idle; stride 2.4° off travel.
Several of these exist because the obvious check passes on a broken system.
Asking a model which clip it is playing reads a variable it sets on itself and
says "Idle" just as happily when nothing is ticking. An anchor system is easy to
build so the sliders move, the file saves and the gun does not budge. And
measuring anything through a pose layer from a SceneTree script reports the
animation — Godot restores every bone's local pose after the modifier pass,
which cost a cycle here again despite the repo having a reference section headed
READ THIS FIRST about exactly that.
Four of the last five real defects came from looking at a PNG, not from an
assertion — a preview showing the back of the character's head, a turntable that
carried on from the previous character, an unstyled list, and momo's idle pose.
roster_capture.gdandui_capture.gdexist for that.Known and not fixed
Root_001–Root_007are her hair roots and are indriven_bones, so theanimation keys bones the cloth solver should own. The name-based fix would
cost a rig whose real root is called
Rootits hips; the structural fix needsa Blender re-run and re-verification of all six characters.
Icosphereships inside every character GLB, from theanimation library. Harmless, now skipped by construction rather than by name.
strafe or backpedal clips.
The "linked ankle cuffs" and the "squashed legs" were the same thing, and it was never the cloth or the weights: it is the model's OWN outline hull. Isolating it took rendering the raw GLB in a bright scene and toggling one surface group at a time. The hull ("FullBlack" and "material", the untextured surfaces that trace the body and hair silhouettes) is a duplicated shell whose skin weights do not track the base mesh through a deep bend. During a run it tears into spikes and stretches sheets across the ankles. Hiding just those surfaces makes the legs render perfectly clean, with no other change — so the two previous attempts here were both treating symptoms: * re-weighting stray ankle vertices only tore the cloth (reverted last time) * deleting cross-leg triangles removed real geometry for no benefit (SkinMeshRepair is deleted in this commit — the clean render above was produced WITHOUT it) The hull is also redundant: characters already get an inverted-hull overlay from apply_toon_recursive AND the screen-space ink_edge pass, so dropping it costs nothing visually. The EYE cards (EyesFullBlack lashes, EyesInvL, EyesHL highlight) are real facial features rather than a hull, so those are kept, flat as before. They are told apart by name prefix, not by guesswork about geometry. Worth recording: no bone is EVER scaled, in any clip. Measured again here across the whole Run cycle — worst deviation of a bone's pose basis from a pure rotation is 0.00000. There is no squash-and-stretch in this rig; it only ever looked that way because of the shell. Backpedalling now plays the locomotion cycle in reverse (negative TimeScale) instead of moon-walking with the forward clip — the library ships no authored backward run. Directional lean is raised from 0.18/0.30 to 0.30/0.42 rad: with one forward cycle and no strafe clips, the lean is the only cue for which way the character is travelling, so it has to be legible rather than subtle. Blends raised again: base 0.22 -> 0.32, locomotion 0.28 -> 0.40. Verified the cross-fade genuinely applies rather than snapping — instrumented an idle->run switch and the thigh ramps gradually over the window instead of stepping on frame one, with xfade_time reading 0.400 s on the Transition node. FSM tests 11/11, spawn smoke test 0 failures. Co-Authored-By: Claude Opus 4.8 <[email protected]>The measurement was comparing skinned lengths against BIND-POSE lengths. A character is never in bind pose, so ordinary posing registered as deformation: with the animation frozen it still reported 0.42x-2.56x edge ratios, and during a run it reported edges collapsing to 0.08x and blowing out to 5.2x. Those numbers were an artefact of the metric, not the model. The baseline is now built from the skeleton's REST transforms, so every ratio reads exactly 1.00 on an unposed model and the tool validates itself. Corrected readings on Taila through the full runtime stack, worst over run, walk, jump, fall, slide and dash: lengthwise stretch 1.07 Body, 1.08 ClothB, 1.09 ClothCAndW, 1.03 shell cross-section 0.97-0.99 knee, 0.85 hip, 0.92-0.94 ankle That is ordinary linear-blend skinning. Also records, in the header, the two ways this measurement was previously wrong, because both nearly produced a bad "fix": * the bind-pose baseline above; * flagging vertices weighted across "non-adjacent" leg bones with max_slot - min_slot >= 2, which flags shin+foot+toe — a perfectly normal contiguous run. A repair pass built on that was written and reverted before it shipped; it was stripping legitimate toe and shin influences. Checks that did come back clean and are worth not repeating: bone pose bases are pure rotations to 0.00001, bone lengths never drift from their rest offsets, and of 4019 coincident-vertex groups not one carries mismatched weights, so there are no split seams tearing at the joints. No character or gameplay code changed. skin_leg_repair.gd is byte-identical in behaviour to the last commit; only its header comment gained a pointer to this tool. Co-Authored-By: Claude Opus 4.8 <[email protected]>Renames SkinKneeHelper to SkinJointHelper and generalises it to a list of [parent, child] joints, so the half-angle joint technique is stated once and can be pointed at any joint. It stays pointed at the KNEES only, and the header now says why, because "add more joints" is the obvious next idea and it is wrong here. Three things were tried this round and measured, on the thigh, per vertex, against the rest transforms: * hip and ankle helpers as well as knees: the knee got WORSE, 0.80 -> 0.50, and the skirt went 0.80 -> 0.72. The hip mostly swings rather than folding, and linear-blend skinning handles a swing far better than a fold; the helper just added another blend for nothing. Reverted. * gating the re-weight to vertices near the leg bone chain, to keep the hip helper off the skirt: that also excludes the OUTSIDE of the knee, which sits further from the bone axis than the limb radius and is exactly the geometry that needs helping. Knee went back to 0.50. Removed — with knees only there is no skirt to protect against. * one joint per vertex (break after the first match): kept, since a second job would read the bones the first just rewrote. Net result on the thigh, worst per height bucket during a jump: y=0.60 0.80 -> 0.83 y=0.75 0.84 -> 0.84 y=0.65 0.81 -> 0.84 y=0.85 0.90 -> 0.90 and the skirt is untouched at 0.80/0.84, still free to move. FSM tests 11/11, spawn smoke test 0 failures. Co-Authored-By: Claude Opus 4.8 <[email protected]>The thighs going thin was never the knee bend. It was inner-thigh vertices keeping ~20% of the weight of the OPPOSITE leg, so a split stride tore them across the body. Printing the weight composition of the worst-collapsing vertices is what finally showed it: a right-thigh vertex reading DEF-thigh.R=0.58 DEF-shin.R=0.21 DEF-thigh.L=0.13 DEF-shin.L=0.08. Every earlier pass had a guard that let these through. The below-knee pass does not reach them; the above-knee pass demanded one leg chain be 1.25x nearer than the other, which excludes everything near the centre line — precisely where the damage was; and the limb-radius gates are measured from the bone AXIS, so a vertex on the front or back of a thigh clears them easily. SkinLegRepair now ends with an unconditional pass: any vertex within a generous radius of either leg chain is forced onto the leg that actually drives it, at any height. It runs last so none of the staged rules can reintroduce the problem. Measured per vertex, across jump, fall, run and dash: before after Body below 0.80 70 0 Body worst 0.46 0.82 ClothCAndW below 0.80 107 0 ClothCAndW worst 0.66 0.85 ClothB worst 0.81 0.87 No vertex anywhere on the legs now loses more than 18% of its thickness, down from 54%. The skirt still deforms freely (0.87-0.88) — clothes flow, limbs hold. Also fixed in SkinJointHelper along the way: it picked the FIRST joint chain that matched a vertex rather than the one holding most of its weight, which bound right-thigh vertices with stray left-leg weight to the LEFT knee's helpers and dragged them across the body. It now picks the dominant joint and requires the joint to own at least half the vertex. Taila snaps 2021 vertices, Miku 659. FSM tests 11/11, spawn smoke test 0 failures. Co-Authored-By: Claude Opus 4.8 <[email protected]>Seven characters shipped "All checks passed" and four were visibly broken in game — lying on their backs at seven times scale, facing backwards, or holding a gun that floated near their chest. Nothing in the suite was wrong; it just never asked the questions that mattered. That distinction is the whole lesson, and it is now written down in the skill as references/failure-modes.md, generalised per failure. Two root causes are measured and certain: - flatten_and_scale() normalises the bounding box along Blender Z because Z is up. For a model that arrives lying along Y that measures the character's THICKNESS, so it scales by ~7 and leaves them on their back. One assumption, both symptoms. The trap is that the normalised number always comes out right — the exporter maps Blender Z to glTF Y, so "is the height 1.75" passes on a character who is 7.5 m tall lying down. All three casualties are VRM files that went through a Blender round-trip and came back with a baked axis rotation. - SkinnedPlayerModel.set_weapon() finds the hand with three hardcoded spellings, which match none of the four non-Rigify rigs — their hands resolve perfectly in the sidecar as "Right wrist" and "J_Bip_R_Hand". When it misses, the weapon is parented to the model root at a fixed chest offset, so it is not attached to the character at all. Same class of bug as the leg check that name-matched thigh/shin. rig_map.py exists so nothing downstream has to guess a bone name; only some consumers read the roles it publishes. Three new hard checks, none needing more than the vertices and the sidecar: character stands up in world space — against WORLD up, not against the model's own proportions. "Is the spine the longest axis?" catches nothing: a model rotated as a whole is internally consistent and passes it comfortably. character is a plausible size / height every role the runtime needs is resolved They separate the four good characters from the three broken ones on the first run. Also fixed the posture measurement to read vertices rather than object.bound_box, which is cached and still stale right after an import — it reported a 1.75 m character as 1.18 m tall. Recorded but not yet fixed: kiyoko faces backwards (facing is inferred by two independent mechanisms and verified by neither), miku's grown hair chains stretch under animation (generated chains are never validated against the geometry they drive), and the mannequin's rifle hold does not convince despite resolving correctly. Co-Authored-By: Claude Opus 5 <[email protected]>A character has arrived as separate body, garment and hair meshes since the pipeline stopped joining them — but nothing recorded which was which, so every system downstream re-guessed from the material. That guess ("untextured and nearly black means ink") had already rendered the mannequin's flat yellow body as a black silhouette once. The question is answerable once, at build time, where the mesh, the weights and the skeleton are all in hand. tools/surface_map.py answers it three ways, in order of how much it trusts them: the material name, which on VRoid exports is formal and on hand-authored models is still explicit; the weights, which are decisive when the name says nothing — a surface pulled by the skirt chain is a skirt whatever it is called; and the material flags, which catch the model's own line-work. The answer goes in the rig sidecar next to the roles and the chains, and SkinSurfaces reads it. All eighteen of Taila's surfaces, and every surface of the other five skins, now resolve from the table with nothing falling through to the heuristic (debug/surface_class_check.gd). The heuristic stays as the fallback, which is the one job it was ever right for. What that buys immediately is per-class art direction, which was impossible while every surface had to take numbers calibrated on skin. Hair takes a much thinner line — at the body's 5 mm each strand's hull swallows its neighbour and the head reads as a solid dark cap. Cloth takes a heavier line and a crisper terminator, because a garment's silhouette is most of what separates a character from the background at range. Accessories take the heaviest. `body` is unchanged on purpose, so the look this was all calibrated against does not move. That required moving the outline from the instance to the surface: Miku's body, face and hair are three surfaces of ONE mesh, so an instance-wide overlay could only ever give all three the same weight. Two things found on the way, fixed here because they are one line each: the surface classifier skips meshes with no vertex groups, which drops the stray 42-vertex Icosphere that rides inside every shipped skin — two older tools already skipped it by spelling its name — and load_model now clears _rig_info, which a model with no skeleton used to inherit from the last character loaded. Co-Authored-By: Claude Opus 5 <[email protected]>The weapon lab could tune how a character HOLDS a gun. It could not tune where the gun sits in the hand, and that is a different question with a different scope: a hold is per character and weapon, an anchor is a fact about the hand. RigAnchors adds it. A hand bone's origin is the WRIST, not the palm, and how far down the palm a grip should sit — and how the gun rolls in the fingers — depends on how big that character's hand is and how the artist posed the thumb. It cannot be derived, it differs per character, and it is small. So it is an offset that defaults to identity, and identity means exactly what the code derived before anchors existed: an untuned character is bit-for-bit unchanged. Deliberately NOT a fixed rotation on the mount. There used to be one, and a bone attachment is expressed in the BONE's axes, which no two rigs agree on — that constant is why the hand mount points were wrong on every character. The derived mount stays derived; the anchor is a nudge on top of it, and nothing here spells a bone name. The layering, the JSON round trip and the res://-then-user:// write are now TuningStore, because none of that was ever specific to weapons and two copies of it would mean two places for "an exported build's tuning pass is silently discarded" to come back. WeaponHoldTuning is built on it with its file format unchanged. The lab is a rig lab now, and it grew three things: ANCHORS a second knob group. It cost a spec table — everything in the lab is written against "which specs, which file, keyed on what" rather than twice against the two groups, so anchors got sliders, live preview, reset, save and clipboard for free. A third group is a third row in GROUPS. CLIP audition one animation on its own. The four pose buttons are the states the game drives; watching a whole clip end to end is how you see where a retarget went wrong, and there was no way to do it. SURFACES what the importer decided each surface IS, and a click to isolate a class. Isolating is how the decision gets CHECKED: click `hair` and anything else still standing was misclassified. Hidden by swapping in a transparent material rather than hiding the node, because a mesh is not one class — Miku's body, face and hair are three surfaces of one mesh. And it takes the game's theme, applied to its own panel rather than only to the Window, for the same reason the pause menu needed it: a CanvasLayer is not a Control, so theme inheritance stops at one. debug/rig_anchor_check.gd asserts the physical consequence rather than the plumbing — an anchor system is easy to build so that the sliders move, the file saves, the JSON round-trips and the gun does not budge. All six characters move their weapon by exactly the offset asked for, measured in the attachment's frame because a world-space delta on an animating skeleton is mostly the idle animation, and all six return exactly to the derived mount when it is cleared. Also fixes BoltRule, whose zigzag was self-intersecting: draw_colored_polygon triangulates, and a crossing outline fails triangulation and draws nothing at all except a console full of "Invalid polygon data". Same silhouette, walked as a closed loop, with the ink edge as a polyline rather than a grown polygon — growing a concave shape from its centroid reintroduces the crossing. Co-Authored-By: Claude Opus 5 <[email protected]>The skill is this project's own instructions, and three commits made it wrong: it described a runtime that re-guessed what every surface was, a lab that only tuned weapon holds, and a suite that could not see any of the last five defects. It also still listed a seventh character that is not in skins.json. Records what is new — the surface table and how it is decided, the layered tuning files and why a fixed rotation on the weapon mount is the wrong answer, the rig lab — and what the work taught: * assert the CONSEQUENCE. Asking a model which clip it is playing reads a variable it set on itself, and says "Idle" just as happily when nothing is ticking. Asking whether an anchor saved says nothing about whether the gun moved. * four of the last five real defects came from looking at a PNG. Every one of them passed every assertion. * the line-work rule now exists twice, at build time and as the runtime fallback, and they must be changed together or a model with a surface table starts rendering differently from one without. Also records momo's broken idle and the stray Icosphere under known-unsolved, with what is worth suspecting first in each case, since neither is fixed. Co-Authored-By: Claude Opus 5 <[email protected]>