Compare commits

..
26 Commits
Author SHA1 Message Date
Nicholas ButzkeandClaude Opus 5 742d68e318 chore(characters): Aria's AK-47 hold, tuned in the lab
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]>
2026-07-28 02:12:28 -04:00
Nicholas ButzkeandClaude Opus 5 e97de9aafd fix(weapons): the wrist turns the hand, and the gun stays on the aim line
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]>
2026-07-28 01:43:36 -04:00
Nicholas ButzkeandClaude Opus 5 a97ccca13c feat(rig lab): wrists in three axes, and sliders that belong to the pose on screen
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]>
2026-07-28 01:31:08 -04:00
Nicholas ButzkeandClaude Opus 5 b1c8bab714 feat(rig lab): drag the anchors themselves, not just the gun under them
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]>
2026-07-28 01:05:37 -04:00
Nicholas ButzkeandClaude Opus 5 700d0925d7 tools: measure rest poses, and diagnose momo
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]>
2026-07-27 15:03:33 -04:00
Nicholas ButzkeandClaude Opus 5 1e6f3001ac docs(skill): the surface table, rig anchors, and asserting the consequence
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]>
2026-07-27 15:00:42 -04:00
Nicholas ButzkeandClaude Opus 5 99a2ee131d style(ui): the last hand-rolled colours join the theme
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]>
2026-07-27 14:57:31 -04:00
Nicholas ButzkeandClaude Opus 5 08ae85b286 feat(characters): rig anchors, and a lab that edits any tuning group
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]>
2026-07-27 14:56:03 -04:00
Nicholas ButzkeandClaude Opus 5 26f7c2c622 feat(ui): one high-voltage theme, and pick your character from the escape menu
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]>
2026-07-27 14:46:14 -04:00
Nicholas ButzkeandClaude Opus 5 53f175ed6d feat(characters): say what every surface IS, and light it accordingly
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]>
2026-07-27 14:35:06 -04:00
Nicholas ButzkeandClaude Opus 5 33d07b3717 feat(tools): a weapon hold lab for tuning each character's grip in 3D
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]>
2026-07-26 21:15:53 -04:00
Nicholas ButzkeandClaude Opus 5 2a6b321984 fix(weapons): orient the support hand by frame, not by arc plus a twist
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]>
2026-07-26 19:50:25 -04:00
Nicholas ButzkeandClaude Opus 5 daed7ab980 chore(characters): regenerate every sidecar with the finger table
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]>
2026-07-26 19:05:36 -04:00
Nicholas ButzkeandClaude Opus 5 682597a0d2 feat(weapons): scale the gun to the arm, and close the hands around it
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]>
2026-07-26 19:02:11 -04:00
Nicholas ButzkeandClaude Opus 5 c8b0337aa7 fix(characters): rig-independent weapon mount, measured facing, revert Miku
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]>
2026-07-26 16:29:04 -04:00
Nicholas ButzkeandClaude Opus 5 27c4117c25 fix(pipeline): stand the character up before scaling; reach bones by role
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]>
2026-07-26 16:06:51 -04:00
Nicholas ButzkeandClaude Opus 5 2b3e29dd30 fix(pipeline): check that a character is CORRECT, not merely well-formed
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]>
2026-07-26 16:02:06 -04:00
Nicholas ButzkeandClaude Opus 5 2ce2175d99 docs(skill): record what four non-library rigs taught the role resolver
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]>
2026-07-26 13:46:23 -04:00
Nicholas ButzkeandClaude Opus 5 fc9e6f4275 feat(characters): four VRoid characters imported and selectable
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]>
2026-07-26 13:45:27 -04:00
Nicholas ButzkeandClaude Opus 5 cd0d1b2d99 feat(characters): import the Quaternius mannequin as a selectable skin
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]>
2026-07-26 13:25:26 -04:00
Nicholas ButzkeandClaude Opus 5 270d5f0973 chore: track Godot uid files for the new debug tools
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 13:19:55 -04:00
Nicholas ButzkeandClaude Opus 5 7892669319 feat(pipeline): grow skirt and hair bone chains for a costume that has none
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]>
2026-07-26 13:19:44 -04:00
Nicholas ButzkeandClaude Opus 5 4559a1adc3 docs(skill): record why Miku has no cloth — the source had no skeleton
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]>
2026-07-26 12:47:42 -04:00
Nicholas ButzkeandClaude Opus 5 daf9627ece docs(skill): compact the character pipeline into a reusable skill
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]>
2026-07-26 12:46:50 -04:00
Nicholas ButzkeandClaude Opus 5 040b595397 feat(anim): point the legs where the character is actually going
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]>
2026-07-26 03:16:24 -04:00
Nicholas ButzkeandClaude Opus 5 0dd9d01ac7 fix(cloth): solve the garment instead of repairing it five times
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]>
2026-07-26 03:07:30 -04:00
143 changed files with 27737 additions and 1330 deletions
+326
View File
@@ -0,0 +1,326 @@
---
name: character-pipeline
description: Import, rig, stylize and animate an anime-styled character into Papaya-Shooter as a selectable skin — keeping the model's own skeleton, artist weights and separate body/cloth/hair meshes, with cloth and hair driven by the spring solver. Use when adding a new playable character, re-importing an existing one, debugging skinning/cloth/hair/animation problems on a character, or changing the cel-shaded look. Triggers on "add a character", "import a skin", "new playable model", "skirt clipping", "hair flailing", "T-posing", "character looks squashed".
---
# Character pipeline
Turns a source model into a playable, cel-shaded, cloth-simulated character skin.
The whole design follows one principle, which is also what the Hoyoverse-class
anime pipelines (Genshin / Star Rail / Zenless Zone Zero) are built on:
> **The character is not one object. It is a body, a set of garments, and hair —
> authored separately, rigged separately, and moved by different systems.**
> The body is skinned and animated. The garments and hair are bone chains that
> the animation never touches; physics moves them. Keeping those separate is
> what makes the result read as an anime character instead of a mannequin in a
> painted-on costume.
Everything below exists to protect that separation.
## The one rule
**If a model arrives with a skeleton, that skeleton ships.** Its bones, its
artist-painted weights, its per-part meshes and its skirt/hair chains all
survive. Only the ANIMATION is moved onto it.
The old route (`strip_rig.py``autorig.py``merge_animations.py`) solved a
bone-*naming* problem by destroying the asset — 18 meshes became 1, 21 skirt
bones and ~50 hair bones became 0, and 16% of vertices ended up pulled by both
legs. `tools/rig_map.py` solves naming properly now. **Never reach for
`strip_rig.py` or `--rebind`** unless the model genuinely has no skeleton at all.
## Doing it
```bash
# From an already-rigged local model (the normal case)
python tools/pipeline.py --input assets/characters/incoming/<name>.glb --name <name> --rigged
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN)
python tools/pipeline.py --uid <uid> --name <name>
# From an unrigged mesh — auto-rigs, and accepts the quality loss
python tools/pipeline.py --input <mesh.glb> --name <name>
# Rigged (or auto-rigged) but with NO skirt/hair bones — grow them, or the
# costume is welded solid and the spring solver has nothing to simulate
python tools/pipeline.py --input <model.glb> --name <name> --rigged --grow-cloth
```
Then, once, so Godot sees the new files:
```bash
godot --headless --path . --import
```
The result is `assets/characters/skins/<name>.glb` + `<name>.rig.json`, and a
registry entry in `skins.json` that `SkinManager` picks up with no code change.
Blender is required (`BLENDER_PATH`, or auto-found under
`C:\Program Files\Blender Foundation`). Godot lives at
`C:\Program Files\Godot\Godot_v4.7-stable_win64_console.exe`.
## The stages, and what each one protects
| Stage | Where | Protects |
|---|---|---|
| Fix unlit/emissive materials | `tools/gltf_fix.py` | Textures surviving import at all |
| Resolve bone ROLES, not names | `tools/rig_map.py` | The model's own skeleton |
| Rebuild parenting | `retarget.py::rebuild_hierarchy` | Limbs/cloth following the hips |
| Grow cloth chains (opt-in) | `tools/cloth_bones.py` | A costume that has no bones being able to move at all |
| Subdivide cloth panels | `retarget.py::subdivide_cloth_panels` | A skirt being able to bend at all |
| Retarget clips as rest-relative deltas | `retarget.py::retarget_clip` | Limbs not being twisted by foreign bone roll |
| Leave cosmetic bones unkeyed | `export_optimize_animation_keep_anim_armature=False` | Physics owning the cloth |
| Classify every SURFACE | `tools/surface_map.py` | The runtime never re-guessing what a surface is |
| Write the rig sidecar | `retarget.py::describe_rig` | The runtime never re-guessing anatomy |
| Cel look, per surface class | `LevelMaterials.apply_character_look` | Hair not reading as a solid dark cap |
| Cloth + hair | `characters/spring_bones.gd` | Clothes reading as clothes |
| Per-character judgement calls | `characters/tuning_store.gd` | Art direction not becoming another constant |
## The surface table
The sidecar carries a `surfaces` list saying what each mesh surface IS — `body`,
`cloth`, `hair`, `accessory`, or `linework` (the model's own ink shell, which is
not a surface of the character at all). It is keyed on the MATERIAL name, because
every character in this game arrives with its meshes called `Object_7` through
`Object_32` while material names survive the glTF round trip intact.
`SkinSurfaces` reads it and `apply_character_look` acts on it: hair takes a much
thinner outline than the body, cloth a heavier one and a crisper terminator,
accessories the heaviest. `SkinnedPlayerModel.surfaces_of(cls)` answers the
question for anything else that needs it.
Backfill a character that predates it, without re-importing:
```bash
blender --background --python tools/surface_map.py -- \
assets/characters/skins/<name>.glb
```
New imports get it from `describe_rig`, built from the same chains the solver
uses, so the surface table and the cloth solver can never disagree about which
bones are a skirt.
## Per-character judgement
Anything derivable from the skeleton is derived. What is left is genuinely an
artist's call, and it lives in layered JSON rather than in a constant:
| File | Scope | Class |
|---|---|---|
| `assets/characters/weapon_holds.json` | character + weapon | `WeaponHoldTuning` |
| `assets/characters/rig_anchors.json` | character | `RigAnchors` |
Both layer `defaults``skins.<skin>._all``skins.<skin>.<subject>` through
`TuningStore`. An absent file means "use what the code derives", so nothing here
is required for the game to run. Adding a knob is adding a row to a `KNOBS` spec
table — the lab builds its whole UI from those.
`RigAnchors` is where "the grip sits here in the palm" lives. A hand bone's
origin is the WRIST; how far down the palm a grip belongs depends on the
character's hand and cannot be derived. It defaults to identity, and identity is
exactly the derived mount. Do **not** put a fixed rotation on the weapon mount
instead — a bone attachment is expressed in the BONE's axes, no two rigs agree on
those, and that constant is why the hand mount points were once wrong on every
character.
## The rig lab
```bash
godot --path . res://debug/rig_lab.tscn
```
Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD
(character + weapon) and the ANCHORS (character), and save.
**The HOLD is per pose.** The runtime blends between exactly two holds, on
`ads`, so the lab offers two: low ready and aiming. Selecting a pose rebuilds
the hold sliders to that pose's — you never see a control belonging to the pose
you are not adjusting. Running and Crouched use the low-ready hold, and the
heading says so rather than letting someone tune "Running" and wonder why
standing still changed. `pitch` exists at low ready only: down the sights the
muzzle follows the camera, so there is nothing there to tune, and a slider that
does nothing is worse than a missing one.
**The wrists turn the HAND, not the gun.** The weapon is a child of a
BoneAttachment3D on the trigger hand, so the two are welded by construction: a
wrist rotation swings the barrel off the aim line and takes every control that
could correct it along with it, which made the knob useless for aligning a hand
to a gun. `ShooterPoseModifier.wrist_comp_r` is the exact counter-rotation in
the hand's local frame, and `SkinnedPlayerModel._hold_weapon_still` applies it.
To rotate the GUN inside the hand instead, use the grip rotation in ANCHORS.
Knobs that describe the WEAPON and the hands on it — where each hand sits along
it and off its barrel line, the finger curls, the weapon size — are shared,
because shouldering a gun does not move the hand along it. Both wrists take
pitch, yaw and roll in the gun's own frame, per pose. Click a surface class
to isolate it — that is how the classifier gets checked: click `hair` and
anything else still standing was misclassified.
**Drag the coloured markers.** They ARE the anchor points the hands are solved
onto — red the trigger grip, green the support hand, blue the buttstock — and
the one under the mouse swells and draws through the body so it can be grabbed
where the hands would otherwise hide it.
Dragging an anchor is not the same as any slider:
| | moves |
|---|---|
| `grip_offset` (ANCHORS) | the GUN, inside the fist |
| `gun_fore` / `gun_stock` (HOLD) | the hands ALONG the weapon's own axis |
| dragging a marker | the anchor itself, in three dimensions |
That distinction was the gap. `gun_fore` and `gun_stock` are distances along the
barrel, so the two hand anchors could slide up and down the gun and nowhere
else — no use for a handguard below the bore, an angled foregrip, or a pistol
whose grip is nowhere near its barrel line. A drag writes `grip_shift` or
`fore_shift` in the gun's own across/up/along frame, so a sideways nudge stays
sideways as the weapon pitches; the buttstock marker writes the shoulder pocket
for whichever pose is showing.
The anchors sit on the shoulder, and the arm chasing them moves the shoulder, so
a drag settles at 0.77x-1.13x of the mouse. Small enough to ignore — you stop
when it looks right — and measured, not assumed.
## Read before you touch anything
Load the reference that matches what you are doing. They are short and each one
is a list of things that cost a debugging cycle to learn.
- **`references/failure-modes.md`** — **read this first.** Seven characters
shipped "All checks passed" and four were visibly broken. What each failure
was, why the suite missed it, and the rule that generalises it to any model.
- **`references/separation.md`** — body vs. garments vs. hair: what must stay
separate, how cloth chains are detected and classed, why cloth is never
skinned to a leg, and the ZZZ-convention mapping.
- **`references/growing-cloth-bones.md`** — what to do when the source has no
skirt or hair bones: how the chains are fitted to the geometry and re-weighted.
- **`references/rigging.md`** — role resolution, hierarchy rebuild, cloth panel
subdivision, twist bones, joint helpers, the retarget maths.
- **`references/cloth-and-hair.md`** — the position-based spring solver, its
colliders, per-class tuning, collision hulls, LOD and cost.
- **`references/stylization.md`** — cel shading, outlines, the imported
line-work trap, eyes, materials.
- **`references/verification.md`** — every measuring tool, what each one
actually measures, and the pose-reading trap that invalidated all of them
once. **Read this before trusting any measurement.**
## Non-negotiables
1. **Never join meshes.** Per-part meshes are how body, cloth and hair stay
separable — for materials, for the outline pass, and for the cloth solver's
hull extraction.
2. **Never key cosmetic bones.** If a clip has tracks on skirt/hair bones, the
AnimationPlayer overwrites the solver every frame and the cloth goes rigid.
3. **Never skin cloth to a leg.** A vertex weighted 0.9 to a thigh cannot be
moved by its own cloth bone, so the solver loses the authority to push it out
of that thigh — and the leg still overtakes it. There was a
`bind_cloth_to_legs()`; it is deleted, and the note above its grave in
`retarget.py` says why.
4. **Never run `SkinLegRepair` on authored weights.** It snaps weights and
deletes triangles. It exists only to undo auto-rigging. It is gated on
`weights_authored`, which is MEASURED, not assumed.
5. **Measure from inside the modifier pass.** See `references/verification.md`.
6. **Never assume an axis.** Up, forward and scale are all measurable from the
skeleton. Assuming +Z is up scaled three characters 7x and left them on their
backs — and the height check passed on every one of them, because the number
being normalised always comes out right whether or not it was the right
number. See `references/failure-modes.md`.
7. **Never look a bone up by name.** `tools/rig_map.py` resolves roles and writes
them to the sidecar so nothing downstream has to guess. Any hardcoded spelling
`_find_bone(["RightHand", ...])`, a `thigh`/`shin` substring test — is a rig
this project has not met yet. Four characters could not hold a gun because of
exactly one such lookup.
## After importing
```bash
godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks
godot --headless --path . -s res://debug/surface_class_check.gd # every surface classified
godot --headless --path . -s res://debug/character_picker_check.gd # the escape-menu roster
godot --headless --path . -s res://debug/rig_anchor_check.gd # anchors move the weapon
godot --headless --path . -s res://debug/cloth_clip_check.gd # leg-through-cloth
godot --headless --path . -s res://debug/cloth_settle_check.gd # idle stability
godot --headless --path . -s res://debug/cloth_perf_check.gd # ms per character
godot --headless --path . -s res://debug/travel_dir_check.gd # legs face travel
```
And LOOK at it, which is where four of the last five real defects were found:
```bash
godot --path . -s res://debug/roster_capture.gd -- <dir> # every character, one shot each
godot --path . -s res://debug/ui_capture.gd -- <dir> # every menu screen
godot --path . res://debug/rig_lab.tscn -- shot <png> <skin>
```
`surface_class_check` fails on any surface that falls through to the heuristic
rather than resolving from the table. That is deliberate: a model whose names
stopped matching still RENDERS, because the fallback catches it — it just
quietly loses its per-class art direction, which is exactly the kind of
regression nothing else would report.
What "good" looks like on Taila, for calibration:
| Measure | Good | Bad |
|---|---|---|
| Idle skirt movement | < 0.1 deg/frame | 0.5+, or never decaying |
| Leg inside cloth, idle/walk | < 25 mm | 100 mm |
| Leg inside cloth, run/slide/dash | ~95 mm *(current, unsolved)* | — |
| Solver cost | ~2.6 ms/character | 10 ms |
| Stride vs. travel direction | < 10° (except a capped sidestep) | 90° |
| Bind-pose AABB | tall on the hips→head axis, others < 2.5 m | tallest axis is depth |
## Characters currently shipping
The six in `skins.json`, with what `surface_class_check` reports:
| Skin | Source | Cloth chains | Surfaces |
|---|---|---|---|
| taila | rigged, Sketchfab CC-BY | 35 | 18 — body 6, cloth 7, hair 1, linework 4 |
| kiyoko | VRoid, CC-BY | 20 | 13 — body 8, cloth 3, hair 2 |
| aria | VRoid, CC-BY | 15 | 15 — body 9, cloth 4, hair 2 |
| momo | VRoid, CC-BY | 9 | 5 — body 2, cloth 1, hair 2 |
| miku | unrigged source, auto-rigged | 0 | 4 — body 3, hair 1 *(one mesh, four surfaces)* |
| mannequin | Quaternius CC0, from the animation library | 0 | 2 — body 2 |
## Known-unsolved
- **Peak cloth clipping** in a run, slide and dash sits at ~95 mm of thigh
inside the skirt. Idle, walk and fall are clean. The solver sees the contact
and pushes on it every iteration; the remaining gap is a standing fight
between the collision and the garment's own shape constraints.
- **No foot IK.** Feet do not plant on ground height, so stairs and uneven
ground read as sliding.
- **No strafe or backpedal clips.** Direction is conveyed by yawing the hips
(`SkinnedPlayerModel._update_travel`), which is capped, so a pure sidestep
still runs its legs ~40° off the direction of travel.
- **momo's idle pose is wrong** — arms overhead and a pinched waist. Every
assertion passes on her: she loads, animates, classifies and mounts a weapon.
It shows up only in `roster_capture`.
**Diagnosed, not fixed.** Her `driven_bones` contains `Root_001` through
`Root_007` — and those are her HAIR roots. The Godot surface dump shows
`Hair_A` dominated by `Root_001_001:3203`, `Root_007:3203`, `Root_005:2642`.
So the animation is keying bones that physics is supposed to own, which is
non-negotiable #2 being violated by the role resolver rather than by a clip.
The corroboration is in the surface table: her hair surfaces report only 1.8%
and 9.4% chain share, because most of their vertices belong to `Root_00N`,
which is in no chain at all.
`Root_00N` matches no COSMETIC stem, so `is_cosmetic` does not catch it and
nothing keeps it out of the driven set. Fixing it by adding "root" to the
stems would be wrong — a rig whose actual root is called `Root` would lose its
hips. The fix is structural: a bone whose geometry is dominated by a mesh
classified `hair` is a hair bone, whatever it is called. The surface table
now makes that answerable at build time, which it was not when this rig was
imported. Not attempted here — it needs a Blender re-run and re-verification
of all six characters.
Her `head` role is also wrong (`Unused_Noname_010`, when a real `Head` bone
exists and is in her spine chain), and her spine chain runs two junk bones
PAST the head. Probably the same import; worth fixing in the same pass.
- **A stray `Icosphere` ships inside every skin GLB** — 42 vertices, no parent,
no vertex groups. It rides in from the animation library. Harmless, and now
skipped by construction rather than by name in `surface_map`, but the export
should not be producing it.
@@ -0,0 +1,146 @@
# Cloth and hair — `characters/spring_bones.gd`
A position-based (PBD) solver over the rig's own cosmetic bones, the same shape
as Magica Cloth 2's BoneCloth, which is the tool the Hoyoverse-style pipelines
are built around.
## Why cloth cannot be solved with weights
Weight a skirt to the thighs → trousers. Weight it to the hips → a rigid bell.
Neither is cloth. A skirt reads as cloth because it **lags** — it keeps going
when the hips stop, swings out through a turn, floats on the way up through a
jump. That is inertia, and inertia has to be integrated, not skinned.
## The solve
Installed as a `SkeletonModifier3D` **after** `ShooterPoseModifier`, so it reacts
to the final body pose (animation + lean/slide/aim layer).
1. **Every JOINT is a particle.** Bone `i` spans particle `i` to `i+1`, so a
bone's HEAD can move. This is the load-bearing choice: a contact with no
rotational leverage — a thigh against the top of a panel — is resolved by the
whole panel moving, which is what a real skirt does.
2. **Predict** with inertia, gravity and wind, in the chain ANCHOR's frame, so
travelling at a steady speed excites nothing.
3. **Relax everything together**, Gauss-Seidel: cross-panel links, then per
chain — bone length, bend limit, backstop, colliders.
4. **Convert to rotations once**, at the end, and feed back the poses the
skeleton ACTUALLY got.
Order matters: **links first, chains second**, so the last thing to touch any
particle is its collision. With the chains first, every iteration ended by
pulling neighbouring panels back toward their rest separation — straight into the
leg just cleared. Measured on a slide: 93 mm in, 95 mm out; with the links off
entirely the same frame solved to 27 mm.
Then a short tail of **collision-and-length-only** passes, because the bend limit
and the backstop are shape constraints and re-imposing them after each collision
makes the two argue rather than converge.
## What the previous version did wrong
One spring per bone plus FOUR repair passes behind it, each writing bone poses the
next read back and partly undid — and the last (an ancestor "lift") wrote poses
never fed back into the spring state, so every frame began pulling against a pose
the springs did not know about. That feedback was the skirt "glitching out".
Three bolt-on stages (a bodily chain push, an ancestor lift, a drape weight) all
existed because a rotation-only solver cannot clear a contact near the head it
rotates about.
## Per-class tuning (`TUNING`)
| | w | zeta | gravity | wind | stray | hinge | bend |
|---|---|---|---|---|---|---|---|
| hair | 15 | 0.45 | 1.6 | 0.30 | 0.45 | 26° | 52° |
| skirt | 12 | 0.48 | 2.0 | 0.18 | 1.25 | 78° | 55° |
| cloth | 13 | 0.55 | 1.9 | 0.18 | 1.10 | 62° | 52° |
- **`hinge` vs `bend`** are the same constraint meaning different things. Between
segments it is BENDING stiffness (how sharply cloth may crease) and belongs
tight. At the root it is a HINGE at the waistband — a knee coming up to hip
height puts a thigh horizontally through where the front panel hangs, and the
panel must ride onto it, which is most of a right angle. Magica's skirt guide
suggests 20° at the root, but that is for a chain whose first bone is a large
share of the skirt; Taila's first segment is 49 mm of a 288 mm panel, so 20°
there moves the panel below it by **17 mm**.
- **`stray`** is Magica's "Max Distance" — how far a particle may end from where
the animation alone would have put it. Tight on hair (it has nothing to get out
of the way of; this is what stops flailing), loose on cloth (a skirt has to lie
along a thigh that has gone horizontal in a slide).
- **`gravity` is small** because the authored rest pose already has the garment
hanging. A constant force offsets the resting particle by `g/w²`, so a large
value pulls the hem below where it was modelled — into the thigh it then has to
be pushed out of.
## Colliders
Five capsules, measured from the mesh by `retarget.py::_leg_colliders`:
- A **waist LID** (`lid: true`) across the pelvis. Magica's skirt guide is blunt
about this: one big sphere at the waist "acts as a lid that prevents particles
in the skirt from slipping into the body". Leg capsules alone only stop cloth
going through a thigh; nothing stops a panel swinging INWARD into the pelvis.
- **Tapered** thigh and shin capsules — separate head and tail radii. A limb is
not a cylinder: Taila's thigh is ~0.11 m at the hip and ~0.06 m above the knee.
Fitted as a least-squares line through ten bands, dropping the contaminated end
bands, with twist children folded in.
- `from: 0.10` — the capsule starts BELOW the hip joint. The top of a thigh is
hip, buried inside the body the skirt hangs from.
**Per-point rest clearance.** Each (bone, collider) point's radius is capped to
just inside where that point rests, so the authored rest pose is a valid state.
Without it, cloth hanging against a thigh is shoved out and pulled back every
frame forever. The cap is PER POINT, not per bone — scaling a whole bone by its
worst point switches collision off for every panel whose top hangs against the
thigh, which is all the ones that matter.
## Collision hulls come from the MESH
`SkinnedPlayerModel._cloth_hulls`, at load time: every vertex a cloth bone
dominates, binned into a ~20 mm grid, outermost cells kept, capped at 14 points.
The sidecar's ten farthest-point samples describe a panel's corners and hem and
leave its MIDDLE unsampled — exactly where a thigh comes through. The solver
reported every contact resolved while 158 vertices sat 95 mm inside a leg.
## There is no drape term
"Cloth takes a share of the leg's motion before the solver runs" is a real
technique (Hoyoverse rigs carry a partial constraint from the leg onto the upper
skirt bones). It was here to move panels the old rotation-only solver could not.
With it against without, over the movement sweep:
```
run 101 -> 92 mm fall 82 -> 49 mm dash 136 -> 95 mm
idle after a dash 103 -> 20 mm
```
Worse in every state but a walk, and 20× worse in stability (0.48 vs 0.05
deg/frame at a dead idle) because its target sat inside the leg the collision was
pushing out of. **If you reintroduce it, the target must be collision-free
first.** A naive "seat the reference on the limb" pass was tried and destabilised
the reference chain, because a parent's seat rotation cascades into every child.
## Cost and LOD
~2.6 ms per character per frame at full quality, three quarters of it collision.
It was 10.9 ms before the inner loop stopped rebuilding every capsule and
reallocating the hull array for every (bone, collider, pass).
`SpringBones.lod` 03 drops passes then collision;
`SkinnedPlayerModel._update_cloth_lod` picks it from camera distance
(6 / 14 / 28 m) four times a second.
If you add cloth bones, re-run `debug/cloth_perf_check.gd`. The cost is the
product of joints × colliders × hull points × passes and all four are easy to
raise by accident.
## Hair specifically
- Hair DOES collide now. It used to be excluded because a collision push happened
after the integrator and so was deaf to spring tuning — long back hair got
shoved out of a thigh and hauled back at stride frequency, which was the blur.
Inside the relaxation there is no such fight.
- Hair chains are NOT linked sideways; linking them stiffens them into rope.
- Hair sits silent at idle (0.005 deg/frame). If it does not, something is
driving its target — that was the drape, and it is the first thing to suspect.
@@ -0,0 +1,299 @@
# How imports fail, and why the checks did not catch it
Seven characters shipped with "All checks passed" and four of them were visibly
broken in game — lying on their backs, seven times too big, facing backwards,
holding a gun that floated near their chest. Nothing in the verification suite
was wrong. It just never asked the questions that mattered.
**The generalised lesson, which is the whole of this page:**
> The suite verified that the character was *well-formed* — skeleton attached,
> weights authored, clips non-frozen, cloth unkeyed. It never verified that the
> character was *correct*: the right size, the right way up, the right way round,
> and reachable by the runtime. Structural validity and usable output are
> different properties, and a pipeline that only checks the first will ship the
> second broken every time a source deviates from the one it was written against.
Every check below is cheap. None of them existed.
---
## 1. Up is not always +Z — measure it, never assume it
**Symptom:** the character is enormous and lying on their back.
**Hit:** aria, momo, hikari. **Confidence: certain** — measured, not inferred.
`flatten_and_scale()` sets the character's real-world size with
```python
height = hi.z - lo.z # Blender Z is up
s = target_height / height
```
which is right for a model that arrives Z-up in Blender, and catastrophic for one
that does not. If the character is actually lying along Blender's Y, `hi.z - lo.z`
measures their **thickness** — about 0.25 m — so `s = 1.75 / 0.25 ≈ 7`. The model
is scaled seven-fold *and* left on its back. One wrong assumption, both symptoms.
The bind-pose bounding boxes say it plainly. A correct character is tall on Y and
narrow on X and Z:
| | X | Y | Z | |
|---|---|---|---|---|
| taila | 1.24 | **1.75** | 0.69 | correct |
| kiyoko | 1.50 | **1.75** | 0.32 | correct |
| mannequin | 1.86 | **1.75** | 0.35 | correct |
| aria | 6.12 | 1.75 | **7.48** | tall axis is Z — lying down, ~7x too big |
| momo | 6.89 | 1.75 | **7.95** | same |
| hikari | 6.23 | 1.75 | **13.01** | same, and worse |
The 1.75 lands on Y for everyone because the exporter maps Blender Z to glTF Y.
That is exactly what makes the bug invisible: **the number you normalised always
comes out right, whether or not it was the right number.** A check on "is the
height 1.75" passes on all seven of these.
**Rule:** derive the up axis from the SKELETON, and rotate the model upright
before scaling anything. `flatten_and_scale()` now does this.
**Measure it 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 whatever the last non-cosmetic
bone in the chain is, and on a rig with a facial skeleton that can be a bone
sitting BELOW the hips. Momo's did, so the first version of this fix stood her
neatly on her head — correct size, correct proportions, upside down. Feet cannot
be mistaken; they are the bottom of a standing character on every rig, and
`foot.L/R` have resolved on every source met so far.
**And compare it to WORLD up, not to the model's own proportions.** The obvious
test — "is the spine the longest axis of the bounding box?" — catches nothing
here, because a model rotated as a whole is internally consistent: aria's spine
*is* her longest axis, she is just lying down. She passes that test comfortably.
The question is whether the character stands up in the world the game runs in,
which means asserting the spine runs along Blender +Z, full stop.
Two more numbers are worth asserting for free: the other two extents should be
under about 2.5 m, and the height itself should land in a human range. Those
three together are what separated the four good characters from the three broken
ones on the first run.
**Where this comes from:** all three casualties have bone names like `Hips`,
`Left leg`, `Upper Chest`, `Breast_L` — a VRM that someone imported into Blender,
renamed, and re-exported. Kiyoko kept raw VRoid `J_Bip_*` names and was fine. A
**Blender round-trip can bake an axis rotation into the export**, and that family
of files is common on Sketchfab. Treat "the bone names have been humanised" as a
signal to check the axes.
---
## 2. The runtime must look bones up by ROLE, not by name
**Symptom:** the gun is not in the hands — it floats near the chest, and can
point backwards. **Hit:** aria, momo, kiyoko, hikari.
**Confidence: certain** — measured.
`SkinnedPlayerModel.set_weapon()` finds the hand with
```gdscript
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
```
Three hardcoded spellings. Against the shipped roster:
| Skin | `hand.R` resolved in the sidecar | matched by `_find_bone` |
|---|---|---|
| taila, miku, mannequin | `DEF-hand.R` | yes |
| kiyoko | `J_Bip_R_Hand` | **no** |
| aria, momo, hikari | `Right wrist` | **no** |
When it misses, `set_weapon` falls back to parenting the weapon to the model root
at a fixed chest-height offset. The gun is then not attached to the character at
all; it hangs in space near the torso and inherits none of the arm's motion.
This is the same class of bug as `verify_character.py` looking for legs by the
substrings `thigh`/`shin`. **`tools/rig_map.py` exists precisely so that nothing
downstream has to guess a bone name, and the resolved roles are written to
`<model>.rig.json` for exactly this purpose — but only some consumers read them.**
**Rule:** every bone lookup anywhere in the runtime or the tools goes through the
sidecar roles, with a name heuristic only as a last-resort fallback. Grep for
`find_bone`, `findn(`, and any tuple of bone-name spellings; each one is a rig
this project has not met yet.
---
## 3. Facing is inferred and never verified
**Symptom:** the character runs backwards. **Hit:** kiyoko.
**Confidence: probable** — the mechanism is understood, the specific cause is not
yet isolated.
Two independent things decide which way a character ends up pointing:
`retarget.py::facing_correction()` computes a yaw to align the character's rest
pose with the library's, and `SkinnedPlayerModel.facing_flip` then applies a
blanket 180° because "glTF forward is +Z; players face -Z". If the source already
faces the other way, the two compose to a character running backwards — and
nothing anywhere measures the finished result.
**Rule:** facing is measurable from the skeleton — the toes are forward of the
ankles. `flatten_and_scale()` now snaps that to Blender -Y, the convention the
runtime flip is built around, so every character leaves the pipeline pointing the
same way whatever the source did. Kiyoko was 180 degrees off and is now correct.
Snap to the nearest QUARTER TURN, not to the measured angle: a rest pose with the
feet slightly splayed is not a character who is 7 degrees turned, and correcting
it as one puts a permanent yaw on the whole skeleton.
---
## 4. Generated cloth chains must be validated against the geometry they drive
**Symptom:** hair stretches wildly during animation.
**Hit:** miku. **Confidence: probable.**
`tools/cloth_bones.py` grows chains for a costume that has none, then **clears
each vertex's existing body weights** and re-assigns it to the fitted chain,
keeping the original only over the first 22%. That is correct when the polyline
actually follows the clump. When it does not — a large or forked island, a
mis-picked root end — vertices land on a bone travelling somewhere else entirely,
and linear-blend skinning turns that into stretching.
The tool reports how many chains it grew. It never checks whether they *work*.
**Rule:** after growing chains, verify per vertex that its assigned bone stays
near it — pose the chain a few degrees and assert the vertex moves with its bone
rather than away from it. And never destroy the original weights without a
fallback: a generated chain should blend against the body weight it replaced, so
a bad fit degrades to "stiff" rather than to "torn".
---
## 5. A weapon has to be scaled to the arm that holds it
**Symptom:** hands flat and open, both fists bunched together at the grip, the
stock nowhere near the shoulder. **Hit:** every model.
**Confidence: certain** — measured and fixed.
Three separate causes, all of them "a constant where a measurement belonged".
**The gun was mounted with a constant rotation.** `set_weapon` used
`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 mounts 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
handing it the IDENTITY means "forward is the hand bone's -Z" — true by
construction on any rig — and the wrist absorbs the roll.
**The gun was full size on a stylised character.** The set is modelled at
real-world scale (an M4 is 0.84 m butt to muzzle); these characters have 0.47 m
arms against an adult 0.52. That puts the handguard 0.66 m from the support
shoulder — 0.2 m beyond reach — so a loop slid the support hand back down the
weapon until it fitted. On Taila a support offset authored at 0.35 m collapsed to
**0.083 m**: two fists together at the grip, which reads as a two-handed pistol
grip, not a rifle.
The fix is not a fixed scale factor. The binding constraint is the SUPPORT arm:
its hand must reach `stock + fore` in front of the pocket, from a shoulder half a
shoulder-width off the weapon axis. Solve that triangle for the largest gun whose
handguard still lands inside the arm's reach. Taila and Kiyoko come out at
different scales from the same code, both with the support hand at its full
authored handguard distance and no sliding at all.
Also give the slide-back loop a FLOOR. A slightly straight support arm looks far
better than no handguard hold.
**Nothing posed the fingers.** Every hand was flat and open — the single loudest
tell that a character is not really holding anything. Fingers are now closed by
the pose layer, using an axis derived from each hand's OWN anatomy in the rest
pose, because no two rigs agree on finger bone orientation:
```
along wrist -> middle knuckle the length of the hand
palm middle knuckle -> thumb tip across it; the thumb OPPOSES the
fingers, so it is on the palm side by
construction — a fact about hands, not
a rig convention
curl along x palm turning about this swings the fingers
into the palm, not sideways
```
The trigger hand's index finger gets a much shallower curl than the rest — it
lies along the trigger. Curling it with the others is what makes a character look
like they are squeezing a bar of soap.
Finger bones now resolve by role too (`rig_map.DIGITS`), across all three naming
families met so far: Rigify `DEF-f_index.01.L`, VRoid `J_Bip_L_Index1`, and
Blender-export `IndexFinger1_L`. Segments are ordered by DEPTH BELOW THE HAND,
not by the number in the name — the numbering is not consistent between families,
but the hierarchy always runs knuckle to fingertip.
**The support hand came out upside down**, because its orientation was built as
a shortest arc plus a constant twist: align the hand's forearm line to the barrel
(`Quaternion(fa_rest_dir, aim_dir)`), then add 0.5 rad of roll. A shortest arc
says NOTHING about roll — it is the minimal rotation between two directions — so
the entire roll came from that constant, and a constant is right only for the rig
it was tuned on.
Orienting a hand onto something it grips is a FRAME-TO-FRAME problem, and framing
it that way leaves nothing free to guess:
```
curl axis must lie along the object's axis, or the fingers close ACROSS the
handguard instead of around it
palm must face the object — up, for a hand supporting from underneath
along falls out of the other two (palm x curl)
```
Map the hand's rest anatomical frame onto that target and the roll is determined,
not chosen. Verified on both the Rigify-named mannequin and the VRoid-named
Kiyoko: fingers wrap the handguard from below, over the top.
**Rule:** anything expressed as a constant in a rig's local frame — a mount
rotation, a grip offset, a curl axis, a weapon size, a wrist twist — is a guess
about one skeleton. Derive it from the skeleton, or hand it to a solver that
already knows the answer. And when a rotation needs a specific ROLL, never build
it from a shortest arc: that operator has no opinion about roll, so whatever you
add afterwards is doing all the work.
## 6. Some sources are not salvageable, and the gate should say so
**Hit:** hikari. She failed every way at once — stretched and warped, tiny, far
away, gun backwards. Her rig has been through at least two toolchains: her
cosmetic bones are zero-length terminators, she carried a second armature with
its own clips, and her feet and her spine disagree about which way is up, so the
stand-up correction cannot resolve her either. She is now REJECTED by the gate
and removed from the roster rather than shipped broken.
**Rule:** a source that fails several unrelated checks is not a tuning problem,
it is a bad file. Spend the effort on finding a cleaner source, not on repairing
this one — and make sure the gate blocks it, because the failure mode before this
work was that everything passed and the breakage was only visible in game.
The vetting snippet in `separation.md` catches most of these before download:
several meshes, 50+ joints, cosmetic bones present. Add a look for duplicate
armatures and for bone chains whose bones are all at the same position.
## 7. Known-unsolved, and honestly so
Taila's legs still clip through the front of her skirt in a run, slide and dash
(~95 mm). See `cloth-and-hair.md`. The solver sees the contact and pushes on it
every iteration; what remains is a standing fight between the collision and the
garment's shape constraints, not a missing check.
---
## The check that would have caught most of this
One pass over the finished GLB, before it is ever registered:
```
POSTURE tallest axis of the bind-pose AABB == the hips->head axis,
and the other two are under ~2.5 m (catches #1)
SCALE height within a few percent of --height (catches #1)
FACING toes forward of ankles, along the world forward the game
expects, AFTER facing_flip (catches #3)
REACHABLE every role the runtime looks up — hands, head, spine — resolves
through the sidecar and not by name (catches #2)
CLOTH every chain has measurable extent, and its vertices track it (#4)
```
None of these needs Blender or the engine; the bind-pose AABB and the inverse
bind matrices in the GLB are enough for the first four.
@@ -0,0 +1,76 @@
# Growing cloth bones on a model that has none
`tools/cloth_bones.py`, opt-in via `pipeline.py --grow-cloth`.
## When you need it
The spring solver simulates cloth **bones**. A garment with none is welded to
whatever body bone it was weighted to, and no runtime setting changes that. Every
auto-rigged model is in this state, and so is many a "rigged" download whose
skeleton is body-only.
Check before importing (no Blender needed — see `separation.md`): if the joint
list has nothing matching `hair|skirt|tail|ribbon`, the costume will not move.
## What it does
Runs on the **rigged** model, before the retarget.
1. **Finds the geometry by material slot.** A slot called `hair` is hair. The
artist already answered the question, and on a joined mesh — which is what the
auto-rig leaves behind — the material slot is the only separation left.
2. **Splits it into clumps.**
- *Hair*: connected islands over the mesh's own edges. A strand is a connected
piece of surface; clustering by position would merge two ponytails passing
near each other and split one that bends.
- *Skirt*: radial wedges around the body's up axis. A skirt is ONE connected
surface, so islands would return the whole thing as a single piece — which
is the bell-shaped failure. Wedges are the ZZZ-convention panel grid.
3. **Fits a polyline down each clump** by binning vertices by distance from the
anchored end and taking each bin's centroid, so the chain follows the piece's
own curve. A straight root-to-tip line cuts the corner on a bent ponytail and
every vertex on the outside of that bend ends up on a bone travelling the
wrong way.
4. **Builds a bone chain along the polyline**, parented to the body bone that was
already holding that geometry.
5. **Re-weights** each vertex onto the two bones either side of where it projects,
blended by how far between them it lands — while keeping the ORIGINAL body
weight over the first `ROOT_BLEND` (22%) of the chain, so the scalp stays on
the skull and the waistband stays on the hips.
Then `retarget.py::describe_rig` finds the chains by name exactly as it would an
artist's, and writes tips, hulls and neighbours to the sidecar.
## Tuning
```
--hair-segments 3 bones per hair strand
--skirt-segments 4 bones per skirt panel
--skirt-panels 12 radial panels; more = opens around a leg more smoothly
--classes hair,skirt which material names to look for
```
`MIN_STRAND_LENGTH` (60 mm) and `MIN_STRAND_VERTS` (12) drop fringe and
ornaments. Simulating those costs the same as a ponytail and only ever produces
jitter around the face.
## Worked result: Miku
```
19 chains, 57 bones grown from one `hair` material slot
sidecar: 0 cloth chains -> 19
idle stability: 0.007-0.018 deg/frame (quiet)
mesh intact, no tearing (debug/character_look_capture.gd)
```
## Limits
- **It cannot find a garment that shares a material with the body.** Miku's skirt
is on her `body` slot, so she got hair chains and no skirt. Splitting by
geometry rather than by material would be the next step.
- **It does not set `weights_authored`.** An auto-rigged model still gets the
destructive load-time `SkinLegRepair`. That repair only touches cross-leg
vertices, so it leaves hair alone, but it is worth knowing.
- **Grown chains are a fallback, not a substitute for a rigged source.** They
follow the geometry, but an artist's chains carry intent — where a panel should
split, which strands move together — that no fit recovers.
@@ -0,0 +1,127 @@
# Rigging and retargeting
## Roles, not names
`tools/rig_map.py` resolves a skeleton to ROLES — `hips`, `spine[]`, `neck`,
`head`, and `limb[(role, side)]` for `thigh/shin/foot/toe/shoulder/upper_arm/
forearm/hand`. Matching is by whole tokens plus anatomy (chain length, position,
which bone is a child of which), so a Rigify `DEF-thigh.L`, a Mixamo
`mixamorig:LeftUpLeg` and a bespoke `Bip01_L_Thigh` all land on the same role.
This is what removed the need to destroy foreign skeletons. `roles.missing_core()`
is the gate: if the core roles cannot be found the pipeline stops rather than
guessing.
The resolved roles are written to `<model>.rig.json` and read at runtime by
`ShooterPoseModifier._resolve`, which aliases its library-flavoured names
(`DEF-hips`, `DEF-spine.001`…) onto whatever this rig calls them. Taila's hips
are `DEF-spine`, her head is `DEF-spine.006`, and she has **no bone with "neck"
in its name at all** — unresolved, every lean, aim pitch and slide head-lift
silently did nothing.
### Names lie. Anatomy does not.
Four sources that were not authored against the library's spelling each broke
role resolution in a different way. All four fixes are in; the lesson is that
**anything guessing anatomy from a name needs a structural fallback.**
- A bare `leg` is the SHIN on Mixamo (`LeftUpLeg` is the thigh) and the THIGH on
a rig whose shin is called `knee`. Same token, opposite bones, both common. So
`RigRoles` walks the leg upward from the foot and fills in whatever the names
could not, stepping over twist bones.
- Claims are granted **longest-stem first**. Taking the first role in
`LIMB_ORDER` that matched at all let shin's catch-all `"leg"` beat thigh's
exact `"upperleg"`, and the outcome depended on bone iteration order.
- Cosmetic and spring classes accept a **two-character positional suffix**:
`HairFL`, `HairFR`, `HairF_Top` tokenise to `hairfl` and matched nothing, so a
character imported with no hair chains at all. Two characters is short enough
that `forearm` and `earring` are still not swept in.
- VRoid spells legs `UpperLeg`/`LowerLeg`. Any CHECK that name-matches
`thigh`/`shin` will silently pass or silently fail on it — see
`verification.md`.
If a new source fails with `could not identify these bones`, dump the joint names
first (`separation.md` has a no-Blender snippet) and decide whether it is a
missing stem or a case only anatomy can settle.
## Rebuilding the hierarchy
A Rigify DEF-rig exports its chain roots parented straight to the armature root,
because Rigify drives them by constraint rather than by hierarchy. Left that way,
rotating the hips leaves the legs, skirt and hair floating in place.
`rebuild_hierarchy` re-attaches orphans: by anatomy where the role is known, and
by rest geometry (nearest plausible parent) otherwise. **Cloth may only attach to
the trunk.**
## Subdividing cloth panels
`subdivide_cloth_panels(arm, meshes, roles, segments=4)`.
A skirt panel that is a single bone from the waist is a rigid flap: it can only
rotate about its own head, and a contact near that head is unreachable at any
angle. Splitting each panel into a chain is what lets it bend, and it is why the
ZZZ-convention skirt is a grid rather than a fan.
On Taila this turns 21 panel bones into 21 chains of 4. The segment lengths come
out uneven (49/49/49/141 mm) because the last segment runs on to the hem.
Weights are redistributed along the panel as it is split, so the mesh follows the
new chain.
## Twist bones
A forearm or thigh twist bone takes half the roll of its parent so the skin does
not candy-wrap. They are detected (`is_segment_of`) and recorded in the sidecar's
`twist` list. They are also folded into the limb when measuring collider radii:
most of a thigh's surface belongs to `DEF-thigh.L.001`, and what is left
dominated by `DEF-thigh.L` is mostly hip flare, which fitted a 0.154 m radius —
a 30 cm thigh.
## Joint helpers
`SkinJointHelper.install` runs for EVERY model however it was rigged. Linear-blend
skinning collapses any joint by cos(angle/2) no matter how good the weights are;
measured at the knee, 0.77 without helpers against 0.99 with. They are updated
LAST, inside the modification pass, so each helper tracks whatever final rotation
its child bone ended up with.
## The retarget maths
Bake each clip as a **rest-relative delta**:
```
R_world = src_pose_rot * src_rest_rot⁻¹ what the clip does
tgt_rot = R_world * tgt_rest_rot done to THIS rig
```
Copying absolute world orientation instead — which is what a constraint bake does
— forces the library's bone ROLL onto a mesh bound with a different one, and
twists every limb by a constant offset.
Also handled: a facing correction (`facing_correction`) when the library and the
character face different ways, and a hips-height scale so a short character does
not float.
## Export flags that matter
```python
export_bake_animation=False,
export_optimize_animation_keep_anim_armature=False,
```
`keep_anim_armature` forces a track onto every bone whether or not the clip
touches it. Off, the skirt and hair export with **no tracks at all** and belong
entirely to the spring solver. This one flag is the animation/physics split.
## Height normalisation
`flatten_and_scale(arm, meshes, TARGET_HEIGHT)` — default 1.75 m. Applied before
the retarget so the library's stride matches the character's legs.
## When a model has no skeleton
`tools/autorig.py` will fit one, and the pipeline accepts the quality loss:
nearest-bone weights, cross-leg bleed, no cloth chains. `weights_authored` comes
out false, `SkinLegRepair` runs at load to snap the worst of it, and the
character will have no secondary motion. Prefer finding a rigged source.
@@ -0,0 +1,178 @@
# Body, garments, hair — what must stay separate
The single structural idea behind an anime-styled character rig, and the thing
every failure in this project traced back to.
## The convention this pipeline follows
Hoyoverse-class character rigs (Genshin, Star Rail, Zenless Zone Zero) are built
the same way, and the parts that matter are visible in any of their exported
assets and in the toolchains built around them (Magica Cloth 2, UnityChan
SpringBone, VRM's spring-bone spec — all of which exist because this shape is
the convention):
| Convention | What this repo does |
|---|---|
| Body, face, hair and each garment are SEPARATE meshes with separate materials | Never join meshes; 18 meshes on Taila are all kept |
| Skirts get a radial grid of bone chains — many panels, several segments each | 21 panels × 4 segments, subdivided at build time |
| Hair is chains of 24 bones from the scalp | Detected from the source rig; 14 chains on Taila |
| Cloth/hair bones carry NO animation keys; physics owns them | `export_optimize_animation_keep_anim_armature=False` |
| Physics colliders are a small set of capsules: thighs, shins, and a big one at the waist acting as a lid | 5 capsules, measured from the mesh (`_leg_colliders`) |
| Neighbouring skirt panels are linked sideways | 278 cross-panel distance links from shared vertices |
| Each surface is TAGGED with what it is, so shading can differ per class | `tools/surface_map.py` writes it; `SkinSurfaces` reads it |
| Cel shading with a ramp, plus a separate outline pass | `LevelMaterials.apply_toon_recursive` + `apply_character_look` |
Where we differ: their collider capsules and cloth parameters are hand-authored
per character by a technical artist. We MEASURE them from the model's own
geometry at build time, because there is no artist in this loop. That is the
whole reason `<model>.rig.json` exists.
Where there IS an artist in the loop, there is now somewhere to put the answer:
`debug/rig_lab.tscn` and the layered files behind it (see the SKILL). Measuring
is the default and hand-authoring is the override, rather than the other way
round.
## Separation is only half of it — the parts have to be NAMED
Keeping the meshes apart is structural. Knowing which is which is what lets
anything act on the difference, and until the surface table existed nothing did:
every surface of every character took one set of shading numbers, calibrated on
skin, because there was no way to ask whether a surface was hair.
The table lives in the sidecar as `surfaces`, keyed on the MATERIAL name — mesh
node names are `Object_7` through `Object_32` on every character in this game and
carry no meaning, while material names survive the glTF round trip intact and are
what the artist actually chose. It is decided three ways, in descending order of
how much it trusts them:
1. **the material name.** On VRoid exports it is formal —
`N00_000_00_Body_00_SKIN_Instance` carries its own class infix, and every
VRoid character here uses SKIN / FACE / EYE / HAIR / CLOTH.
2. **the weights.** Decisive when the name says nothing: a surface pulled by the
skirt chain is a skirt whatever it is called. The threshold is deliberately
low (5%), because VRoid welds the whole cap of the hair to the head bone and
springs only the strands — kiyoko's hair mesh is 85% head, and a majority rule
would call it skin.
3. **the material flags.** These catch line-work, which is the one class that is
not a surface of the character at all.
It is built from the same chains the spring solver uses, so the two can never
disagree about which bones are a skirt.
## Why the separation is load-bearing
**Materials.** The body wants skin shading, hair wants an anisotropic-ish ramp
and its own outline weight, cloth wants flat banding. One merged mesh gets one
treatment and everything reads as plastic.
**The cloth solver.** `SkinnedPlayerModel._cloth_hulls` extracts, per cloth bone,
the vertices that bone dominates — that is only meaningful while the garment is
its own mesh with its own weights. Merge the meshes and the solver has no way to
know which vertices are skirt.
**Weights.** A joined mesh rebound by nearest-bone weighting produced 2817
vertices pulled by BOTH legs on Taila (16% of the model, worst a dead 50/50).
Such a vertex sits between the legs and stays there while they separate,
stretching every triangle around it. That is the "squashing on jump" and the
"elongated boot".
## How cloth is detected and classed
`tools/rig_map.py::is_cosmetic` matches WHOLE TOKENS in a bone name against:
```
hair skirt cloth ribbon tail cape coat scarf sleeve breast bust
feather strap antenna wing (+ face/eye classes that must never swing)
```
Whole-token only — `shoulder` must not match `should`, and a bone called
`hair_root` is hair while `chairbone` is not.
`retarget.py::SPRING_CLASSES` is a NARROWER set: the classes that actually get
secondary motion. A face-shape or eye chain is cosmetic but must never swing.
Each chain lands in `<model>.rig.json` as:
```json
{ "class": "skirt",
"root_parent": "DEF-spine.001",
"bones": ["DEF-skirt", "DEF-skirt.seg1", "DEF-skirt.seg2", "DEF-skirt.seg3"],
"tips": [[x,y,z], ...], // where each bone points, in its own space
"hulls": [[[x,y,z], ...], ...], // sample of the geometry it drives
"neighbours": [{"DEF-skirt.L": 10.7, ...}] // shared-vertex weight
}
```
`tips` exists because **a glTF skeleton carries no bone tails at all**, and
Taila's skirt panel bones have no children either, so nothing in the skeleton
says which way a panel hangs. It is measured from the geometry the bone drives.
`neighbours` means SHARED VERTICES — the artist's own answer to which pieces of
cloth are sewn together. Adjacency by name or by rest distance would both be
guesses.
## The three rules that keep it intact
1. **Cloth may only ever parent to the trunk, never to a limb.**
`rebuild_hierarchy` enforces this. A skirt parented to a thigh becomes
trousers.
2. **Cloth is never SKINNED to a leg.** There was a `bind_cloth_to_legs()` that
gave cloth vertices near a thigh a share of that thigh, so the skirt would
ride the leg the way a real one does. It is deleted. A vertex weighted 0.9 to
a thigh cannot be moved by its own cloth bone, so the solver loses the
authority to push it out of that leg — and 0.9 of a rotation always lags the
surface doing 1.0 of it, so the leg overtakes it anyway. It also poisoned the
collider measurement: 2258 skirt vertices counted as thigh geometry and fitted
a 0.28 m thigh.
3. **Cloth bones carry no animation tracks.** If the exporter bakes rest-pose
tracks onto them (`keep_anim_armature`), the AnimationPlayer overwrites the
spring solver every frame.
## Worked example: why the two shipped characters differ so much
Both are in `assets/characters/skins/`. Compare their sidecars:
| | Taila | Miku |
|---|---|---|
| source had a skeleton | yes | **no — 5 meshes, 0 joints** |
| `weights_authored` | true | **false** |
| cloth chains | 35 (127 bones) | **0** |
| twist bones | 8 | **0** |
| meshes shipped | 18 | **1** |
Miku's source (`assets/characters/incoming/miku_test.glb`) is an unrigged mesh,
so she went through `autorig.py`: joined to one mesh, rebound by nearest-bone
weighting, no cloth chains. Her twin tails and skirt are dead geometry that
cannot move, and `SkinLegRepair` runs destructively on her every spawn.
Nothing downstream can recover this. **The single highest-leverage decision in
this whole pipeline is choosing a source model that already has a skeleton with
skirt and hair bones.** Everything else is recoverable; this is not.
A quick check on any candidate, without Blender:
```python
import json, struct
with open(path,'rb') as f:
f.read(12); clen,_=struct.unpack('<II',f.read(8))
j=json.loads(f.read(clen))
nodes=[n.get('name','') for n in j['nodes']]
joints=[nodes[i] for s in j.get('skins',[]) for i in s['joints']]
print(len(j['meshes']), 'meshes', len(joints), 'joints')
print([n for n in joints if any(t in n.lower() for t in ('hair','skirt','tail','ribbon'))])
```
Several meshes, 50+ joints, and a non-empty cosmetic list means a good source.
## Checking a source model before importing
```bash
python tools/verify_character.py <model.glb>
```
What you want to see: several meshes, bone names containing `skirt`/`hair`,
twist bones (`thigh.L.001`), and weights that are NOT all at 4 influences.
`weights_authored` in the sidecar is measured from exactly this and decides
whether the destructive load-time repair runs.
@@ -0,0 +1,112 @@
# Stylization — the cel-shaded look
Two passes, applied at load in `SkinnedPlayerModel.load_model`:
```gdscript
LevelMaterials.apply_toon_recursive(scene) # world-wide toon shading
LevelMaterials.apply_character_look(scene) # character-only corrections
```
## The trap: imported models bring their own line-work
Anime models exported from MMD/VRoid/Blender toon setups very often ship the
outline **as geometry** — an inverted-hull shell of the mesh with a flat black,
UNTEXTURED material, plus separate flat cards for the eye whites, irises and the
pupil highlight. The mesh you import is not just the character; part of it is
already the drawing.
Toon-lighting that shell is what put a **white rim on every hair strand**. It is
an inverted hull whose normals face away from you; a lighting model that adds a
rim term lights it brightly exactly where it is supposed to read as ink.
`apply_character_look` therefore looks for the model's own line-work and handles
it flat and unshaded. "Untextured" alone is NOT the test — that made every
flat-coloured model render as a black silhouette, because Quaternius' mannequin
has two untextured materials (a yellow body, lilac joints) and both were hidden
as though they were an outline shell. The test asks three things instead: is it
named `eyes*`, is it drawn front-face-culled (the classic inverted-hull setup),
or is its albedo near-black. An ink shell is black; a flat-coloured character is
any colour at all.
That test now runs at BUILD time (`tools/surface_map.classify_linework`) and its
answer lives in the sidecar. `SkinSurfaces.guess()` is the same rule kept as the
runtime fallback, for a model with no surface table — and the cull-mode half of
it is re-run at runtime even when the table exists, because glTF has no way to
say "draw only the backfaces" and an inverted hull cannot survive the round trip
as a cull mode. Blender genuinely cannot see it; Godot can.
What it then does:
- **Outline hull** → made fully transparent rather than deleted. Deleting a
surface would renumber the rest and break the mesh's own skin bindings. The
game draws its own outline.
- **Eye cards** (`resource_name` starts with `eyes`) → flat ink, except anything
with `HL` in the name, which is the glint in the pupil and really is white.
If a newly imported character comes out with a white halo, a black silhouette,
or black eyes that should have irises, the line-work test and the name-matching
below it are where to look — now in `tools/surface_map.py`, mirrored by
`SkinSurfaces.guess()`. **Change both or neither**: a model with a surface table
would start rendering differently from one without.
## Per-class art direction
Because each surface says what it is, each class takes its own numbers
(`LevelMaterials.CHARACTER_LOOK`). `body` is deliberately identical to what every
surface used to get, so the calibration this was all built on does not move. The
others are departures, each for a reason:
| Class | Outline | Band | Why |
|---|---|---|---|
| body | 5.0 mm | 0.16 | unchanged — the baseline |
| cloth | 5.8 mm | 0.13 | a garment's silhouette is most of what separates a character from the background at range; folds need a defined terminator to read as fabric |
| hair | 3.4 mm | 0.20 | **the one that matters.** A hair mesh is dozens of near-parallel strands millimetres apart; at the body's 5 mm each strand's hull swallows its neighbour and the head reads as one solid dark cap |
| accessory | 6.8 mm | 0.10 | small, rigid, usually the most saturated thing on the character — meant to pop |
This required moving the outline from `material_overlay` on the INSTANCE to
`next_pass` on each surface's material. Miku's body, face and hair are three
surfaces of one mesh, so an instance-wide overlay can only ever give all three
the same weight.
Taila's eyes still render as black cards rather than amber irises. Her eye
surfaces are untextured, and the glTF import hands every untextured surface a
default near-white albedo, so colour cannot tell an iris card from a lash card
on her — the name is all there is, and `eyes*` currently means "ink". Unfixed.
## Materials on import: the unlit problem
Anime glTFs are very often exported "unlit": `KHR_materials_unlit`, a **black**
`baseColorFactor`, and the real texture wired to `emissiveTexture`. Renderers
honouring the unlit extension use base colour and ignore emission — so Blender
reads black, never references the images, and imports with `bpy.data.images`
**empty**. The character comes out a silhouette, and there is no node graph left
to patch afterwards.
`tools/gltf_fix.py` rewrites the container **before** import: emissive becomes
base colour, the unlit flag is dropped. It must run first — this is the first
thing `retarget.py::main` does, before `import_any`.
`fix_unlit_materials(meshes)` then repairs anything left inside Blender.
## What the toon pass does
`apply_toon_recursive` gives everything the game's banded ramp. `apply_character_look`
then softens the banding on characters, because re-banding an already-shaded
anime texture reads as gloss — the texture already contains its own shading and
the second pass fights it.
## Convention alignment
The Hoyoverse-class look is, broadly: a ramp texture indexed by NdotL for the
body, a separate ramp and often a dedicated shader for the face, an inverted-hull
outline whose width is vertex-colour-modulated, and specific handling for eyes
and hair highlights. This project does the simplified version — one banded ramp
plus a screen-space-ish ink treatment, and the model's own outline shell hidden
in favour of the game's. The face is NOT specially shaded here; if a character
comes out with harsh shadow shapes across the nose, that is the missing piece.
## Outline thickness
Lives with the toon material in `scenes/maps/level_materials.gd`
(`CHARACTER_INK` and the outline settings). This is the branch it was last
touched on — `feat/outline-thickness-and-tp-weapon-hold`.
@@ -0,0 +1,168 @@
# Verification — and the trap that invalidated all of it
## READ THIS FIRST
**Godot restores every bone's local pose after the `SkeletonModifier3D` pass.**
So calling `force_update_all_bone_transforms()` and reading
`get_bone_global_pose()` from a `SceneTree` script, from `_process`, or anywhere
outside that pass recomputes the globals from the **animation alone**. The
shooter pose layer and the cloth solver are simply not in what you measure.
`debug/cloth_clip_check.gd` did exactly this. It reported the same ~95 mm of
leg-inside-skirt with collision fully enabled **and with the collision call
commented out**. Every number ever taken from that tool before 2026-07-26 is
void, and several rounds of "tuning did nothing" in the history were reading a
pose the solver never touched.
**To measure a pose layer, add your own `SkeletonModifier3D` as a child of the
`Skeleton3D` AFTER the one you care about, and snapshot inside its
`_process_modification()`.** The `PoseProbe` class in `cloth_clip_check.gd` and
`travel_dir_check.gd` is the pattern.
Two related traps:
- **Headless runs uncapped**, so the engine delta is sub-millisecond and anything
integrated barely moves. Set `SpringBones.fixed_delta = 1.0/60.0`.
- **A single frame of a locomotion clip measures the clip.** A run cycle twists
the torso against the hips by tens of degrees twice per stride, swamping
anything a pose layer does. Average over a stride.
## The tools
| Tool | Measures | Good |
|---|---|---|
| `spawn_smoke_test.gd` | spawn, skins, anim tree, camera, state cycling | 29 OK, 0 failures |
| `cloth_clip_check.gd` | leg-inside-cloth per movement state, per vertex | idle < 25 mm |
| `cloth_settle_check.gd` | deg/frame at a dead idle, contacts/frame | skirt < 0.1, hair < 0.01 |
| `cloth_perf_check.gd` | ms per character per frame | ~2.6 ms |
| `cloth_allow_check.gd` | how much of each limb the rest-clearance cap makes the solver blind to | 1735 mm on Taila |
| `cloth_stretch_check.gd` | mesh tearing between panels | no 3× edges |
| `travel_dir_check.gd` | stride direction vs. travel direction | < 10° except a capped sidestep |
| `limb_deform_check.gd` | joint collapse | knee ~0.99 |
| `verify_character.py` | meshes, bones, weights of a SOURCE model | several meshes, cloth bones present |
| `surface_class_check.gd` | every surface resolves from the sidecar, not the fallback | 0 fallbacks on all six skins |
| `character_picker_check.gd` | the escape-menu roster: skeleton, clips, surfaces, and that the pose MOVES | 0 failures |
| `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures |
| `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures |
| `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures |
| `hold_pose_check.gd` | the lab shows only the selected pose's knobs; every wrist axis turns its hand | 0 failures |
| `wrist_gun_check.gd` | the wrist turns the hand and NOT the gun welded to it | hand ~28°, gun < 1° |
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
| `roster_capture.gd` | one photo of every character, from the picker | — |
| `ui_capture.gd` | one photo of every menu screen | — |
| `rest_pose_check.gd` | each rig's bind-pose limb directions vs. the library's | see below |
## What `rest_pose_check` actually established
It was written to test a suspicion — that the rest-relative retarget silently
assumes both rigs rest alike — and it disproved it. Miku's arms rest **41°** off
the animation library's and Taila's **32°**, and both animate correctly. The
delta retarget handles a rest-pose difference, which is what it is for. Do not
go looking there again.
It also demonstrates the measurement trap in miniature. Written as "the direction
from a bone to its FIRST CHILD", it reported kiyoko's and aria's legs 71° off —
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°. The same rule as everywhere else in this pipeline: resolve
roles, never take whatever the rig happens to hand you.
## Assert the consequence, not the plumbing
Three of these exist because the obvious check passes on a broken system.
- `character_picker_check` asserts the skeleton's pose CHANGES over a dozen
frames. Asking the model which clip it is playing does not work: that is a
variable the class sets on itself, and it reads `"Idle"` just as happily when
the animation tree is not ticking at all.
- `rig_anchor_check` asserts the weapon MOVES by the offset asked for. An anchor
system is easy to build so that the sliders move, the file saves and the JSON
round-trips while the gun does not budge — the value read into a variable
nobody consumed. It measures in the attachment's frame, not the world's:
the attachment tracks a bone on an animating skeleton, so a world-space delta
is mostly the idle animation.
- `anchor_shift_check` and `anchor_drag_check` both measure in the GUN's frame
rather than the world's, and have to. The hold BREATHES — a
`sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
same world position twice, and comparing absolute positions reported a 3.5 mm
error that was the character inhaling. Taking each anchor relative to the one
it hangs off and rotating into the current gun basis cancels the breathing,
the ADS blend and the recoil kick exactly, because all three move the basis
and the anchor together.
- `hold_pose_check` measures the wrists through a `PoseProbe`, and had to learn
it the same way everything else did: reading `get_bone_pose_rotation` from the
SceneTree reported every wrist axis as turning the hand by **0.0 degrees**
the identical answer it would give if the wrists had never been implemented.
See READ THIS FIRST. That trap is still the most expensive one in this repo.
- `surface_class_check` FAILS on a surface that falls through to the heuristic
instead of resolving from the table. A model whose names stopped matching still
renders — the fallback catches it — and quietly loses its per-class art
direction. Nothing else would report that.
And four of the last five real defects came from LOOKING, not from asserting:
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. Every one
passed every assertion. Run `roster_capture` and `ui_capture` and open the PNGs.
Run them:
```bash
godot --headless --path . -s res://debug/<tool>.gd
godot --headless --path . -s res://debug/<tool>.gd -- res://assets/characters/skins/<name>.glb
```
Scripts run with `-s` MUST extend `SceneTree`. A `Node` script never quits and
hangs forever.
## Measure the right quantity
`cloth_clip_check.gd` used to report "how much CLOSER the leg got than the artist
modelled it". A hem 200 mm clear of a shin legitimately comes 180 mm closer when
the leg kicks out in a slide, and counting that as a failure buried the real
clipping under motion the character is supposed to have. It now reports how far
INSIDE a capsule a cloth vertex is, over and above however far inside it was
modelled — only cloth actually within the capsule can be showing a leg through.
It also applies the collider's `from` offset, so it tests the same band of thigh
the solver is defending. Measuring the full bone tests the hip cap the solver
deliberately excludes and reports it as clipping no tuning can fix.
## What the suite still does not check
It verifies that a character is WELL-FORMED, not that it is CORRECT. Those are
different properties, and only the first was ever asserted — which is how four
characters shipped "All checks passed" while lying on their backs, seven times
too large, facing backwards, or unable to hold a gun. See `failure-modes.md`.
`posture` and `bone roles reachable at runtime` are now hard checks. Still
missing, and worth adding when a source next exposes them: facing measured on the
OUTPUT, and per-vertex validation that a generated cloth chain actually tracks
the geometry it was given.
## Diagnosing "the solver isn't working"
In order:
1. **Is the measurement inside the modifier pass?** (Above. Do this first.)
2. **Does the solver SEE the contact?** `debug_hit_report()` — bone → deepest
overlap it found. If ~0 while the mesh is deep inside a leg, the collision
hull does not cover the geometry that is clipping.
3. **Does it CONVERGE?** `debug_residual_report()` — overlap left after the
relaxation. Seen 93 mm, left 95 mm is a standing fight, not slow convergence;
quadrupling the iterations will buy nothing. Find what is pulling back.
4. **Only then, tune.**
That order was learned the hard way: the drape, the bend limits, the backstop,
the iteration count and the hull sampling were each suspected and tested, and
the answer was in step 1.
## Also run
```bash
godot --headless --path . -s res://movement/tests/run_fsm_tests.gd # 11 tests
godot --headless --path . --check-only --script res://<file>.gd # syntax
```
Autoload identifiers report false "not found" errors under `--check-only`
ignore those.
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Anime Girl Rigged Anime model",
"uid": "fbccf5c5a7b244e7ab04fa44da19c621",
"author": "dequeijospizza",
"author_url": "https://sketchfab.com/dequeijospizza",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/anime-girl-rigged-anime-model-fbccf5c5a7b244e7ab04fa44da19c621"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Kiyoko School Girl",
"uid": "072667d7b2b3468e9baff483b27c3a09",
"author": "Kasujin",
"author_url": "https://sketchfab.com/Kasujin",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/kiyoko-school-girl-072667d7b2b3468e9baff483b27c3a09"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
@@ -0,0 +1,8 @@
{
"name": "Universal Animation Library \u2014 Mannequin",
"author": "Quaternius",
"license": "CC0 1.0 Universal (Public Domain Dedication)",
"url": "https://quaternius.com/",
"source": "assets/characters/animations/_library.glb",
"note": "The reference mannequin shipped inside the animation library this project already uses. No attribution required under CC0; recorded anyway, and because the library's own LICENSE asks that Quaternius be credited."
}
+207
View File
@@ -0,0 +1,207 @@
{
"roles": {
"hips": "DEF-hips",
"head": "DEF-head",
"neck": "DEF-neck",
"spine": [
"DEF-spine.001",
"DEF-spine.002",
"DEF-spine.003",
"DEF-neck",
"DEF-head"
],
"shoulder.L": "DEF-shoulder.L",
"shoulder.R": "DEF-shoulder.R",
"upper_arm.R": "DEF-upper_arm.R",
"upper_arm.L": "DEF-upper_arm.L",
"forearm.L": "DEF-forearm.L",
"forearm.R": "DEF-forearm.R",
"thigh.L": "DEF-thigh.L",
"thigh.R": "DEF-thigh.R",
"foot.R": "DEF-foot.R",
"foot.L": "DEF-foot.L",
"shin.R": "DEF-shin.R",
"shin.L": "DEF-shin.L",
"hand.R": "DEF-hand.R",
"hand.L": "DEF-hand.L",
"toe.R": "DEF-toe.R",
"toe.L": "DEF-toe.L"
},
"fingers": {
"thumb.L": [
"DEF-thumb.01.L",
"DEF-thumb.02.L",
"DEF-thumb.03.L"
],
"index.L": [
"DEF-f_index.01.L",
"DEF-f_index.02.L",
"DEF-f_index.03.L"
],
"middle.L": [
"DEF-f_middle.01.L",
"DEF-f_middle.02.L",
"DEF-f_middle.03.L"
],
"ring.L": [
"DEF-f_ring.01.L",
"DEF-f_ring.02.L",
"DEF-f_ring.03.L"
],
"pinky.L": [
"DEF-f_pinky.01.L",
"DEF-f_pinky.02.L",
"DEF-f_pinky.03.L"
],
"thumb.R": [
"DEF-thumb.01.R",
"DEF-thumb.02.R",
"DEF-thumb.03.R"
],
"index.R": [
"DEF-f_index.01.R",
"DEF-f_index.02.R",
"DEF-f_index.03.R"
],
"middle.R": [
"DEF-f_middle.01.R",
"DEF-f_middle.02.R",
"DEF-f_middle.03.R"
],
"ring.R": [
"DEF-f_ring.01.R",
"DEF-f_ring.02.R",
"DEF-f_ring.03.R"
],
"pinky.R": [
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.R"
]
},
"chains": [],
"twist": [],
"colliders": [
{
"bone": "DEF-hips",
"child": "DEF-spine.001",
"from": 0.0,
"radius_head": 0.1381,
"radius_tail": 0.1381,
"radius": 0.1381,
"lid": true
},
{
"bone": "DEF-thigh.L",
"child": "DEF-shin.L",
"from": 0.1,
"radius_head": 0.1037,
"radius_tail": 0.0733,
"radius": 0.0733
},
{
"bone": "DEF-thigh.R",
"child": "DEF-shin.R",
"from": 0.1,
"radius_head": 0.1037,
"radius_tail": 0.0733,
"radius": 0.0733
},
{
"bone": "DEF-shin.L",
"child": "DEF-foot.L",
"from": 0.1,
"radius_head": 0.0927,
"radius_tail": 0.0516,
"radius": 0.0516
},
{
"bone": "DEF-shin.R",
"child": "DEF-foot.R",
"from": 0.1,
"radius_head": 0.0927,
"radius_tail": 0.0516,
"radius": 0.0516
}
],
"weights_authored": true,
"driven_bones": [
"DEF-f_index.01.L",
"DEF-f_index.01.R",
"DEF-f_index.02.L",
"DEF-f_index.02.R",
"DEF-f_index.03.L",
"DEF-f_index.03.R",
"DEF-f_middle.01.L",
"DEF-f_middle.01.R",
"DEF-f_middle.02.L",
"DEF-f_middle.02.R",
"DEF-f_middle.03.L",
"DEF-f_middle.03.R",
"DEF-f_pinky.01.L",
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.L",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.L",
"DEF-f_pinky.03.R",
"DEF-f_ring.01.L",
"DEF-f_ring.01.R",
"DEF-f_ring.02.L",
"DEF-f_ring.02.R",
"DEF-f_ring.03.L",
"DEF-f_ring.03.R",
"DEF-foot.L",
"DEF-foot.R",
"DEF-forearm.L",
"DEF-forearm.R",
"DEF-hand.L",
"DEF-hand.R",
"DEF-head",
"DEF-hips",
"DEF-neck",
"DEF-shin.L",
"DEF-shin.R",
"DEF-shoulder.L",
"DEF-shoulder.R",
"DEF-spine.001",
"DEF-spine.002",
"DEF-spine.003",
"DEF-thigh.L",
"DEF-thigh.R",
"DEF-thumb.01.L",
"DEF-thumb.01.R",
"DEF-thumb.02.L",
"DEF-thumb.02.R",
"DEF-thumb.03.L",
"DEF-thumb.03.R",
"DEF-toe.L",
"DEF-toe.R",
"DEF-upper_arm.L",
"DEF-upper_arm.R",
"root"
],
"surfaces": [
{
"mesh": "Mannequin",
"surface": 0,
"material": "M_Main",
"class": "body",
"detail": "skin",
"why": "no name or weight evidence \u2014 treated as body",
"verts": 3391,
"textured": false,
"chain_share": {}
},
{
"mesh": "Mannequin",
"surface": 1,
"material": "M_Joints",
"class": "body",
"detail": "skin",
"why": "no name or weight evidence \u2014 treated as body",
"verts": 5157,
"textured": false,
"chain_share": {}
}
]
}
+225 -106
View File
@@ -1,110 +1,229 @@
{ {
"roles": { "roles": {
"hips": "DEF-hips", "hips": "DEF-hips",
"head": "DEF-head", "head": "DEF-head",
"neck": "DEF-neck", "neck": "DEF-neck",
"spine": [ "spine": [
"DEF-spine.001", "DEF-spine.001",
"DEF-spine.002", "DEF-spine.002",
"DEF-spine.003", "DEF-spine.003",
"DEF-neck", "DEF-neck",
"DEF-head" "DEF-head"
],
"forearm.R": "DEF-forearm.R",
"toe.R": "DEF-toe.R",
"upper_arm.R": "DEF-upper_arm.R",
"shin.L": "DEF-shin.L",
"shin.R": "DEF-shin.R",
"toe.L": "DEF-toe.L",
"hand.R": "DEF-hand.R",
"forearm.L": "DEF-forearm.L",
"foot.R": "DEF-foot.R",
"upper_arm.L": "DEF-upper_arm.L",
"thigh.R": "DEF-thigh.R",
"hand.L": "DEF-hand.L",
"shoulder.R": "DEF-shoulder.R",
"thigh.L": "DEF-thigh.L",
"foot.L": "DEF-foot.L",
"shoulder.L": "DEF-shoulder.L"
},
"chains": [],
"twist": [],
"colliders": [
{
"bone": "DEF-thigh.L",
"child": "DEF-shin.L",
"radius": 0.1245
},
{
"bone": "DEF-thigh.R",
"child": "DEF-shin.R",
"radius": 0.1079
},
{
"bone": "DEF-shin.L",
"child": "DEF-foot.L",
"radius": 0.0762
},
{
"bone": "DEF-shin.R",
"child": "DEF-foot.R",
"radius": 0.0762
}
], ],
"weights_authored": false, "shoulder.L": "DEF-shoulder.L",
"driven_bones": [ "shoulder.R": "DEF-shoulder.R",
"DEF-f_index.01.L", "upper_arm.L": "DEF-upper_arm.L",
"DEF-f_index.01.R", "upper_arm.R": "DEF-upper_arm.R",
"DEF-f_index.02.L", "forearm.L": "DEF-forearm.L",
"DEF-f_index.02.R", "forearm.R": "DEF-forearm.R",
"DEF-f_index.03.L", "thigh.L": "DEF-thigh.L",
"DEF-f_index.03.R", "thigh.R": "DEF-thigh.R",
"DEF-f_middle.01.L", "foot.R": "DEF-foot.R",
"DEF-f_middle.01.R", "foot.L": "DEF-foot.L",
"DEF-f_middle.02.L", "shin.R": "DEF-shin.R",
"DEF-f_middle.02.R", "shin.L": "DEF-shin.L",
"DEF-f_middle.03.L", "hand.R": "DEF-hand.R",
"DEF-f_middle.03.R", "hand.L": "DEF-hand.L",
"DEF-f_pinky.01.L", "toe.R": "DEF-toe.R",
"DEF-f_pinky.01.R", "toe.L": "DEF-toe.L"
"DEF-f_pinky.02.L", },
"DEF-f_pinky.02.R", "fingers": {
"DEF-f_pinky.03.L", "thumb.L": [
"DEF-f_pinky.03.R", "DEF-thumb.01.L",
"DEF-f_ring.01.L", "DEF-thumb.02.L",
"DEF-f_ring.01.R", "DEF-thumb.03.L"
"DEF-f_ring.02.L", ],
"DEF-f_ring.02.R", "index.L": [
"DEF-f_ring.03.L", "DEF-f_index.01.L",
"DEF-f_ring.03.R", "DEF-f_index.02.L",
"DEF-foot.L", "DEF-f_index.03.L"
"DEF-foot.R", ],
"DEF-forearm.L", "middle.L": [
"DEF-forearm.R", "DEF-f_middle.01.L",
"DEF-hand.L", "DEF-f_middle.02.L",
"DEF-hand.R", "DEF-f_middle.03.L"
"DEF-head", ],
"DEF-hips", "ring.L": [
"DEF-neck", "DEF-f_ring.01.L",
"DEF-shin.L", "DEF-f_ring.02.L",
"DEF-shin.R", "DEF-f_ring.03.L"
"DEF-shoulder.L", ],
"DEF-shoulder.R", "pinky.L": [
"DEF-spine.001", "DEF-f_pinky.01.L",
"DEF-spine.002", "DEF-f_pinky.02.L",
"DEF-spine.003", "DEF-f_pinky.03.L"
"DEF-thigh.L", ],
"DEF-thigh.R", "thumb.R": [
"DEF-thumb.01.L", "DEF-thumb.01.R",
"DEF-thumb.01.R", "DEF-thumb.02.R",
"DEF-thumb.02.L", "DEF-thumb.03.R"
"DEF-thumb.02.R", ],
"DEF-thumb.03.L", "index.R": [
"DEF-thumb.03.R", "DEF-f_index.01.R",
"DEF-toe.L", "DEF-f_index.02.R",
"DEF-toe.R", "DEF-f_index.03.R"
"DEF-upper_arm.L", ],
"DEF-upper_arm.R", "middle.R": [
"root" "DEF-f_middle.01.R",
"DEF-f_middle.02.R",
"DEF-f_middle.03.R"
],
"ring.R": [
"DEF-f_ring.01.R",
"DEF-f_ring.02.R",
"DEF-f_ring.03.R"
],
"pinky.R": [
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.R"
] ]
},
"chains": [],
"twist": [],
"colliders": [
{
"bone": "DEF-hips",
"child": "DEF-spine.001",
"from": 0.0,
"radius_head": 0.1913,
"radius_tail": 0.1913,
"radius": 0.1913,
"lid": true
},
{
"bone": "DEF-thigh.L",
"child": "DEF-shin.L",
"from": 0.1,
"radius_head": 0.1435,
"radius_tail": 0.1304,
"radius": 0.1304
},
{
"bone": "DEF-thigh.R",
"child": "DEF-shin.R",
"from": 0.1,
"radius_head": 0.1084,
"radius_tail": 0.1084,
"radius": 0.1084
},
{
"bone": "DEF-shin.L",
"child": "DEF-foot.L",
"from": 0.1,
"radius_head": 0.0862,
"radius_tail": 0.0728,
"radius": 0.0728
},
{
"bone": "DEF-shin.R",
"child": "DEF-foot.R",
"from": 0.1,
"radius_head": 0.0862,
"radius_tail": 0.0728,
"radius": 0.0728
}
],
"weights_authored": false,
"driven_bones": [
"DEF-f_index.01.L",
"DEF-f_index.01.R",
"DEF-f_index.02.L",
"DEF-f_index.02.R",
"DEF-f_index.03.L",
"DEF-f_index.03.R",
"DEF-f_middle.01.L",
"DEF-f_middle.01.R",
"DEF-f_middle.02.L",
"DEF-f_middle.02.R",
"DEF-f_middle.03.L",
"DEF-f_middle.03.R",
"DEF-f_pinky.01.L",
"DEF-f_pinky.01.R",
"DEF-f_pinky.02.L",
"DEF-f_pinky.02.R",
"DEF-f_pinky.03.L",
"DEF-f_pinky.03.R",
"DEF-f_ring.01.L",
"DEF-f_ring.01.R",
"DEF-f_ring.02.L",
"DEF-f_ring.02.R",
"DEF-f_ring.03.L",
"DEF-f_ring.03.R",
"DEF-foot.L",
"DEF-foot.R",
"DEF-forearm.L",
"DEF-forearm.R",
"DEF-hand.L",
"DEF-hand.R",
"DEF-head",
"DEF-hips",
"DEF-neck",
"DEF-shin.L",
"DEF-shin.R",
"DEF-shoulder.L",
"DEF-shoulder.R",
"DEF-spine.001",
"DEF-spine.002",
"DEF-spine.003",
"DEF-thigh.L",
"DEF-thigh.R",
"DEF-thumb.01.L",
"DEF-thumb.01.R",
"DEF-thumb.02.L",
"DEF-thumb.02.R",
"DEF-thumb.03.L",
"DEF-thumb.03.R",
"DEF-toe.L",
"DEF-toe.R",
"DEF-upper_arm.L",
"DEF-upper_arm.R",
"root"
],
"surfaces": [
{
"mesh": "Object_2",
"surface": 0,
"material": "body",
"class": "body",
"detail": "skin",
"why": "material name says 'skin'",
"verts": 1496,
"textured": true,
"chain_share": {}
},
{
"mesh": "Object_2",
"surface": 1,
"material": "body_parts",
"class": "body",
"detail": "skin",
"why": "material name says 'skin'",
"verts": 330,
"textured": true,
"chain_share": {}
},
{
"mesh": "Object_2",
"surface": 2,
"material": "hair",
"class": "hair",
"detail": "hair",
"why": "material name says 'hair'",
"verts": 500,
"textured": true,
"chain_share": {}
},
{
"mesh": "Object_2",
"surface": 3,
"material": "face",
"class": "body",
"detail": "face",
"why": "material name says 'face'",
"verts": 461,
"textured": true,
"chain_share": {}
}
]
} }
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "DANDADAN - Momo Ayase (3D Model) + DL",
"uid": "6ac6c3476a1f4b8da1c7de7e98a7c83c",
"author": "HiGuys920",
"author_url": "https://sketchfab.com/higuys920",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/dandadan-momo-ayase-3d-model-dl-6ac6c3476a1f4b8da1c7de7e98a7c83c"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+28
View File
@@ -13,6 +13,34 @@
"description": "", "description": "",
"model": "res://assets/characters/skins/taila.glb", "model": "res://assets/characters/skins/taila.glb",
"unlocked": true "unlocked": true
},
{
"id": "mannequin",
"name": "Mannequin",
"description": "Quaternius reference mannequin (CC0)",
"model": "res://assets/characters/skins/mannequin.glb",
"unlocked": true
},
{
"id": "kiyoko",
"name": "Kiyoko",
"description": "Kiyoko School Girl \u2014 VRoid (CC-BY, Kasujin)",
"model": "res://assets/characters/skins/kiyoko.glb",
"unlocked": true
},
{
"id": "aria",
"name": "Aria",
"description": "Anime Girl Rigged \u2014 VRoid (CC-BY, dequeijospizza)",
"model": "res://assets/characters/skins/aria.glb",
"unlocked": true
},
{
"id": "momo",
"name": "Momo",
"description": "Momo Ayase, DANDADAN (CC-BY, HiGuys920)",
"model": "res://assets/characters/skins/momo.glb",
"unlocked": true
} }
] ]
} }
Binary file not shown.
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"skins": {
"aria": {
"ak47": {
"curl_thumb": 0.0,
"curl_trigger": 2.0,
"curl_wrap": 0.655,
"fore_shift": [
-0.103000000119209,
0.034000001847744,
0.017000000923872
],
"grip_shift": [
0.0199999995529652,
-0.145999997854233,
-0.00899999961256981
],
"gun_fore": 0.157,
"gun_stock": 0.06,
"pitch_hip": 0.0610000000000001,
"pocket_hip": [
0.00700000021606684,
-0.146999999880791,
0.0329999998211861
],
"pole_l_hip": [
0.493999987840652,
-0.433999985456467,
-0.18299999833107
],
"pole_r_hip": [
-0.114000000059605,
-0.25900000333786,
-0.526000022888184
],
"weapon_scale": 0.53,
"wrist_l_hip": [
-1.307000041008,
-0.0480000004172325,
0.0410000011324883
],
"wrist_r_hip": [
-0.421999990940094,
1.60000002384186,
1.28299999237061
]
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
extends Object
class_name RigAnchors
## Named attachment points on a character's skeleton, adjustable per character.
##
## An anchor is a bone ROLE plus an offset: "the grip sits here, relative to the
## right hand". The role is resolved from the rig sidecar, so nothing here ever
## spells a bone name — that rule is what let four characters hold a gun at all.
## The offset is the part a human has to decide.
##
## Why an offset is needed even though the code derives a mount:
##
## The third-person weapon is seated at the hand bone's ORIGIN with no
## hand-relative rotation, and the pose layer then aims it by rotating the wrist
## until the gun's forward axis lies on the aim line. That is deliberate and it
## is right — a constant rotation there is expressed in the BONE's axes, no two
## rigs agree on those, and a fixed `(0, 90, -90)` is exactly why the hand mount
## points used to be wrong on every character.
##
## But a hand bone's origin is the WRIST, not the palm. How far down the palm a
## grip should sit, and how the gun should roll in the fingers, is a judgement
## about that character's hand — how big it is, how the fingers were modelled,
## how the artist posed the thumb. It cannot be derived, it differs per
## character, and it is small. So it is an offset, it defaults to zero, and zero
## means "exactly what the code derives" — which is what every character gets
## until someone opens the rig lab and decides otherwise.
const PATH := "res://assets/characters/rig_anchors.json"
## Written to the project when running from source; falls back to user:// for an
## exported build, where res:// is read-only.
const USER_PATH := "user://rig_anchors.json"
## The one subject key. Anchors are per CHARACTER, not per weapon — where a grip
## sits in a palm is a fact about the hand, and re-tuning it for every gun would
## be re-answering the same question. TuningStore is keyed by subject, so this
## names the only one there is.
const SUBJECT := "anchors"
## key -> [label, minimum, maximum, is_vector, default]
##
## The lab builds its whole anchor UI from this, so adding an anchor here is all
## it takes to expose one. Ranges are what a plausible answer lives inside, not
## what the value can technically be: a grip more than 12 cm from the wrist is
## not a grip, it is a mistake, and a slider that can express it only makes the
## useful range harder to hit.
##
## Every default is ZERO, and that is load-bearing — see the note above. A knob
## whose slider sits at 0 next to a code default of something else means the
## first touch of that slider silently changes behaviour.
const KNOBS := [
["grip_offset", "Grip position in the palm (m)", -0.12, 0.12, true, Vector3.ZERO],
["grip_rotation", "Grip roll/pitch/yaw (rad)", -1.6, 1.6, true, Vector3.ZERO],
]
## Which bone role each anchor hangs off. Roles, not names — resolved through
## the sidecar the pipeline writes.
const ANCHOR_BONE := {
"grip_offset": "hand.R",
"grip_rotation": "hand.R",
}
static func default_for(key: String):
return TuningStore.default_for(KNOBS, key)
static func load_all() -> Dictionary:
return TuningStore.read(PATH, USER_PATH)
## The resolved anchor table for one character.
static func resolve(all: Dictionary, skin_id: String) -> Dictionary:
return TuningStore.resolve(all, skin_id, SUBJECT)
static func save(all: Dictionary, skin_id: String, table: Dictionary) -> String:
return TuningStore.write(all, skin_id, SUBJECT, table, PATH, USER_PATH)
## The grip anchor as a transform to seat a weapon with, in hand-bone space.
##
## Identity when nothing is tuned, which is what the code did before anchors
## existed — so a character nobody has opened the lab for is bit-for-bit
## unchanged.
static func grip_transform(table: Dictionary) -> Transform3D:
var pos: Vector3 = table.get("grip_offset", Vector3.ZERO)
var rot: Vector3 = table.get("grip_rotation", Vector3.ZERO)
if pos == Vector3.ZERO and rot == Vector3.ZERO:
return Transform3D.IDENTITY
return Transform3D(Basis.from_euler(rot), pos)
+1
View File
@@ -0,0 +1 @@
uid://ck8e6odry037
+116
View File
@@ -0,0 +1,116 @@
extends RefCounted
class_name SkinSurfaces
## What every surface of a character IS — body, cloth, hair or accessory.
##
## Written at build time by tools/surface_map.py into `<skin>.rig.json`, read
## here. Nothing at runtime re-derives it, which is the point: the question has
## one right answer per model and it is knowable in Blender, where the mesh, the
## weights and the skeleton are all in hand. Asking it again from a material at
## load time is how the mannequin's flat yellow body came to be rendered as a
## black silhouette — "untextured" is not the same question as "is ink".
##
## The heuristic that mistake came from is still here, as `guess()`, and still
## earns its place: it is the fallback for a model imported before the surface
## table existed, or one whose material genuinely says nothing. But it is now
## the last resort rather than the only source.
const BODY := "body"
const CLOTH := "cloth"
const HAIR := "hair"
const ACCESSORY := "accessory"
const LINEWORK := "linework"
## Same threshold as tools/surface_map.INK_LEVEL. The two must agree, or a model
## with a surface table would render differently from one without.
const INK_LEVEL := 0.18
var _by_slot: Dictionary = {} # "mesh|index" -> record
var _by_material: Dictionary = {} # material name -> record
## Materials used by two surfaces the classifier disagreed about. Taila reuses
## `ClothA` on three meshes and `FullBlack` on three more; those agree, so they
## stay usable. One that did not would silently give whichever surface was read
## first, so it is dropped from the material index instead and falls back to the
## slot key.
var _material_conflict: Dictionary = {}
static func from_rig_info(rig_info: Dictionary) -> SkinSurfaces:
var out := SkinSurfaces.new()
for record in rig_info.get("surfaces", []):
if not record is Dictionary:
continue
out._by_slot["%s|%d" % [record.get("mesh", ""),
int(record.get("surface", 0))]] = record
var mat: String = record.get("material", "")
if mat == "":
continue
var seen = out._by_material.get(mat)
if seen != null and seen.get("class", "") != record.get("class", ""):
out._material_conflict[mat] = true
else:
out._by_material[mat] = record
return out
func is_empty() -> bool:
return _by_slot.is_empty()
## The build-time record for one surface, or an empty Dictionary.
##
## The mesh node name and surface index are tried first because they name
## exactly one surface. The material name is the fallback because it is what
## survives best — every character in this game arrives with its meshes called
## `Object_7` through `Object_32`, and a renamed node would take the slot key
## with it while `ClothB` stays `ClothB`.
func lookup(mesh_name: String, surface_index: int, material_name: String) -> Dictionary:
var by_slot = _by_slot.get("%s|%d" % [mesh_name, surface_index])
if by_slot != null:
return by_slot
if material_name != "" and not _material_conflict.has(material_name):
var by_mat = _by_material.get(material_name)
if by_mat != null:
return by_mat
return {}
## (class, detail) for a surface with no build-time record.
##
## This is the pre-surface-table heuristic, kept verbatim for the one job it is
## still right for. It answers a narrow question — is this untextured surface
## part of the model's own DRAWING? — and it answers it from the three things
## that actually distinguish line-work: being black, being drawn inside-out, or
## saying outright that it is an eye card. Anything else is treated as body,
## which is the safe answer because body is ordinary character shading.
static func guess(mat: BaseMaterial3D) -> Array:
if mat == null:
return [BODY, "skin"]
var name := mat.resource_name.to_lower()
if mat.albedo_texture == null:
if name.begins_with("eyes"):
return [BODY, "eyes_highlight" if name.contains("hl") else "eyes_ink"]
if mat.cull_mode == BaseMaterial3D.CULL_FRONT:
return [LINEWORK, "outline_hull"]
var c: Color = mat.albedo_color
if maxf(maxf(c.r, c.g), c.b) < INK_LEVEL:
return [LINEWORK, "outline_hull"]
return [BODY, "skin"]
## (class, detail) for a surface, from the table where it has an entry and from
## the heuristic where it does not.
##
## The cull-mode test is re-run even when the table HAS an entry, because that
## is the one piece of evidence the build side cannot see: glTF has no way to
## say "draw only the backfaces", so an inverted-hull outline arrives in Blender
## indistinguishable from an ordinary surface and only shows itself here.
func resolve(mesh_name: String, surface_index: int, mat: BaseMaterial3D) -> Array:
var material_name := "" if mat == null else mat.resource_name
if mat != null and mat.albedo_texture == null \
and mat.cull_mode == BaseMaterial3D.CULL_FRONT:
return [LINEWORK, "outline_hull"]
var record := lookup(mesh_name, surface_index, material_name)
if record.is_empty():
return guess(mat)
return [record.get("class", BODY), record.get("detail", "")]
+1
View File
@@ -0,0 +1 @@
uid://diqctdcqixjqg
File diff suppressed because it is too large Load Diff
+1139 -160
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
extends Object
class_name TuningStore
## Per-character art direction on disk, layered so a number can be set once for
## everyone and then contradicted exactly where it matters.
##
## defaults every character, every subject
## skins.<skin>._all this character, every subject
## skins.<skin>.<key> this character, this subject
##
## "Subject" is whatever the caller is keying on — a weapon id for how a gun is
## held, an anchor set for where it is held. The store does not care.
##
## This is the shape WeaponHoldTuning arrived at, pulled out so it is not the
## only thing that can have it. Every knob in the rifle hold started as a
## constant tuned against one rig and was wrong on the next character imported;
## the ones that can be derived from the skeleton now are, and what is left is
## genuinely an artist's judgement — how high a stock rides, where in the palm a
## grip sits. Judgement wants a slider and a file, not another guess in code.
##
## An absent or empty file means "use the built-in defaults", so the game runs
## perfectly well with nothing tuned at all. This only ever ADDS information.
## Where a tuning file is read from and written to.
##
## The project copy is preferred on save so a tuning pass lands in version
## control beside the character it belongs to. `user://` is the fallback for an
## exported build, where res:// is read-only — and it WINS on load, so a pass
## made in a shipped build is not silently discarded.
static func read(res_path: String, user_path: String) -> Dictionary:
var base := _read_one(res_path)
var over := _read_one(user_path)
if over.is_empty():
return base
if base.is_empty():
return over
# Shallow is enough: the layers below are merged per key anyway.
for k in over:
base[k] = over[k]
return base
static func _read_one(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
## The resolved table for one character and one subject, most general first.
##
## Vectors survive the JSON round trip as three-element arrays and are rebuilt
## here rather than at every read site — a caller that forgot would get an Array
## where it expected a Vector3, which fails somewhere else entirely.
static func resolve(all: Dictionary, skin_id: String, subject: String) -> Dictionary:
var out := {}
var skins: Dictionary = all.get("skins", {})
var mine: Dictionary = skins.get(skin_id, {})
for layer in [all.get("defaults", {}), mine.get("_all", {}),
mine.get(subject, {})]:
if typeof(layer) != TYPE_DICTIONARY:
continue
for k in layer:
out[k] = layer[k]
return revive(out)
## Three-element arrays back into Vector3s, in place. Anything else is left
## alone, so a knob that is genuinely a list of three numbers would need its own
## handling — none is, and one that was would be a Vector3 anyway.
static func revive(table: Dictionary) -> Dictionary:
for k in table.keys():
var v = table[k]
if v is Array and v.size() == 3:
table[k] = Vector3(float(v[0]), float(v[1]), float(v[2]))
return table
static func flatten(table: Dictionary) -> Dictionary:
var flat := {}
for k in table:
var v = table[k]
flat[k] = [v.x, v.y, v.z] if v is Vector3 else v
return flat
## Store one character+subject's table and write the file. Returns where it went.
static func write(all: Dictionary, skin_id: String, subject: String,
table: Dictionary, res_path: String, user_path: String) -> String:
if not all.has("skins"):
all["skins"] = {}
if not all["skins"].has(skin_id):
all["skins"][skin_id] = {}
all["skins"][skin_id][subject] = flatten(table)
var text := JSON.stringify(all, " ")
for path in [res_path, user_path]:
var f := FileAccess.open(path, FileAccess.WRITE)
if f:
f.store_string(text)
f.close()
return path
return "<could not write>"
## The default for a knob, from a spec table shaped
## `[key, label, minimum, maximum, is_vector, default]`.
static func default_for(specs: Array, key: String):
for spec in specs:
if spec[0] == key:
return spec[5]
return 0.0
+1
View File
@@ -0,0 +1 @@
uid://cq378n7o0qnoc
+193
View File
@@ -0,0 +1,193 @@
extends Object
class_name WeaponHoldTuning
## Per-character, per-weapon overrides for how a gun is held.
##
## Every knob in the rifle hold used to be a constant tuned against one rig, and
## every one of them was wrong on the next character imported — the mount
## rotation, the wrist twist, the weapon size. The ones that CAN be derived from
## the skeleton now are. The rest are genuinely art direction: how high the stock
## rides, how far the elbow flares, how hard the fingers close. Those want an
## artist's eye and a slider, not another guess in code.
##
## This is where that judgement is stored. debug/rig_lab.gd writes it;
## SkinnedPlayerModel reads it when a weapon is equipped.
##
## Resolution is layered, most general first, so a single number can be set once
## for everything and then 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 the built-in defaults", so the game runs perfectly
## well with no tuning at all — this only ever adds information.
const PATH := "res://assets/characters/weapon_holds.json"
## Written to the project when running from source; falls back to user:// for an
## exported build, where res:// is read-only.
const USER_PATH := "user://weapon_holds.json"
# ── The pose axis ────────────────────────────────────────────────────────────
#
# Half of these knobs mean something different at low ready than they do down
# the sights, and half do not. Where a hand sits ON the weapon is a fact about
# the gun and the character's hands; how the weapon is carried is a fact about
# what they are doing with it.
#
# The runtime blends between exactly TWO holds, on `ads` — there is no third.
# "Running" and "Crouched" in the lab are locomotion states that still use the
# low-ready hold, because that is all `_apply_rifle_hold` can express. Offering
# four independent pose tunings would be inventing a capability the code does
# not have, and the fourth would silently do nothing.
#
# So: two poses, and a knob names the ones it exists for.
const POSE_HIP := "hip"
const POSE_ADS := "ads"
const POSE_NAMES := {POSE_HIP: "low ready", POSE_ADS: "aiming"}
## Which pose a given `ads` blend is being tuned as.
static func pose_for_ads(ads: float) -> String:
return POSE_ADS if ads > 0.5 else POSE_HIP
## key -> [label, minimum, maximum, is_vector, default]
##
## The lab builds its whole UI from this, so adding a knob here is all it takes
## to expose one. Ranges are what a plausible answer lives inside, not what the
## value can technically be.
##
## The DEFAULT must match what the code does when nothing is tuned, or the lab
## lies: a slider parked at 0 next to a code default of 1.0 means the first touch
## of that slider silently switches the behaviour off. Zero means "let the code
## decide" only where it is called out below.
##
## These are the pose-INDEPENDENT ones. They describe the weapon and the hands
## on it, which do not change when the character shoulders the gun.
const SHARED_KNOBS := [
["weapon_scale", "Weapon size (0 = fit to arm)", 0.0, 1.4, false, 0.0],
["gun_stock", "TRIGGER hand along the weapon, from the butt (0 = auto)",
0.0, 0.45, false, 0.0],
["gun_fore", "SUPPORT hand along the weapon, from the grip (0 = auto)",
0.0, 0.50, false, 0.0],
# The two hand anchors, off the barrel line.
#
# `gun_stock` and `gun_fore` above are DISTANCES ALONG the barrel, and for a
# long time that was the only freedom either anchor had: the trigger hand
# could slide up and down the gun's own axis and nowhere else, and so could
# the support hand. That is fine for where along a handguard to hold, and
# useless for a handguard that sits below the bore, an angled foregrip, or a
# pistol whose grip is nowhere near its barrel line.
#
# These are in the GUN's frame — x across, y up, z along the barrel — so they
# stay meaningful as the weapon pitches between low ready and ADS. Zero is
# exactly the old behaviour. Their z overlaps `gun_stock`/`gun_fore`, which
# is redundant but harmless, and keeping the along-axis distances separate is
# what lets the reach solver slide the support hand back down the handguard
# without also undoing a deliberate sideways nudge.
["grip_shift", "TRIGGER hand, off the barrel line", -0.15, 0.15, true,
Vector3.ZERO],
["fore_shift", "SUPPORT hand, off the barrel line", -0.15, 0.15, true,
Vector3.ZERO],
["curl_wrap", "Finger wrap", 0.0, 2.0, false, 1.0],
["curl_trigger", "Trigger finger", 0.0, 2.0, false, 1.0],
["curl_thumb", "Thumb", 0.0, 2.0, false, 1.0],
]
## stem -> [label, minimum, maximum, is_vector, {pose: default}]
##
## Stored and read as `<stem>_<pose>`, which is the convention `pocket_hip` and
## `pocket_ads` already used — generalised so every knob that ought to differ
## between the two holds can.
##
## A pose ABSENT from the defaults dictionary means the knob does not exist
## there, and the lab will not show it. `pitch` is the case that forces this:
## down the sights the muzzle follows the camera, so there is nothing to tune,
## and a "muzzle pitch, aiming" slider would be a control that does nothing.
const POSE_KNOBS := [
["pocket", "Stock pocket", -0.30, 0.30, true, {
POSE_HIP: Vector3(0.03, -0.07, 0.06),
POSE_ADS: Vector3(0.05, 0.01, 0.07)}],
["pitch", "Muzzle pitch", -0.6, 0.6, false, {POSE_HIP: 0.16}],
# Full wrist orientation, not just a roll.
#
# These were one scalar each, a twist about the barrel, because that is the
# only axis a hand wrapping a cylinder is free in ONCE the arc onto the
# barrel has been solved. That is true of the support hand and it was never
# true of the trigger hand, and even for the support hand it left no way to
# cock a wrist forward or break it inward — which is most of what separates a
# convincing rifle hold from a mannequin's.
#
# Pitch, yaw and roll, applied in the GUN's frame (about across, up, and the
# barrel) so the axes mean the same thing at any weapon pitch. Zero is
# exactly the old behaviour, since the roll term was zero by default too.
["wrist_r", "TRIGGER wrist — pitch / yaw / roll", -1.6, 1.6, true, {
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
["wrist_l", "SUPPORT wrist — pitch / yaw / roll", -1.6, 1.6, true, {
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
# Zero means "use the code's own default" for these two — see _tv in
# ShooterPoseModifier, which treats a zero-length vector as unset.
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, {
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
["pole_l", "Support elbow (0 = auto)", -1.5, 1.5, true, {
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
]
## The spec table for one pose: the shared knobs, plus that pose's own, with
## their keys already suffixed.
##
## This is what the lab builds its sliders from, so a knob that does not apply
## to the pose being adjusted is not merely disabled — it is not there.
static func knobs_for(pose: String) -> Array:
var out: Array = SHARED_KNOBS.duplicate()
for spec in POSE_KNOBS:
var defaults: Dictionary = spec[5]
if not defaults.has(pose):
continue
out.append(["%s_%s" % [spec[0], pose],
"%s, %s" % [spec[1], POSE_NAMES[pose]],
spec[2], spec[3], spec[4], defaults[pose]])
return out
## Every knob across every pose. For anything that has to reason about the whole
## table rather than about one screen of it — resetting, saving, and the checks.
static func all_knobs() -> Array:
var out: Array = SHARED_KNOBS.duplicate()
for spec in POSE_KNOBS:
var defaults: Dictionary = spec[5]
for pose in defaults:
out.append(["%s_%s" % [spec[0], pose],
"%s, %s" % [spec[1], POSE_NAMES[pose]],
spec[2], spec[3], spec[4], defaults[pose]])
return out
## The built-in value for a knob, for a lab that has nothing saved yet.
##
## Across ALL poses, not just the one on screen: a reset or a save has to know
## what `pocket_ads` defaults to even while low ready is being adjusted.
static func default_for(key: String):
return TuningStore.default_for(all_knobs(), key)
## The layering, the JSON round trip and the res://-then-user:// write all live
## in TuningStore now, because they are not specific to weapons — rig anchors
## want exactly the same behaviour, and having two copies of it would mean two
## places for "an exported build's tuning pass is silently discarded" to come
## back. The on-disk format is unchanged.
static func load_all() -> Dictionary:
return TuningStore.read(PATH, USER_PATH)
## The resolved knob table for one character holding one weapon.
static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary:
return TuningStore.resolve(all, skin_id, weapon_id)
## Store one character+weapon's knobs and write the file. Returns where it went.
static func save(all: Dictionary, skin_id: String, weapon_id: String,
knobs: Dictionary) -> String:
return TuningStore.write(all, skin_id, weapon_id, knobs, PATH, USER_PATH)
+1
View File
@@ -0,0 +1 @@
uid://cxbghp14y3i7v
+165
View File
@@ -0,0 +1,165 @@
extends SceneTree
## Does dragging an anchor marker in the rig lab move that anchor where the
## mouse went?
##
## The maths behind a viewport drag has four frames in it — screen, world,
## skeleton, gun — and every one is a chance to transpose an inverse or lose a
## handedness. All of those mistakes still MOVE the marker, so "the number
## changed" proves nothing. What is asserted here is that the number changed by
## the RIGHT AMOUNT, in the frame that knob is written in, derived independently
## from the camera.
##
## Deliberately NOT asserted: that the marker lands exactly under the mouse. It
## does not, and the reason is a real property of the hold rather than a bug in
## the drag — see THE FEEDBACK below. The screen-space check kept here is a
## direction-and-order-of-magnitude one, which is the band the transform
## mistakes above actually live in: a swapped axis or a lost handedness sends
## the marker the wrong way entirely.
##
## THE FEEDBACK. Every anchor hangs off the shoulder — `stock_pos = shoulder +
## pocket`, and the grip and fore anchors are measured out from there. The
## shoulder is driven by the arm, and the arm is chasing the anchor. So moving
## an anchor moves the shoulder, which moves the anchor again. Measured from a
## clean slate it settles at 0.77x-1.13x of the drag depending on which anchor,
## which is small enough to be invisible interactively — you stop dragging when
## it looks right.
##
## It is worth the paragraph because of how it first showed up. Without the
## `_reset` between cases below, each drag started on top of the last one still
## working its way through the arm, and the ratio read 1.5x-1.8x; 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 the compounding
## away.
##
## godot --path . -s res://debug/anchor_drag_check.gd
const LAB := "res://debug/rig_lab.tscn"
const DRAG := Vector2(60, 0)
## Metres. What the knob is checked to — this part is exact maths, so it can be.
const KNOB_TOLERANCE := 0.0005
## The screen-space check is direction and order of magnitude only. See above.
const MIN_TRAVEL := 0.5
const MAX_TRAVEL := 2.2
var _fails := 0
func _init() -> void:
await process_frame
var lab: Node = load(LAB).instantiate()
root.add_child(lab)
# The pose layer chases its targets exponentially at a rate times DELTA, and
# a headless run is uncapped, so each frame advances the blend by almost
# nothing and the hold takes hundreds of frames to stop moving on its own.
for _i in 300:
await process_frame
if lab._model == null or not lab._model.loaded or lab._model._pose_mod == null:
_expect(false, "the lab built a character with a pose layer")
_done()
return
_expect(true, "the lab built a character with a pose layer")
# Frame the hands, so a pixel is a small distance in the world — a drag
# measured at arm's length is mostly noise.
lab._pivot = lab._model.skeleton.global_transform * lab._model._pose_mod.dbg_grip
lab._dist = 0.6
lab._update_camera()
for _i in 20:
await process_frame
for case in [[0, "trigger hand", "grip_shift"], [1, "support hand", "fore_shift"],
[2, "buttstock", "pocket_hip"]]:
await _drag_case(lab, case[0], case[1], case[2])
_done()
func _drag_case(lab: Node, marker: int, label: String, key: String) -> void:
# From a clean slate each time, or the second case measures the first's
# shift still working its way through the arm.
lab._reset("hold")
for _i in 120:
await process_frame
var m: Node3D = lab._markers[marker]
if not m.visible:
_expect(false, "the %s marker is visible" % label)
return
var before: Vector2 = lab._cam.unproject_position(m.global_position)
_expect(lab._marker_under(before) == marker,
"the %s marker is grabbable where it is drawn" % label)
# What the drag SHOULD write, worked out from the camera here rather than
# from the lab's own code, so the two have to agree independently.
var want: Vector3 = _expected(lab, marker, m.global_position, before, before + DRAG)
var was: Vector3 = _knob(lab, key)
lab._begin_drag(marker, before)
# In steps, as a real drag arrives — a single jump would hide an error that
# accumulates per motion event.
for step in 6:
lab._drag_to(before + DRAG * (float(step + 1) / 6.0))
await process_frame
lab._drag_marker = -1
var wrote: Vector3 = _knob(lab, key) - was
var err: float = (wrote - want).length()
_expect(err <= KNOB_TOLERANCE,
"the %s drag wrote %s into '%s' (wanted %s, off by %.2f mm)"
% [label, _mm(wrote), key, _mm(want), err * 1000.0])
# ...and the marker really did go that way on screen.
for _i in 150:
await process_frame
var now: Vector2 = lab._cam.unproject_position(m.global_position)
var moved: Vector2 = now - before
var along: float = moved.dot(DRAG.normalized()) / DRAG.length()
_expect(along >= MIN_TRAVEL and along <= MAX_TRAVEL,
"the %s marker followed the drag (%.2fx of it; the shoulder feedback puts this over 1)"
% [label, along])
## The knob delta a drag from `a` to `b` ought to produce, in that knob's frame.
func _expected(lab: Node, marker: int, at: Vector3, a: Vector2, b: Vector2) -> Vector3:
var world := _plane(lab._cam, at, b) - _plane(lab._cam, at, a)
var v: Vector3 = lab._model.skeleton.global_transform.basis.inverse() * world
if lab.MARKER_KNOB[marker][1] == "gun":
v = lab._model._pose_mod.dbg_gun_basis.inverse() * v
return v
func _plane(cam: Camera3D, at: Vector3, mouse: Vector2) -> Vector3:
var origin := cam.project_ray_origin(mouse)
var dir := cam.project_ray_normal(mouse)
var n := -cam.global_transform.basis.z
return origin + dir * (((at - origin).dot(n)) / dir.dot(n))
## An absent knob reads as its DEFAULT, not as zero.
##
## Those are the same thing for `grip_shift` and `fore_shift` and not for
## `pocket_hip`, whose default is (30, -70, 60) mm. Reading it as zero made a
## perfectly correct 60 px drag look like a 97 mm error — the difference was
## exactly the default. The lab has a note about this trap in `_reset`; it is
## just as easy to walk into from a test.
func _knob(lab: Node, key: String) -> Vector3:
var v = lab._knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
return v if v is Vector3 else Vector3.ZERO
func _mm(v: Vector3) -> String:
return "(%.0f, %.0f, %.0f) mm" % [v.x * 1000.0, v.y * 1000.0, v.z * 1000.0]
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1
func _done() -> void:
print("\n=== ANCHOR DRAG ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://bw4ojr7c451pq
+140
View File
@@ -0,0 +1,140 @@
extends SceneTree
## Do the hand anchors move where they are told, in the frame they are told in?
##
## `grip_shift` and `fore_shift` exist because the two hand anchors could only
## ever slide along the barrel: `gun_stock` and `gun_fore` are distances along
## the weapon's own axis, so the trigger and support hands travelled up and down
## the gun and nowhere else. What could move freely was the GUN, under anchors
## that stayed put.
##
## Two things have to hold, and only the first is obvious:
##
## 1. the anchor moves by the amount asked for;
## 2. it moves in the GUN's frame, not the skeleton's. A sideways nudge has to
## stay sideways relative to the weapon whether the muzzle is pitched down
## at low ready or level at ADS — otherwise the same number means two
## different places in the two poses, and a skeleton-space implementation
## passes check 1 happily.
##
## MEASURED IN THE GUN'S FRAME, and it has to be. The hold BREATHES — there is a
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
## same world position twice, and the first version of this check compared
## absolute positions and reported a 3.5 mm error that was just the character
## inhaling. Taking each anchor relative to the one it hangs off and rotating
## into the current gun basis cancels the breathing, the ADS blend and the
## recoil kick exactly, because all three move the basis and the anchor together.
##
## godot --headless --path . -s res://debug/anchor_shift_check.gd
## All three axes, deliberately asymmetric, so an axis swap or a sign flip
## cannot pass.
const SHIFT := Vector3(0.05, -0.03, 0.02)
const TOLERANCE := 0.0015
## Long enough for the ADS blend and the hold's take-up to settle. The gun-frame
## measurement is invariant to both, but a half-blended pose is a bad place to
## be reading anything.
const SETTLE := 40
var _fails := 0
func _init() -> void:
await process_frame
await process_frame
var weapon := _first_weapon()
var data = JSON.parse_string(FileAccess.get_file_as_string(
"res://assets/characters/skins/skins.json"))
for entry in data["skins"]:
await _check(entry["id"], entry.get("model", ""), weapon)
print("\n=== ANCHOR SHIFTS ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)
func _first_weapon() -> String:
var db = root.get_node("LoadoutManager").weapon_db
var ids: Array = db.keys()
ids.sort()
for id in ids:
var s: String = db[id].get("script", "")
if s != "" and ResourceLoader.exists(s):
return s
return ""
## The two anchors in the gun's own across/up/along frame:
## grip, relative to the buttstock == (0, 0, gun_stock) + grip_shift
## fore, relative to the grip == (0, 0, fore_dist) + fore_shift
func _local(pm) -> Array:
var inv: Basis = pm.dbg_gun_basis.inverse()
var grip: Vector3 = pm.dbg_grip
var fore: Vector3 = pm.dbg_fore
var stock: Vector3 = pm.dbg_stock
return [inv * (grip - stock), inv * (fore - grip)]
func _check(id: String, path: String, weapon: String) -> void:
if path == "" or not ResourceLoader.exists(path):
return
var model := SkinnedPlayerModel.new()
model.model_path = path
model.skin_id = id
root.add_child(model)
for _i in 4:
await process_frame
model.set_weapon(weapon)
for _i in 6:
await process_frame
# Both poses, because the gun's pitch differs between them and that is the
# whole point of expressing the shift in the gun's frame.
for pose in [["low ready", 0.0], ["ADS", 1.0]]:
var pm = model._pose_mod
if pm == null:
_expect(false, "'%s' has a pose layer" % id)
break
model.set_hold_tuning({})
model.update_state("ground", 0.0, false)
model.set_locomotion(0.0, 0.0, pose[1])
for _i in SETTLE:
await process_frame
var base: Array = _local(pm)
# Each anchor on its own. `fore` hangs off `grip`, so shifting the grip
# legitimately carries the support hand with it — moving where the
# trigger hand holds a rifle moves the whole rifle, handguard included.
# Testing them together would just measure that, and the first version
# of this check did, and reported the sum as a 2x error.
for which in [["grip_shift", 0, "trigger"], ["fore_shift", 1, "support"]]:
model.set_hold_tuning({which[0]: SHIFT})
for _i in 6:
await process_frame
var now: Array = _local(pm)
var moved: Vector3 = now[which[1]] - base[which[1]]
# x and y are across the barrel — the freedom that did not exist
# before. z is along it, and for the support hand the reach solver
# owns that, so it is not ours to predict.
var across := Vector2(moved.x, moved.y)
var want := Vector2(SHIFT.x, SHIFT.y)
_expect(across.distance_to(want) <= TOLERANCE,
"'%s' %s: %s anchor moved %.0f, %.0f mm across the barrel (wanted %.0f, %.0f)"
% [id, pose[0], which[2], across.x * 1000.0, across.y * 1000.0,
want.x * 1000.0, want.y * 1000.0])
model.set_hold_tuning({})
for _i in 6:
await process_frame
var back: Array = _local(pm)
var residue: Vector3 = back[which[1]] - base[which[1]]
_expect(Vector2(residue.x, residue.y).length() <= TOLERANCE,
"'%s' %s: clearing %s restores the derived anchor"
% [id, pose[0], which[0]])
model.queue_free()
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1
+1
View File
@@ -0,0 +1 @@
uid://d3m2g70t1tkho
+123
View File
@@ -0,0 +1,123 @@
extends SceneTree
## Does the escape menu's character picker actually work?
##
## It is built entirely in code, in an autoload, over a paused tree — three
## things that each hide their own class of mistake and none of which a compile
## check catches. So: open it, walk every entry, and assert that each one
## selects, describes itself, and builds a real model with a real skeleton.
##
## godot --headless --path . -s res://debug/character_picker_check.gd
var _fails: int = 0
func _init() -> void:
root.call_deferred("add_child", Node.new()) # let autoloads finish _ready
await process_frame
await process_frame
var menu = root.get_node_or_null("PauseMenu")
_check(menu != null, "PauseMenu autoload exists")
if menu == null:
_done()
return
_check(menu.character_btn != null, "Character button exists on the pause menu")
_check(menu.character_list != null, "Character list exists")
_check(menu.character_editor != null, "Character screen exists")
menu._show_character()
await process_frame
_check(menu.character_editor.visible, "Character screen shows")
_check(not menu.main_vbox.visible, "Main pause list hides behind it")
# Autoload singletons are not resolvable as identifiers from a `-s` SceneTree
# script — it is compiled before they register — so reach it by path.
var skin_mgr = root.get_node("SkinManager")
var count: int = menu.character_list.item_count
_check(count > 0, "Roster is not empty (%d entries)" % count)
var seen_glb := 0
for i in count:
var id: String = menu.character_list.get_item_metadata(i)
menu._on_character_selected(i)
await process_frame
await process_frame
_check(menu.character_desc.text != "", "'%s' has a description line" % id)
var skin = skin_mgr.get_skin(id)
var expects_model: bool = skin.model_path != "" \
and ResourceLoader.exists(skin.model_path)
if not expects_model:
# A colour-tint skin has no GLB. The preview must be EMPTY, not the
# previously selected character left standing there.
_check(menu._preview_model == null,
"'%s' is a colour skin and clears the preview" % id)
continue
seen_glb += 1
var model = menu._preview_model
_check(model != null, "'%s' builds a preview model" % id)
if model == null:
continue
_check(model.loaded, "'%s' preview finished loading" % id)
_check(model.skeleton != null, "'%s' preview has a skeleton" % id)
_check(model.animation_player != null, "'%s' preview has animations" % id)
_check(model.surface_table() != null and not model.surface_table().is_empty(),
"'%s' preview knows its surface classes" % id)
var body: Array = model.surfaces_of(SkinSurfaces.BODY)
_check(not body.is_empty(), "'%s' preview reports body surfaces" % id)
# A preview that is not ANIMATING is a preview of the bind pose, which
# is the one pose the character will never be in during play. The clip
# NAME is not evidence of that — it is a variable this class sets on
# itself, and it reads "Idle" just as happily when the animation tree
# is not ticking at all. So watch the skeleton move.
_check(model.current_clip_debug() == "Idle",
"'%s' preview selected Idle (got '%s')"
% [id, model.current_clip_debug()])
_check(await _pose_moves(model),
"'%s' preview skeleton is actually animating" % id)
_check(seen_glb >= 6, "every shipping GLB skin previewed (%d)" % seen_glb)
# Back out, and make sure the turntable stops costing frames.
menu._show_main_menu()
await process_frame
_check(not menu.character_editor.visible, "Back returns to the pause list")
_check(menu.main_vbox.visible, "Pause list is showing again")
_done()
## Does the skeleton's pose change over a handful of frames?
##
## Sampled from INSIDE the modifier pass would be better, but the question here
## is only "is anything driving this at all", and for that the animated pose is
## the right thing to read: if the AnimationTree is not ticking, every bone
## holds still and this returns false.
func _pose_moves(model) -> bool:
var skel: Skeleton3D = model.skeleton
if skel == null or skel.get_bone_count() == 0:
return false
var before: Array = []
for b in skel.get_bone_count():
before.append(skel.get_bone_pose_rotation(b))
for _i in 12:
await process_frame
for b in skel.get_bone_count():
if not skel.get_bone_pose_rotation(b).is_equal_approx(before[b]):
return true
return false
func _check(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1
func _done() -> void:
print("\n=== CHARACTER PICKER ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://br28g1yiljtpp
+110
View File
@@ -0,0 +1,110 @@
extends SceneTree
## Dev tool: how much room does the collision solver actually HAVE?
##
## godot --headless --path . -s res://debug/cloth_allow_check.gd -- [skin_glb]
##
## SpringBones caps each cloth point's collider radius to just inside where that
## point rests, so the authored rest pose is a valid state and the idle does not
## buzz (see SpringBones._rest_clearances). That cap is also the ceiling on what
## the collision can ever do: a hull point resting 60 mm from a thigh's axis gets
## an allowance of 54 mm, so a 110 mm thigh can put 56 mm of itself inside that
## piece of cloth before a single constraint fires.
##
## This prints, per cloth bone, the gap between the limb's REAL radius and the
## allowance the solver is given — which is the clipping the solver is blind to
## by construction, before any tuning is considered.
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := GLBLoader.load(path)
if scene == null:
print("could not load ", path)
quit()
return
root.add_child(scene)
var skel: Skeleton3D = _find(scene, "Skeleton3D") as Skeleton3D
var side := path.get_basename() + ".rig.json"
var info = JSON.parse_string(FileAccess.get_file_as_string(side))
if skel == null or typeof(info) != TYPE_DICTIONARY:
print("no skeleton or sidecar")
quit()
return
var cols: Array = []
for c in info.get("colliders", []):
var a := skel.find_bone(String(c.get("bone", "")))
var b := skel.find_bone(String(c.get("child", "")))
if a < 0 or b < 0:
continue
var tail := float(c.get("radius_tail", c.get("radius", 0.1)))
cols.append({
"name": String(c.get("bone", "")),
"a": a, "b": b, "from": float(c.get("from", 0.0)),
"lid": bool(c.get("lid", false)),
"rh": float(c.get("radius_head", tail)), "rt": tail,
})
print("\n=== how much of each limb the solver is blind to, per cloth bone ===")
print(" BLIND = limb radius here - the allowance the rest-clearance cap gives\n")
var rows: Array = []
for ch in info.get("chains", []):
if String(ch.get("class", "")) not in SpringBones.DRAPE_CLASSES:
continue
var names: Array = ch.get("bones", [])
var tips: Array = ch.get("tips", [])
var hulls: Array = ch.get("hulls", [])
for i in names.size():
var bi := skel.find_bone(String(names[i]))
if bi < 0 or i >= tips.size():
continue
var t: Array = tips[i]
if t.size() != 3:
continue
var rest := skel.get_bone_global_rest(bi)
var hull := PackedVector3Array()
if i < hulls.size():
for h in hulls[i]:
if h.size() == 3:
hull.append(Vector3(h[0], h[1], h[2]))
var pts := SpringBones._sample_points(rest, rest.origin,
rest * Vector3(t[0], t[1], t[2]), hull)
var worst := 0.0
var who := ""
for col in cols:
if col["lid"]:
continue
var a: Vector3 = skel.get_bone_global_rest(col["a"]).origin
var b: Vector3 = skel.get_bone_global_rest(col["b"]).origin
a = a.lerp(b, float(col["from"]))
var ab := b - a
var d2 := ab.length_squared()
for p: Vector3 in pts:
var u: float = 0.0 if d2 < 1e-9 \
else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
var d: float = p.distance_to(a + ab * u)
var r: float = lerpf(float(col["rh"]), float(col["rt"]), u)
# Exactly SpringBones._rest_clearances.
var allow: float = maxf(r, d * 0.9) if d >= r else d * 0.9
if r - allow > worst:
worst = r - allow
who = String(col["name"])
if worst > 0.001:
rows.append([worst, skel.get_bone_name(bi), who])
rows.sort_custom(func(x, y): return x[0] > y[0])
for r in rows.slice(0, 24):
print(" %-26s BLIND %5.1f mm against %s" % [r[1], r[0] * 1000.0, r[2]])
print(" ... %d cloth bones have a blind band at all\n" % rows.size())
quit()
func _find(node: Node, cls: String) -> Node:
if node.is_class(cls):
return node
for c in node.get_children():
var f := _find(c, cls)
if f:
return f
return null
+1
View File
@@ -0,0 +1 @@
uid://dck4ag2tssaea
+381
View File
@@ -0,0 +1,381 @@
extends SceneTree
## Dev tool: does the LEG actually poke through the CLOTH?
##
## godot --headless --path . -s res://debug/cloth_clip_check.gd -- [skin_glb]
##
## Skins every cloth vertex itself over a sweep of movement states and measures
## how far each one ends up INSIDE the leg capsules from <model>.rig.json.
##
## This exists because debug/cloth_settle_check.gd measures the wrong thing for
## this question. That one reports how far a cloth BONE penetrates, which came
## back at about a millimetre while the thigh was still visibly through the
## skirt in almost every animation — because a skirt panel is a wide sheet and
## its bone is a single stick from the waist. Keeping the stick out of the leg
## says nothing about the hundreds of vertices hanging off it.
##
## Reports per surface, worst over the sweep:
## DEPTH how far the deepest vertex sits inside a capsule (metres)
## COUNT how many vertices are inside at that worst moment
##
## THE POSE IS READ FROM INSIDE THE MODIFIER PASS, from an observer
## SkeletonModifier3D added after SpringBones. It has to be. Godot restores every
## bone's local pose once the modifier pass is over, so a reader that calls
## force_update_all_bone_transforms() afterwards recomputes the global poses from
## the ANIMATION ALONE and never sees a single thing the cloth solver did. This
## tool did exactly that, and reported the same ~95 mm whether the collision was
## fully enabled or commented out — which is how the mistake was found.
## state, speed
## Idle FIRST and again LAST. A number taken from the state that happens to
## follow a dash is measuring the garment settling, not the garment at rest, and
## the two want opposite fixes — the sweep used to end on idle and reported the
## recovery as an idle failure.
const SWEEP := [["ground", 0.0], ["ground", 3.0], ["ground", 9.0], ["air", 6.0],
["air", -8.0], ["slide", 10.0], ["dash", 14.0], ["ground", 0.0]]
const FRAMES_PER_STATE := 60
var _frames := 0
var _model: SkinnedPlayerModel = null
var _caps: Array = [] # [bone_a, bone_b, r_head, r_tail]
var _worst := {}
var _worst_n := {}
var _cloth_bones := {} # skin bind index sets are per surface; see below
var _driver := {} # mesh -> bone dominating its deepest vertex
var _rest := {} # "mesh/surface" -> per-vertex rest clearance
var _key := ""
var _spring = null
## Full weight list of each surface's deepest vertex. A cloth solver can only
## move a vertex the CLOTH drives — one that is half-weighted to a thigh follows
## that thigh however well the garment is simulated, so "how much of this vertex
## does the skirt actually own" has to be part of the report.
var _mix := {}
## Worst phase of the sweep per surface, so a failure points at a movement state.
var _phase_of := {}
var _phase := 0
## bone name -> deepest contact the SOLVER reported on it over the sweep.
var _saw := {}
var _probe: PoseProbe = null
## bone name -> overlap still left once the relaxation had converged.
var _res := {}
var _per_phase := {}
var _per_phase_n := {}
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _load_caps(skel: Skeleton3D, path: String) -> void:
var side := path.get_basename() + ".rig.json"
if not FileAccess.file_exists(side):
print("no sidecar — nothing to check against")
return
var info = JSON.parse_string(FileAccess.get_file_as_string(side))
if typeof(info) != TYPE_DICTIONARY:
return
for c in info.get("colliders", []):
var a := skel.find_bone(String(c.get("bone", "")))
var b := skel.find_bone(String(c.get("child", "")))
if a < 0 or b < 0:
continue
var tail := float(c.get("radius_tail", c.get("radius", 0.1)))
_caps.append([a, b, float(c.get("radius_head", tail)), tail,
float(c.get("from", 0.0))])
for c in info.get("chains", []):
for n in c.get("bones", []):
var i := skel.find_bone(String(n))
if i >= 0:
_cloth_bones[i] = true
print("checking %d cloth bones against %d leg capsules" % [
_cloth_bones.size(), _caps.size()])
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if _caps.is_empty() and _cloth_bones.is_empty():
_load_caps(skel, _model.model_path)
if _caps.is_empty():
return true
if _spring == null:
# Headless runs uncapped, so the engine delta is sub-millisecond and the
# solver integrates almost nothing. Pin it to a real frame so the sweep
# measures cloth in motion rather than cloth held at its rest pose.
_spring = skel.get_node_or_null("SpringBones")
if _spring:
_spring.fixed_delta = 1.0 / 60.0
_probe = PoseProbe.new()
_probe.name = "ClipProbe"
skel.add_child(_probe) # AFTER SpringBones, so it sees the final pose
return false
if _rest.is_empty():
# Baseline first: a skirt legitimately drapes INSIDE the thigh capsule,
# so absolute depth says nothing. What matters is the leg getting closer
# to a piece of cloth than the artist modelled it.
_capture_rest(skel)
return false
var phase: int = clampi((_frames - 8) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
_phase = phase
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
_model.set_locomotion(0.0, 1.0, 0.0)
_measure(skel)
# What the SOLVER thinks is happening, alongside what the mesh is doing. If
# a bone's vertices are deep inside a leg while its own contact report is
# near zero, the solver is not blind by tuning — it is not looking at the
# geometry that is clipping.
if _spring:
var rep: Dictionary = _spring.debug_hit_report()
for b in rep:
_saw[skel.get_bone_name(b)] = maxf(_saw.get(skel.get_bone_name(b), 0.0),
float(rep[b]))
var res: Dictionary = _spring.debug_residual_report()
for b in res:
_res[skel.get_bone_name(b)] = maxf(_res.get(skel.get_bone_name(b), 0.0),
float(res[b]))
if _frames > 8 + FRAMES_PER_STATE * SWEEP.size():
_report()
return true
return false
## Clearance of every cloth vertex to the legs in the REST pose.
func _capture_rest(skel: Skeleton3D) -> void:
var segs: Array = []
for c in _caps:
# The `from` offset MATTERS. SpringBones starts a limb capsule 10% down
# the bone because the top of a thigh is hip, buried inside the body the
# skirt hangs from — see tools/retarget.py::_leg_colliders. Measuring
# against the full bone tests a band the solver is deliberately not
# defending and reports it as clipping that no tuning can ever fix.
var ra: Vector3 = skel.get_bone_global_rest(c[0]).origin
var rb: Vector3 = skel.get_bone_global_rest(c[1]).origin
segs.append([ra.lerp(rb, c[4]), rb, c[2], c[3]])
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
var skin: Skin = mi.skin
var bone_of := {}
for b in skin.get_bind_count():
var bi := skin.get_bind_bone(b)
if bi < 0:
bi = skel.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
var out := PackedFloat32Array()
out.resize(verts.size())
for v in verts.size():
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
if w <= 0.0:
continue
var bi: int = bone_of[bones[v * per + k]]
if bi < 0:
continue
q += (skel.get_bone_global_rest(bi) * skin.get_bind_pose(bones[v * per + k]) * verts[v]) * w
out[v] = _clearance(q, segs)
_rest["%s/%d" % [mi.name, s]] = out
## Which capsule the last _clearance() call picked. Reported for the deepest
## vertex, because "inside a leg" and "inside the waist lid" are different
## failures with different fixes and the bare number cannot tell them apart.
var _which := -1
## Distance from the nearest capsule SURFACE (negative = inside).
func _clearance(p: Vector3, segs: Array) -> float:
var best := INF
var idx := 0
for s in segs:
var a: Vector3 = s[0]
var ab: Vector3 = s[1] - a
var d2: float = ab.length_squared()
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
var r: float = lerpf(s[2], s[3], t)
var d := p.distance_to(a + ab * t) - r
if d < best:
best = d
_which = idx
idx += 1
return best
## Snapshot of every bone's global pose, taken INSIDE the modifier pass. See the
## header: read any later and the cloth solver's work is already gone.
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _measure(skel: Skeleton3D) -> void:
if _probe == null or _probe.pose.size() != skel.get_bone_count():
return
var segs: Array = []
for c in _caps:
var pa: Vector3 = (_probe.pose[c[0]] as Transform3D).origin
var pb: Vector3 = (_probe.pose[c[1]] as Transform3D).origin
segs.append([pa.lerp(pb, c[4]), pb, c[2], c[3]])
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
var skin: Skin = mi.skin
var bone_of := {}
for b in skin.get_bind_count():
var bi := skin.get_bind_bone(b)
if bi < 0:
bi = skel.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
var deepest := 0.0
var count := 0
var deep_v := -1
var deep_cap := -1
for v in verts.size():
# Only vertices the CLOTH actually drives — the body's own legs
# are inside these capsules by definition.
var is_cloth := false
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
if w <= 0.0:
continue
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0:
continue
if _cloth_bones.has(bi) and w > 0.5:
is_cloth = true
q += ((_probe.pose[bi] as Transform3D) * skin.get_bind_pose(bind) * verts[v]) * w
if not is_cloth:
continue
var rest_arr: PackedFloat32Array = _rest.get("%s/%d" % [mi.name, s], PackedFloat32Array())
if v >= rest_arr.size():
continue
# How far INSIDE a leg this piece of cloth now is, over and above
# however far inside the artist modelled it.
#
# Not "how much closer the leg got": a hem 200 mm clear of a shin
# legitimately comes 180 mm closer when the leg kicks out in a
# slide, and counting that as a failure buried the real clipping
# under motion the character is supposed to have. Only cloth that
# is actually within the capsule can be showing a leg through it.
var inside := -_clearance(q, segs)
var hit := _which
if inside <= 0.0:
continue
var d := inside - maxf(-rest_arr[v], 0.0)
if d > 0.0:
count += 1
if d > deepest:
deepest = d
deep_v = v
deep_cap = hit
if deepest <= 0.0:
continue
# Per state as well as overall: one worst number over a whole sweep
# hides which movement actually breaks, and it moves to a different
# state after every change, which reads as "no progress" when a real
# failure has in fact been fixed and a smaller one exposed.
var pk := "%d" % _phase
if deepest > _per_phase.get(pk, 0.0):
_per_phase[pk] = deepest
_per_phase_n[pk] = count
var key: String = "%s/%s" % [mi.name, mi.mesh.surface_get_name(s)]
if deepest > _worst.get(key, 0.0):
_worst[key] = deepest
_worst_n[key] = count
_phase_of[key] = "%s@%.0f in %s" % [SWEEP[_phase][0],
SWEEP[_phase][1],
skel.get_bone_name(_caps[deep_cap][0]) if deep_cap >= 0 else "?"]
# EVERY bone driving the deepest vertex, not just the strongest.
# A solver can only move what the cloth owns: a vertex half
# weighted to a thigh follows that thigh however well the garment
# is simulated, and no amount of solver work will change it.
var mix: Array = []
var best := 0.0
var bn := -1
for k in per:
var w: float = weights[deep_v * per + k]
if w <= 0.001:
continue
var bi: int = bone_of[bones[deep_v * per + k]]
mix.append("%s=%.2f" % [
skel.get_bone_name(bi) if bi >= 0 else "?", w])
if w > best:
best = w
bn = bi
_mix[key] = " ".join(mix)
_driver[mi.name] = "%s w=%.2f" % [
skel.get_bone_name(bn) if bn >= 0 else "?", best]
# _model.set_locomotion is enough to keep the pose layer fed.
## How far inside the nearest leg capsule this point is (0 if clear).
func _penetration(p: Vector3, segs: Array) -> float:
var worst := 0.0
for s in segs:
var a: Vector3 = s[0]
var ab: Vector3 = s[1] - a
var d2: float = ab.length_squared()
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
var r: float = lerpf(s[2], s[3], t)
worst = maxf(worst, r - p.distance_to(a + ab * t))
return worst
func _report() -> void:
print("\n=== worst LEG-INSIDE-CLOTH penetration over the sweep ===")
if _worst.is_empty():
print(" none — no cloth vertex entered a leg capsule\n")
return
var keys := _worst.keys()
keys.sort_custom(func(a, b): return _worst[a] > _worst[b])
for k in keys:
print(" %-30s %6.1f mm %4d verts worst in %-10s" % [
k, _worst[k] * 1000.0, _worst_n[k], _phase_of.get(k, "?")])
print(" deepest vertex weights: %s" % _mix.get(k, "?"))
var owner: String = _mix.get(k, "=").get_slice("=", 0)
print(" on %s: contact seen %.1f mm, left after solving %.1f mm" % [
owner, _saw.get(owner, 0.0) * 1000.0, _res.get(owner, 0.0) * 1000.0])
print(" per movement state, worst cloth vertex inside a capsule:")
for i in SWEEP.size():
print(" %-12s %6.1f mm %4d verts" % [
"%s@%.0f" % [SWEEP[i][0], SWEEP[i][1]],
_per_phase.get("%d" % i, 0.0) * 1000.0, _per_phase_n.get("%d" % i, 0)])
print("")
+1
View File
@@ -0,0 +1 @@
uid://c4x5gy6vjcvb0
+61
View File
@@ -0,0 +1,61 @@
extends SceneTree
## Dev tool: what does the cloth solver cost per character, per frame?
##
## godot --headless --path . -s res://debug/cloth_perf_check.gd -- [skin_glb]
##
## The solver runs a Gauss-Seidel relaxation over every cloth joint and tests
## every collision hull point against every capsule on every pass, so its cost is
## the product of four numbers that are all easy to raise by accident. This is
## the budget check: a character is one of several on screen and the whole frame
## is 16 ms.
const FRAMES := 240
var _frames := 0
var _model: SkinnedPlayerModel = null
var _spring = null
var _usec := 0
var _samples := 0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if _spring == null:
_spring = skel.get_node_or_null("SpringBones")
if _spring == null:
print("no SpringBones on this model")
return true
_spring.fixed_delta = 1.0 / 60.0
return false
# A run cycle, which is where the colliders are busiest.
_model.update_state("ground", 9.0, false)
_model.set_locomotion(0.0, 1.0, 0.0)
var t0 := Time.get_ticks_usec()
_spring._process_modification()
_usec += Time.get_ticks_usec() - t0
_samples += 1
if _frames > FRAMES:
print("\n=== cloth solver cost ===")
print(" %.3f ms per character per frame (%d samples, running)" % [
float(_usec) / float(_samples) / 1000.0, _samples])
print(" budget: a 60 fps frame is 16.7 ms and holds several characters\n")
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://d3td7sln77h1i
+167
View File
@@ -0,0 +1,167 @@
extends SceneTree
## Dev tool: is the cloth MOVING SANELY, or spasming?
##
## godot --headless --path . -s res://debug/cloth_settle_check.gd -- [speed] [nospring] [glb]
##
## Reports how far each cloth bone ROTATES per frame, in degrees, read from the
## bone's LOCAL pose — which is exactly what SpringBones writes, and is immune to
## the head bobbing or the character travelling.
##
## speed 0 everything should fall towards 0.
## speed 9 a few degrees per frame is cloth. Tens of degrees per frame, at
## frame rate, is the "blur spazzing about".
##
## WARNING: the deg/frame column is LOCAL bone rotation, and for a chain that
## is not the same as visible motion. Correcting a panel root shows up as an
## equal and opposite delta on each of its segments, so a hem that has not
## moved on screen at all can report 12-18 deg/frame. Measured against
## debug/idle_jitter_check.gd, which counts changed PIXELS between
## consecutive idle frames: with collision on 24866 px/frame, with collision
## off 38594 — the collision was the thing this tool accused, and it is
## actually damping the idle rather than driving it. Use the pixel check
## before believing a settling number here.
##
## HOW IT MEASURES, AND WHY THAT MATTERS. Sampling is done by an observer
## SkeletonModifier3D appended AFTER SpringBones, so it sees precisely the pose
## the renderer will use. Two earlier versions of this tool were wrong and both
## sent the investigation the wrong way:
##
## * Reading `get_bone_pose_rotation()` from `_process` reported an identical
## 0.06 deg in EVERY configuration. `_process` runs BEFORE the modifiers, and
## cloth bones carry no animation tracks, so it read the rest pose every time.
## * Calling `force_update_all_bone_transforms()` RE-RUNS the modification
## stack, stepping the solver a second time per frame. A blend applied to the
## final pose write — which cannot fail to reduce motion — moved the reading
## from 32.02 to 32.00 mm/frame. Six real changes in a row read as no-ops.
##
## If a change to the solver does not move these numbers, suspect this file
## before concluding the change did nothing.
const WINDOW := 30
var _frames := 0
var _model: SkinnedPlayerModel = null
var _spring: SpringBones = null
var _obs: Observer = null
var _speed := 0.0
class Observer extends SkeletonModifier3D:
var bones: PackedInt32Array = PackedInt32Array()
var names: Array = []
var prev: Array = []
var worst := 0.0
var worst_name := ""
var sum := 0.0
var n := 0
# Per class, because "the cloth moves" can hide "the hair is frozen".
var hair_sum := 0.0
var hair_n := 0
var hair_worst := 0.0
var skirt_sum := 0.0
var skirt_n := 0
var started := false
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or bones.is_empty():
return
for i in bones.size():
var q := skel.get_bone_pose_rotation(bones[i])
if started:
var d: float = absf(q.angle_to(prev[i]))
if d > worst:
worst = d
worst_name = names[i]
sum += d
n += 1
if names[i].findn("hair") != -1:
hair_sum += d
hair_n += 1
hair_worst = maxf(hair_worst, d)
elif names[i].findn("skirt") != -1:
skirt_sum += d
skirt_n += 1
prev[i] = q
started = true
func _initialize() -> void:
var path := "res://assets/characters/skins/taila.glb"
for a in OS.get_cmdline_user_args():
if a.begins_with("res://"):
path = a
elif a != "nospring":
_speed = a.to_float()
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _setup(skel: Skeleton3D) -> bool:
_spring = skel.get_node_or_null("SpringBones") as SpringBones
if _spring == null:
print("SpringBones not installed")
return false
_spring.fixed_delta = 1.0 / 60.0
if OS.get_cmdline_user_args().has("nospring"):
_spring.active = false
print("springs DISABLED (animation-only baseline)")
var info = JSON.parse_string(
FileAccess.get_file_as_string(_model.model_path.get_basename() + ".rig.json"))
if typeof(info) != TYPE_DICTIONARY:
return false
_obs = Observer.new()
_obs.name = "ClothObserver"
for c in info.get("chains", []):
for nm in c.get("bones", []):
var i := skel.find_bone(String(nm))
if i >= 0:
_obs.bones.append(i)
_obs.names.append(String(nm))
_obs.prev.append(Quaternion.IDENTITY)
# AFTER SpringBones in the tree, so it observes the final pose.
skel.add_child(_obs)
print("tracking %d cloth bones at %.1f m/s" % [_obs.bones.size(), _speed])
return _obs.bones.size() > 0
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 6:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
print("no skeleton")
return true
if _obs == null and not _setup(skel):
return true
_model.update_state("ground", _speed, false)
_model.set_locomotion(0.0, 1.0 if _speed > 0.1 else 0.0, 0.0)
_model.position += Vector3(0, 0, -_speed) / 60.0
if _frames % WINDOW == 0 and _frames > 20:
print("t=%4d worst %6.2f deg/frame (%-18s) mean %5.3f contacts/frame %.1f" % [
_frames, rad_to_deg(_obs.worst), _obs.worst_name,
rad_to_deg(_obs.sum / maxf(_obs.n, 1)),
_spring.debug_collisions_per_frame()])
print(" hair mean %5.3f deg/frame (worst %5.2f) skirt mean %5.3f" % [
rad_to_deg(_obs.hair_sum / maxf(_obs.hair_n, 1)),
rad_to_deg(_obs.hair_worst),
rad_to_deg(_obs.skirt_sum / maxf(_obs.skirt_n, 1))])
_obs.hair_sum = 0.0
_obs.hair_n = 0
_obs.hair_worst = 0.0
_obs.skirt_sum = 0.0
_obs.skirt_n = 0
_obs.worst = 0.0
_obs.sum = 0.0
_obs.n = 0
if _frames > WINDOW * 8:
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://bk7pst2vhawvt
+280
View File
@@ -0,0 +1,280 @@
extends SceneTree
## Dev tool: is the skirt STRETCHING around the thigh, or tearing open?
##
## godot --headless --path . -s res://debug/cloth_stretch_check.gd -- [skin_glb]
##
## The collision and drape solve each cloth bone on its own. Neighbouring skirt
## panels therefore get different answers, and the mesh between them has to
## absorb the difference — which linear-blend skinning does by pulling the shared
## edge apart. On screen that reads as the skirt "breaking" open around the thigh
## instead of deforming over it, and no capsule or spring number shows it,
## because every individual bone is behaving.
##
## So measure the MESH: skin every cloth triangle over a movement sweep and
## compare each edge against its own rest length. An edge whose two ends are
## driven by different panels is a SEAM — that is where a tear appears — so those
## are reported separately from edges inside one panel.
##
## Reports, worst over the sweep:
## stretch posed edge length / rest length
## gap how many millimetres that edge grew
const SWEEP := [["ground", 9.0], ["ground", 3.0], ["air", 6.0],
["air", -8.0], ["slide", 10.0], ["dash", 14.0]]
const FRAMES_PER_STATE := 30
## An edge has to grow by more than this to count as a tear rather than noise.
const REPORT_MM := 8.0
var _frames := 0
var _model: SkinnedPlayerModel = null
var _cloth := {} # bone index -> panel family name
var _edges: Array = [] # [mesh, surface, ia, ib, rest_len, family_a, family_b]
var _skins: Array = [] # [mesh, skin, bone_of, verts, bones, weights, per]
var _worst := {} # "famA|famB" -> [stretch, grow_m, bones, rest_m, state]
## The single worst edge seen, kept so _report can dump what actually drives it.
var _peak := 0.0
var _peak_edge: Array = []
var _state := ""
var _ready := false
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
## Panel a bone belongs to: the chain root's name, with the segments built by
## tools/retarget.py::subdivide_cloth_panels stripped off. Two segments of the
## same panel are meant to bend apart; two different panels are not.
static func _family(bone_name: String) -> String:
var n := bone_name
var cut := n.find(".seg")
return n.substr(0, cut) if cut >= 0 else n
func _build(skel: Skeleton3D) -> void:
var side: String = _model.model_path.get_basename() + ".rig.json"
var info = JSON.parse_string(FileAccess.get_file_as_string(side))
if typeof(info) != TYPE_DICTIONARY:
print("no sidecar")
return
for c in info.get("chains", []):
if String(c.get("class", "")) == "hair":
continue
for n in c.get("bones", []):
var i := skel.find_bone(String(n))
if i >= 0:
_cloth[i] = _family(String(n))
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
var skin: Skin = mi.skin
var bone_of := {}
for b in skin.get_bind_count():
var bi := skin.get_bind_bone(b)
if bi < 0:
bi = skel.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if bones.is_empty() or verts.is_empty() or idx.is_empty():
continue
var per: int = bones.size() / verts.size()
var sk := [mi, skin, bone_of, verts, bones, weights, per]
# Which panel drives each vertex, and its rest position.
var fam := []
var drv := []
var rest := PackedVector3Array()
var any := false
fam.resize(verts.size())
drv.resize(verts.size())
rest.resize(verts.size())
for v in verts.size():
var bw := 0.0
var cw := 0.0
var f := ""
var dn := ""
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_rest(bi) * skin.get_bind_pose(bind)
* verts[v]) * w
if _cloth.has(bi):
cw += w
if w > bw:
bw = w
f = _cloth[bi]
dn = skel.get_bone_name(bi)
# The cloth chains must actually OWN this vertex. Body surfaces
# carry stray cloth influence — one arm vertex measured 0.54
# forearm, 0.35 skirt — and counting those made the skirt look
# like it was tearing by half a metre when the arm was simply
# moving during a dash.
if cw < 0.75:
f = ""
dn = ""
fam[v] = f
drv[v] = dn
rest[v] = q
if f != "":
any = true
if not any:
continue
_skins.append(sk)
var seen := {}
for t in range(0, idx.size(), 3):
for pair in [[idx[t], idx[t + 1]], [idx[t + 1], idx[t + 2]],
[idx[t + 2], idx[t]]]:
var a: int = mini(pair[0], pair[1])
var b: int = maxi(pair[0], pair[1])
if String(fam[a]) == "" or String(fam[b]) == "":
continue
var key := "%d_%d_%d" % [_skins.size(), a, b]
if seen.has(key):
continue
seen[key] = true
var L := rest[a].distance_to(rest[b])
if L < 0.0005:
continue
_edges.append([_skins.size() - 1, a, b, L,
String(fam[a]), String(fam[b]),
"%s -> %s" % [drv[a], drv[b]]])
var seams := 0
for e in _edges:
if e[4] != e[5]:
seams += 1
print("tracking %d cloth edges, %d of them across a panel seam" % [
_edges.size(), seams])
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if not _ready:
_build(skel)
_ready = true
if _edges.is_empty():
return true
return false
var phase: int = clampi((_frames - 9) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
_model.set_locomotion(0.0, 1.0, 0.0)
_state = "%s %.0f" % [SWEEP[phase][0], SWEEP[phase][1]]
_measure(skel)
if _frames > 9 + FRAMES_PER_STATE * SWEEP.size():
_report()
return true
return false
func _measure(skel: Skeleton3D) -> void:
# Skin every cloth vertex once, then walk the edges.
var posed: Array = []
for sk in _skins:
var skin: Skin = sk[1]
var bone_of: Dictionary = sk[2]
var verts: PackedVector3Array = sk[3]
var bones: PackedInt32Array = sk[4]
var weights: PackedFloat32Array = sk[5]
var per: int = sk[6]
var out := PackedVector3Array()
out.resize(verts.size())
for v in verts.size():
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_pose(bi) * skin.get_bind_pose(bind)
* verts[v]) * w
out[v] = q
posed.append(out)
for e in _edges:
var p: PackedVector3Array = posed[e[0]]
var L: float = p[e[1]].distance_to(p[e[2]])
var grow: float = L - float(e[3])
if grow <= 0.0:
continue
var key: String = "%s | %s" % [e[4], e[5]] if e[4] != e[5] else "%s (inside)" % e[4]
var cur: Array = _worst.get(key, [0.0, 0.0])
if grow > cur[1]:
_worst[key] = [L / float(e[3]), grow, e[6], float(e[3]), _state]
if grow > _peak:
_peak = grow
_peak_edge = [e[0], e[1], e[2], float(e[3]), _state]
func _report() -> void:
print("\n=== worst cloth EDGE STRETCH over the sweep ===")
print(" a growing seam is the skirt tearing open between two panels;")
print(" growth inside one panel is the panel itself being stretched.\n")
var keys := _worst.keys()
keys.sort_custom(func(a, b): return _worst[a][1] > _worst[b][1])
var shown := 0
for k in keys:
var w: Array = _worst[k]
if w[1] * 1000.0 < REPORT_MM:
break
print(" %-30s x%6.2f +%6.1f mm rest %5.1f mm %-42s %s" % [
k, w[0], w[1] * 1000.0, w[3] * 1000.0, w[2], w[4]])
shown += 1
if shown >= 24:
break
if shown == 0:
print(" nothing grew by more than %.0f mm" % REPORT_MM)
_dissect()
print("")
## Everything that drives the two ends of the single worst edge. A rigid bone
## cannot change the distance between two points, so an edge that grew while
## both ends report the same DOMINANT bone is being pulled by something else in
## their influence lists — which is the only way to find out what.
func _dissect() -> void:
if _peak_edge.is_empty():
return
var skel: Skeleton3D = _model.skeleton
var sk: Array = _skins[_peak_edge[0]]
var skin: Skin = sk[1]
var bone_of: Dictionary = sk[2]
var bones: PackedInt32Array = sk[4]
var weights: PackedFloat32Array = sk[5]
var per: int = sk[6]
print("
worst single edge: rest %.1f mm, grew %.1f mm, during %s" % [
_peak_edge[3] * 1000.0, _peak * 1000.0, _peak_edge[4]])
for which in [1, 2]:
var v: int = _peak_edge[which]
var line := " vertex %d:" % v
for k in per:
var w: float = weights[v * per + k]
if w <= 0.0001:
continue
var bi: int = bone_of[bones[v * per + k]]
line += " %s %.2f" % [
skel.get_bone_name(bi) if bi >= 0 else "?", w]
print(line)
+1
View File
@@ -0,0 +1 @@
uid://ccx4eh7wgfwrh
+86
View File
@@ -0,0 +1,86 @@
extends SceneTree
## Print every mesh surface of every shipping skin: node name, surface index,
## material name, whether it carries a texture, its albedo and cull mode, and
## which bones dominate it.
##
## This exists because the surface table in the rig sidecar is written by Blender
## and read by Godot, and the two do not have to agree on what anything is
## called. Rather than assume the glTF round trip preserves names and ordering,
## this measures what Godot ends up holding, so the sidecar can be keyed on
## something that actually survives.
##
## godot --headless --path . -s res://debug/dump_surfaces.gd
func _init() -> void:
var registry := "res://assets/characters/skins/skins.json"
var data = JSON.parse_string(FileAccess.get_file_as_string(registry))
for entry in data["skins"]:
var path: String = entry.get("model", "")
if path == "" or not ResourceLoader.exists(path):
continue
print("\n=== %s (%s)" % [entry["id"], path])
var scene: Node = load(path).instantiate()
var skel: Skeleton3D = _first_skeleton(scene)
for mi in scene.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null:
continue
var owner_names := _dominant_bones(mi, skel)
for s in mi.mesh.get_surface_count():
var m: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
var mat_name := "<none>" if m == null else m.resource_name
var tex := m != null and m.albedo_texture != null
var col := Color.WHITE if m == null else m.albedo_color
var cull := -1 if m == null else int(m.cull_mode)
print(" %-34s s%d mat=%-28s tex=%s albedo=(%.2f,%.2f,%.2f) cull=%d bones=%s"
% [mi.name, s, mat_name, "Y" if tex else "n",
col.r, col.g, col.b, cull, owner_names])
scene.free()
quit()
func _first_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node
for c in node.get_children():
var found := _first_skeleton(c)
if found:
return found
return null
## The five bones holding the most dominant-weight vertices on this mesh.
func _dominant_bones(mi: MeshInstance3D, skel: Skeleton3D) -> String:
if mi.skin == null or skel == null or mi.mesh == null:
return "<unskinned>"
var bone_of := {}
for b in mi.skin.get_bind_count():
var n := mi.skin.get_bind_name(b)
bone_of[b] = skel.find_bone(n) if n != "" else mi.skin.get_bind_bone(b)
var tally := {}
var arrays: Array = mi.mesh.surface_get_arrays(0)
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty():
return "<no weights>"
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var per := bones.size() / maxi(1, verts.size())
for v in verts.size():
var best := -1
var best_w := 0.0
for k in per:
var w := weights[v * per + k]
if w > best_w:
best_w = w
best = bones[v * per + k]
if best >= 0 and best_w > 0.25:
var bi: int = bone_of.get(best, -1)
if bi >= 0:
var nm := skel.get_bone_name(bi)
tally[nm] = tally.get(nm, 0) + 1
var names: Array = tally.keys()
names.sort_custom(func(a, b): return tally[a] > tally[b])
var out: PackedStringArray = []
for i in mini(5, names.size()):
out.append("%s:%d" % [names[i], tally[names[i]]])
return ", ".join(out)
+1
View File
@@ -0,0 +1 @@
uid://ch7c3vv6t2gei
+184
View File
@@ -0,0 +1,184 @@
extends SceneTree
## Two things about the per-pose hold.
##
## 1. THE SLIDERS ON SCREEN BELONG TO THE POSE ON SCREEN. Half the hold's knobs
## mean something different at low ready than down the sights, and showing
## both sets at once meant every slider was for one of two poses with nothing
## saying which — and `pitch` does not exist down the sights at all, because
## there the muzzle follows the camera. A control that does nothing is worse
## than a missing one.
##
## 2. THE WRISTS ROTATE. They were one scalar each, a twist about the barrel,
## which is the only axis a hand wrapping a cylinder is free in ONCE the arc
## onto the barrel is solved — true of the support hand, never true of the
## trigger hand, and in neither case a way to cock a wrist forward or break it
## inward. Now three axes, in the gun's frame. This asserts each axis moves
## the hand it names, and that the two poses hold separate values.
##
## godot --path . -s res://debug/hold_pose_check.gd
const LAB := "res://debug/rig_lab.tscn"
## Big enough to read past the pose layer's smoothing, small enough that the IK
## does not give up and drop the hold.
const TWIST := 0.35
var _fails := 0
var _probe: PoseProbe = null
## Snapshot the pose from INSIDE the modifier pass.
##
## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so
## reading `get_bone_pose_rotation` from a SceneTree script recomputes the
## globals from the ANIMATION alone — the shooter hold is simply not in what you
## measure. The first version of this check did that and reported every wrist
## axis as moving the hand by 0.0 degrees, which is the same answer it would
## give if the wrists had never been implemented.
##
## The same trap, and the same fix, as `cloth_clip_check` and `travel_dir_check`.
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _init() -> void:
await process_frame
var lab: Node = load(LAB).instantiate()
root.add_child(lab)
for _i in 200:
await process_frame
if lab._model == null or lab._model._pose_mod == null:
_expect(false, "the lab built a character with a pose layer")
_done()
return
_check_scoping(lab)
await _check_wrists(lab)
_done()
# ── 1. slider scoping ────────────────────────────────────────────────────────
func _check_scoping(lab: Node) -> void:
var shared := {}
for spec in WeaponHoldTuning.SHARED_KNOBS:
shared[spec[0]] = true
for i in lab.POSES.size():
lab._pose = i
lab._rebuild_knobs()
var pose: String = lab._hold_pose()
var other: String = "ads" if pose == "hip" else "hip"
var keys: Array = lab._sliders["hold"].keys()
var strays: PackedStringArray = []
for k in keys:
if shared.has(k):
continue
if not String(k).ends_with("_" + pose):
strays.append(k)
_expect(strays.is_empty(),
"'%s' shows only %s knobs%s" % [lab.POSES[i][0], pose,
"" if strays.is_empty() else " — strays: " + ", ".join(strays)])
# Specifically: the OTHER pose's stock pocket must not be on screen. It
# is the knob most likely to be edited by accident, because both poses
# have one and they look identical in a list.
_expect(not lab._sliders["hold"].has("pocket_" + other),
"'%s' does not show the %s stock pocket" % [lab.POSES[i][0], other])
# ...and the heading says which hold is being edited.
var head: String = lab._heading_for("hold")
_expect(head.to_lower().contains(
WeaponHoldTuning.POSE_NAMES[pose]),
"'%s' heading names the hold: %s" % [lab.POSES[i][0], head])
# `pitch` exists at low ready and NOWHERE else.
lab._pose = 0
lab._rebuild_knobs()
_expect(lab._sliders["hold"].has("pitch_hip"), "low ready offers a muzzle pitch")
lab._pose = 1
lab._rebuild_knobs()
_expect(not lab._sliders["hold"].has("pitch_ads"),
"aiming offers no muzzle pitch — down the sights it follows the camera")
# ── 2. the wrists ────────────────────────────────────────────────────────────
func _check_wrists(lab: Node) -> void:
var model = lab._model
var pm = model._pose_mod
var skel: Skeleton3D = model.skeleton
var hands := {"wrist_r": "hand.R", "wrist_l": "hand.L"}
# AFTER the hold, so what it sees is what the hold produced.
_probe = PoseProbe.new()
_probe.name = "WristProbe"
skel.add_child(_probe)
for _i in 10:
await process_frame
for pose in ["hip", "ads"]:
lab._pose = 0 if pose == "hip" else 1
lab._rebuild_knobs()
for stem in hands:
var bone: int = _role_bone(model, hands[stem])
if bone < 0:
_expect(false, "'%s' resolves to a bone" % hands[stem])
continue
for axis in 3:
var v := Vector3.ZERO
v[axis] = TWIST
model.set_hold_tuning({})
await _settle(lab, pose)
var before: Quaternion = _probe_rot(bone)
model.set_hold_tuning({"%s_%s" % [stem, pose]: v})
await _settle(lab, pose)
var after: Quaternion = _probe_rot(bone)
var moved := rad_to_deg(before.angle_to(after))
# Not compared to an exact angle: the wrist is applied as a
# global-space target and blended in by the hold weight, so the
# LOCAL rotation that lands on the bone is not the knob. That it
# moved, and moved substantially, is the claim.
_expect(moved > 3.0,
"%s %s axis %d turned the hand %.1f deg" % [stem, pose, axis, moved])
model.set_hold_tuning({})
func _settle(lab: Node, pose: String) -> void:
lab._model.update_state("ground", 0.0, false)
lab._model.set_locomotion(0.0, 0.0, 1.0 if pose == "ads" else 0.0)
for _i in 60:
await process_frame
func _probe_rot(bone: int) -> Quaternion:
if _probe == null or bone >= _probe.pose.size():
return Quaternion.IDENTITY
var t: Transform3D = _probe.pose[bone]
return t.basis.get_rotation_quaternion()
func _role_bone(model, role: String) -> int:
var name: String = model._rig_info.get("roles", {}).get(role, "")
return model.skeleton.find_bone(name) if name != "" else -1
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1
func _done() -> void:
print("\n=== HOLD POSES ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://dydhylqo46x1n
+79
View File
@@ -0,0 +1,79 @@
extends SceneTree
## Dev tool: does the cloth actually JITTER when the character is standing still?
##
## godot --path . --windowed --resolution 900x900 \
## -s res://debug/idle_jitter_check.gd -- <out_dir>
##
## debug/cloth_settle_check.gd answers this in degrees per frame of LOCAL bone
## rotation, and that number is inflated for a chain: correcting a panel root
## shows up as an equal and opposite delta on its segments, so a hem that has not
## moved at all in world space can report ten degrees. It has misled before.
##
## This renders consecutive frames of a still idle from a fixed camera and saves
## them; comparing neighbouring PNGs gives the only number that matters, which is
## whether anything on screen moved.
var _frames := 0
var _out := "."
var _model: SkinnedPlayerModel = null
var _cam: Camera3D = null
var _shots := 0
const SHOTS := 12
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
_out = args[0] if args.size() > 0 else "."
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
var env := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0.05, 0.05, 0.08)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(1, 1, 1)
e.ambient_light_energy = 1.3
env.environment = e
scene.add_child(env)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, 35, 0)
scene.add_child(sun)
_model = SkinnedPlayerModel.new()
_model.model_path = "res://assets/characters/skins/taila.glb"
scene.add_child(_model)
_cam = Camera3D.new()
_cam.fov = 28.0
scene.add_child(_cam)
_cam.current = true
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 10 or not _model.loaded:
return false
_model.update_state("ground", 0.0, false)
_model.set_locomotion(0.0, 0.0, 0.0)
var hips := 0.95
if _model.skeleton:
var h := _model.skeleton.find_bone("DEF-spine")
if h >= 0:
hips = _model.skeleton.get_bone_global_pose(h).origin.y
# NEGATIVE Z is the FRONT. SkinnedPlayerModel spins the imported scene 180
# degrees (`facing_flip`: glTF forward is +Z, players face -Z), so a camera
# on +Z looks at the character's BACK. Every tool in here used to sit on +Z,
# and every "front" judgement made from them was of the back of the skirt.
_cam.position = Vector3(0.0, hips - 0.08, -0.9)
_cam.look_at(Vector3(0, hips - 0.14, 0), Vector3.UP)
# Let the chains settle before recording — the first second is the model
# dropping into its hanging pose, which is not jitter.
if _frames > 130 and _shots < SHOTS:
root.get_texture().get_image().save_png("%s/idle_%02d.png" % [_out, _shots])
_shots += 1
if _shots == SHOTS:
print("saved %d idle frames" % SHOTS)
return true
return _frames > 400
+1
View File
@@ -0,0 +1 @@
uid://6qrwda6ux54f
+138
View File
@@ -0,0 +1,138 @@
extends SceneTree
## Dev tool: how well do the sidecar's leg capsules actually enclose the leg?
##
## godot --headless --path . -s res://debug/leg_radius_check.gd -- [skin_glb]
##
## tools/retarget.py sizes each capsule from the MEDIAN distance of the limb's
## own vertices, which by construction leaves half the leg's surface outside the
## collider. Cloth is then pushed out to a shape narrower than the leg it is
## meant to clear, so the solver reports the panel as clear while the thigh is
## visibly through it in the render — the metric and the eye disagree, and the
## eye is right.
##
## Prints, per capsule end, the percentile spread of the real vertex distances
## next to the radius actually shipped.
const PCTS := [0.5, 0.75, 0.85, 0.95, 1.0]
var _frames := 0
var _model: SkinnedPlayerModel = null
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
var info = JSON.parse_string(FileAccess.get_file_as_string(
_model.model_path.get_basename() + ".rig.json"))
if typeof(info) != TYPE_DICTIONARY:
print("no sidecar")
return true
# Every vertex, tagged with the bone that dominates it.
var owned := {} # bone index -> PackedVector3Array of rest positions
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
var skin: Skin = mi.skin
var bone_of := {}
for b in skin.get_bind_count():
var bi := skin.get_bind_bone(b)
if bi < 0:
bi = skel.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in range(mi.mesh.get_surface_count()):
var arrays: Array = mi.mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
for v in verts.size():
var bw := 0.0
var bind := -1
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bi: int = bone_of[bones[v * per + k]]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_rest(bi)
* skin.get_bind_pose(bones[v * per + k]) * verts[v]) * w
if w > bw:
bw = w
bind = bi
if bind < 0 or bw < 0.5:
continue
if not owned.has(bind):
owned[bind] = PackedVector3Array()
owned[bind].append(q)
for c in info.get("colliders", []):
var a_i := skel.find_bone(String(c.get("bone", "")))
var b_i := skel.find_bone(String(c.get("child", "")))
if a_i < 0 or b_i < 0:
continue
var a := skel.get_bone_global_rest(a_i).origin
var b := skel.get_bone_global_rest(b_i).origin
var ab := b - a
var d2 := ab.length_squared()
# The limb is this bone plus any twist segment hanging off it — the same
# grouping tools/retarget.py uses when it sizes the capsule.
var base := String(c.get("bone", ""))
var pts := PackedVector3Array()
for bi in owned:
var n := skel.get_bone_name(bi)
if n == base or n.begins_with(base + "."):
pts.append_array(owned[bi])
# Per-tenth of the limb, so the real taper is visible instead of two
# lumps. The head band is where a thigh stops being a thigh and becomes
# the hip, and that is exactly the band a two-point capsule has to guess.
var bands: Array = []
for _b in 10:
bands.append(PackedFloat32Array())
for p in pts:
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
bands[clampi(int(t * 10.0), 0, 9)].append(p.distance_to(a + ab * t))
var rh := float(c.get("radius_head", 0.0))
var rt := float(c.get("radius_tail", 0.0))
print("%s (%d verts) shipped head %.4f tail %.4f" % [
base, pts.size(), rh, rt])
for band in 10:
var v: PackedFloat32Array = bands[band]
if v.is_empty():
continue
v.sort()
var i: int = clampi(int(0.88 * (v.size() - 1)), 0, v.size() - 1)
var mid: int = v.size() / 2
var t := (float(band) + 0.5) / 10.0
print(" t %.2f n%-5d p50 %.4f p88 %.4f p100 %.4f capsule %.4f" % [
t, v.size(), v[mid], v[i], v[v.size() - 1], lerpf(rh, rt, t)])
return true
func _spread(v: PackedFloat32Array) -> String:
if v.is_empty():
return "(none)"
var out := ""
for p in PCTS:
var i: int = clampi(int(p * (v.size() - 1)), 0, v.size() - 1)
out += "p%02d %.4f " % [int(p * 100.0), v[i]]
return out
+1
View File
@@ -0,0 +1 @@
uid://co25textiw4vq

Some files were not shown because too many files have changed in this diff Show More