diff --git a/.claude/skills/character-pipeline/SKILL.md b/.claude/skills/character-pipeline/SKILL.md new file mode 100644 index 0000000..fd93799 --- /dev/null +++ b/.claude/skills/character-pipeline/SKILL.md @@ -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/.glb --name --rigged + +# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN) +python tools/pipeline.py --uid --name + +# From an unrigged mesh — auto-rigs, and accepts the quality loss +python tools/pipeline.py --input --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 --name --rigged --grow-cloth +``` + +Then, once, so Godot sees the new files: + +```bash +godot --headless --path . --import +``` + +The result is `assets/characters/skins/.glb` + `.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/.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.._all` → `skins..` 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 -- # every character, one shot each +godot --path . -s res://debug/ui_capture.gd -- # every menu screen +godot --path . res://debug/rig_lab.tscn -- shot +``` + +`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. diff --git a/.claude/skills/character-pipeline/references/cloth-and-hair.md b/.claude/skills/character-pipeline/references/cloth-and-hair.md new file mode 100644 index 0000000..db3766e --- /dev/null +++ b/.claude/skills/character-pipeline/references/cloth-and-hair.md @@ -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` 0–3 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. diff --git a/.claude/skills/character-pipeline/references/failure-modes.md b/.claude/skills/character-pipeline/references/failure-modes.md new file mode 100644 index 0000000..e0ab278 --- /dev/null +++ b/.claude/skills/character-pipeline/references/failure-modes.md @@ -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 +`.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. diff --git a/.claude/skills/character-pipeline/references/growing-cloth-bones.md b/.claude/skills/character-pipeline/references/growing-cloth-bones.md new file mode 100644 index 0000000..8eca742 --- /dev/null +++ b/.claude/skills/character-pipeline/references/growing-cloth-bones.md @@ -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. diff --git a/.claude/skills/character-pipeline/references/rigging.md b/.claude/skills/character-pipeline/references/rigging.md new file mode 100644 index 0000000..87d330d --- /dev/null +++ b/.claude/skills/character-pipeline/references/rigging.md @@ -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 `.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. diff --git a/.claude/skills/character-pipeline/references/separation.md b/.claude/skills/character-pipeline/references/separation.md new file mode 100644 index 0000000..d2a3bce --- /dev/null +++ b/.claude/skills/character-pipeline/references/separation.md @@ -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 2–4 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 `.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 `.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(' +``` + +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. diff --git a/.claude/skills/character-pipeline/references/stylization.md b/.claude/skills/character-pipeline/references/stylization.md new file mode 100644 index 0000000..8cb065e --- /dev/null +++ b/.claude/skills/character-pipeline/references/stylization.md @@ -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`. diff --git a/.claude/skills/character-pipeline/references/verification.md b/.claude/skills/character-pipeline/references/verification.md new file mode 100644 index 0000000..8da02ae --- /dev/null +++ b/.claude/skills/character-pipeline/references/verification.md @@ -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 | 17–35 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/.gd +godot --headless --path . -s res://debug/.gd -- res://assets/characters/skins/.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://.gd # syntax +``` + +Autoload identifiers report false "not found" errors under `--check-only` — +ignore those. diff --git a/assets/characters/skins/aria.glb b/assets/characters/skins/aria.glb new file mode 100644 index 0000000..f581dc3 Binary files /dev/null and b/assets/characters/skins/aria.glb differ diff --git a/assets/characters/skins/aria.license.json b/assets/characters/skins/aria.license.json new file mode 100644 index 0000000..5d08dc2 --- /dev/null +++ b/assets/characters/skins/aria.license.json @@ -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" +} \ No newline at end of file diff --git a/assets/characters/skins/aria.rig.json b/assets/characters/skins/aria.rig.json new file mode 100644 index 0000000..4fa8751 --- /dev/null +++ b/assets/characters/skins/aria.rig.json @@ -0,0 +1,2673 @@ +{ + "roles": { + "hips": "Hips", + "head": "Head", + "neck": "Neck", + "spine": [ + "Spine", + "Chest", + "Neck", + "Head" + ], + "shoulder.R": "Right shoulder", + "shoulder.L": "Left shoulder", + "foot.R": "Right ankle", + "foot.L": "Left ankle", + "hand.L": "Left wrist", + "hand.R": "Right wrist", + "forearm.R": "Right elbow", + "forearm.L": "Left elbow", + "shin.L": "Left knee", + "shin.R": "Right knee", + "toe.R": "Right toe", + "toe.L": "Left toe", + "upper_arm.L": "Left arm", + "upper_arm.R": "Right arm", + "thigh.L": "Left leg", + "thigh.R": "Right leg" + }, + "fingers": { + "thumb.L": [ + "Thumb0_L", + "Thumb1_L", + "Thumb2_L" + ], + "index.L": [ + "IndexFinger1_L", + "IndexFinger2_L", + "IndexFinger3_L" + ], + "middle.L": [ + "MiddleFinger1_L", + "MiddleFinger2_L", + "MiddleFinger3_L" + ], + "ring.L": [ + "RingFinger1_L", + "RingFinger2_L", + "RingFinger3_L" + ], + "pinky.L": [ + "LittleFinger1_L", + "LittleFinger2_L", + "LittleFinger3_L" + ], + "thumb.R": [ + "Thumb0_R", + "Thumb1_R", + "Thumb2_R" + ], + "index.R": [ + "IndexFinger1_R", + "IndexFinger2_R", + "IndexFinger3_R" + ], + "middle.R": [ + "MiddleFinger1_R", + "MiddleFinger2_R", + "MiddleFinger3_R" + ], + "ring.R": [ + "RingFinger1_R", + "RingFinger2_R", + "RingFinger3_R" + ], + "pinky.R": [ + "LittleFinger1_R", + "LittleFinger2_R", + "LittleFinger3_R" + ] + }, + "chains": [ + { + "class": "breast", + "root_parent": "Chest", + "bones": [ + "Breast_L", + "J_Sec_L_Bust2" + ], + "tips": [ + [ + 0.0, + 0.09099, + -0.0 + ], + [ + 0.0, + 0.09099, + -0.0 + ] + ], + "hulls": [ + [ + [ + 0.00917, + 0.0933, + -0.00744 + ], + [ + -0.07418, + 0.03013, + 0.00578 + ], + [ + 0.00133, + 0.03457, + 0.05712 + ], + [ + -0.01478, + 0.03942, + -0.06075 + ], + [ + 0.04781, + 0.06869, + 0.03379 + ], + [ + 0.04463, + 0.0526, + -0.02954 + ], + [ + -0.03647, + 0.06666, + 0.01678 + ], + [ + -0.0479, + 0.0376, + 0.05365 + ], + [ + -0.04619, + 0.06094, + -0.03406 + ], + [ + 0.0027, + 0.07461, + 0.02918 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_L_Bust2": 9.193998822705257, + "Breast_R": 0.3764394436581815, + "J_Sec_R_Bust2": 0.003499964148881679 + }, + { + "Breast_L": 9.193998822705257, + "Breast_R": 0.0034999641116944535 + } + ] + }, + { + "class": "breast", + "root_parent": "Chest", + "bones": [ + "Breast_R", + "J_Sec_R_Bust2" + ], + "tips": [ + [ + 0.0, + 0.09099, + 0.0 + ], + [ + 0.0, + 0.09099, + 0.0 + ] + ], + "hulls": [ + [ + [ + -0.00917, + 0.0933, + -0.00744 + ], + [ + 0.07418, + 0.03013, + 0.00578 + ], + [ + -0.00133, + 0.03457, + 0.05712 + ], + [ + 0.01478, + 0.03942, + -0.06075 + ], + [ + -0.04781, + 0.06869, + 0.03379 + ], + [ + -0.04463, + 0.0526, + -0.02954 + ], + [ + 0.03647, + 0.06666, + 0.01678 + ], + [ + 0.0479, + 0.0376, + 0.05365 + ], + [ + 0.04619, + 0.06094, + -0.03406 + ], + [ + -0.0027, + 0.07461, + 0.02918 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_R_Bust2": 9.193998837122376, + "Breast_L": 0.3764394436581815, + "J_Sec_L_Bust2": 0.0034999641116944535 + }, + { + "Breast_R": 9.193998837122376, + "Breast_L": 0.003499964148881679 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_01", + "J_Sec_Hair2_01" + ], + "tips": [ + [ + 0.0, + 0.05267, + -0.0 + ], + [ + -0.01175, + 0.0474, + 0.0045 + ] + ], + "hulls": [ + [ + [ + 0.02211, + 0.05654, + -0.00737 + ], + [ + -0.0113, + 0.00462, + 0.00886 + ], + [ + 0.02664, + 0.01135, + -0.00762 + ], + [ + -0.01012, + 0.04418, + 0.0082 + ], + [ + 0.00417, + 0.02934, + -0.01676 + ], + [ + 0.00506, + 0.00069, + -0.01228 + ], + [ + 0.02388, + 0.03378, + -0.00218 + ], + [ + 0.00751, + 0.01734, + 0.00221 + ], + [ + 0.00156, + 0.05109, + -0.01154 + ], + [ + -0.01645, + 0.0246, + 0.00309 + ] + ], + [ + [ + -0.01377, + 0.04599, + 0.02311 + ], + [ + 0.01517, + 0.00071, + -0.01566 + ], + [ + -0.01419, + 0.01615, + -0.00058 + ], + [ + -0.00018, + 0.04996, + -0.00016 + ], + [ + 0.00545, + 0.01883, + -0.0109 + ], + [ + -0.00285, + 0.03046, + 0.00971 + ], + [ + -0.00527, + -0.0005, + -0.00852 + ], + [ + -0.00093, + 0.00626, + 0.01055 + ], + [ + -0.02353, + 0.02583, + 0.0126 + ], + [ + -0.00785, + 0.02851, + -0.00514 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_01": 22.972438677485403 + }, + { + "J_Sec_Hair1_01": 22.972438677485403 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_02", + "J_Sec_Hair2_02" + ], + "tips": [ + [ + 0.0, + 0.04592, + -0.0 + ], + [ + -0.01915, + 0.03667, + -0.02491 + ] + ], + "hulls": [ + [ + [ + 0.01072, + 0.04455, + -0.0095 + ], + [ + -0.00524, + 0.00342, + 0.00917 + ], + [ + -0.00971, + 0.01753, + -0.01712 + ], + [ + 0.0014, + 0.02666, + 0.00964 + ], + [ + 0.0114, + 0.01144, + -0.00735 + ], + [ + -0.00834, + 0.03811, + -0.01428 + ], + [ + -0.00347, + 0.00442, + -0.00748 + ], + [ + -0.00105, + 0.04163, + 0.00561 + ], + [ + -0.00107, + 0.02497, + -0.00467 + ], + [ + 0.00312, + 0.01215, + 0.01332 + ] + ], + [ + [ + -0.02844, + 0.02281, + -0.02358 + ], + [ + 0.00321, + 0.00215, + 0.00308 + ], + [ + -4e-05, + 0.04244, + -5e-05 + ], + [ + -0.00393, + 0.02414, + -0.01924 + ], + [ + -0.01625, + 0.00279, + -0.01771 + ], + [ + 0.00542, + 0.00579, + -0.01485 + ], + [ + -0.00338, + 0.01583, + -0.00303 + ], + [ + -0.02929, + 0.00954, + -0.02894 + ], + [ + -0.01711, + 0.02644, + -0.01476 + ], + [ + -0.00247, + 0.03094, + -0.00726 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_02": 17.0205659394362 + }, + { + "J_Sec_Hair1_02": 17.0205659394362 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_03", + "J_Sec_Hair2_03" + ], + "tips": [ + [ + -0.0, + 0.04129, + -0.0 + ], + [ + -0.01612, + 0.04011, + -0.00151 + ] + ], + "hulls": [ + [ + [ + -0.0169, + 0.04361, + -0.00615 + ], + [ + 0.00714, + -6e-05, + -0.03654 + ], + [ + -0.00202, + 0.00152, + 0.00945 + ], + [ + 0.0051, + 0.01985, + -0.01492 + ], + [ + -0.0019, + 0.03072, + 0.00772 + ], + [ + 0.00809, + 0.00197, + -0.00615 + ], + [ + 0.002, + 0.03754, + -0.00902 + ], + [ + -0.00948, + 0.02959, + -0.02025 + ], + [ + -0.01124, + 0.01631, + 0.01027 + ], + [ + -0.00229, + 0.00827, + -0.02559 + ] + ], + [ + [ + -0.00709, + 0.04307, + 0.00464 + ], + [ + -0.01588, + -0.00104, + -0.00701 + ], + [ + 0.00791, + 0.01708, + -0.00818 + ], + [ + -0.01705, + 0.02161, + -0.00507 + ], + [ + 0.00049, + 0.00372, + 0.00191 + ], + [ + -0.00251, + 0.02523, + 0.00203 + ], + [ + -0.02366, + 0.01048, + -0.00113 + ], + [ + -0.00972, + 0.01506, + 0.00574 + ], + [ + -0.01424, + 0.02991, + 0.00429 + ], + [ + -0.01198, + -0.00122, + 0.00261 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_03": 16.98434789938547 + }, + { + "J_Sec_Hair1_03": 16.98434789938547 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_04", + "J_Sec_Hair2_04" + ], + "tips": [ + [ + 0.0, + 0.04697, + 0.0 + ], + [ + -0.00062, + 0.05408, + -0.00405 + ] + ], + "hulls": [ + [ + [ + -0.01127, + 0.03501, + -0.02567 + ], + [ + 0.00453, + 0.00637, + 0.01072 + ], + [ + 4e-05, + 0.00157, + -0.02429 + ], + [ + 0.00334, + 0.04059, + 0.00844 + ], + [ + 0.00196, + 0.02267, + -0.00801 + ], + [ + -0.01323, + 0.01409, + -0.02853 + ], + [ + -0.00341, + 0.04004, + -0.00781 + ], + [ + -0.00476, + 0.00727, + -0.01015 + ], + [ + -0.00524, + 0.02935, + 0.00267 + ], + [ + 0.00061, + 0.02384, + -0.02182 + ] + ], + [ + [ + -0.00073, + 0.04395, + 0.00224 + ], + [ + 0.00853, + 0.00043, + 0.00247 + ], + [ + -0.02077, + 0.01285, + -0.01013 + ], + [ + 0.01243, + 0.02414, + 0.00407 + ], + [ + -0.00355, + 0.0276, + -0.01104 + ], + [ + -0.00574, + 0.00296, + -0.00559 + ], + [ + 0.00235, + 0.01536, + -0.00274 + ], + [ + 0.00479, + 0.03287, + 0.00121 + ], + [ + 0.01225, + 0.0115, + 0.00246 + ], + [ + -0.00882, + 0.01747, + -0.00418 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_04": 4.785209337865712 + }, + { + "J_Sec_Hair1_04": 4.785209337865712 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_05", + "J_Sec_Hair2_05" + ], + "tips": [ + [ + 0.0, + 0.04582, + 0.0 + ], + [ + 0.00306, + 0.05362, + -0.00337 + ] + ], + "hulls": [ + [ + [ + 0.00104, + 0.04116, + -0.00917 + ], + [ + -0.00403, + 0.0043, + 0.01074 + ], + [ + 0.01282, + 0.00945, + -0.02911 + ], + [ + -0.00177, + 0.0185, + -0.00832 + ], + [ + 0.01131, + 0.03056, + -0.0264 + ], + [ + -0.00263, + 0.02662, + 0.00963 + ], + [ + 0.0045, + 0.00468, + -0.01046 + ], + [ + -0.0009, + 0.00848, + -0.02331 + ], + [ + 0.01099, + 0.01879, + -0.01386 + ], + [ + 0.00605, + 0.01529, + 0.00243 + ] + ], + [ + [ + 0.00185, + 0.04373, + 0.00167 + ], + [ + -0.00798, + -0.00034, + 0.00237 + ], + [ + 0.02331, + 0.01192, + -0.00883 + ], + [ + -0.01103, + 0.02296, + 0.00238 + ], + [ + 0.00608, + 0.0257, + -0.00906 + ], + [ + 0.00428, + 0.00577, + -0.00371 + ], + [ + -0.00747, + 0.01288, + -0.00384 + ], + [ + 0.01147, + 0.01556, + -0.00318 + ], + [ + -0.00434, + 0.03276, + 0.00103 + ], + [ + 0.00684, + 0.03604, + -0.00429 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_05": 4.838946945472742 + }, + { + "J_Sec_Hair1_05": 4.838946945472742 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_06", + "J_Sec_Hair2_06" + ], + "tips": [ + [ + 0.0, + 0.04688, + -0.0 + ], + [ + 0.0024, + 0.05976, + 0.00959 + ] + ], + "hulls": [ + [ + [ + -0.00523, + 0.04672, + 0.02244 + ], + [ + 0.01617, + 0.00892, + -0.01237 + ], + [ + -0.00503, + 0.00015, + 0.02236 + ], + [ + 0.0147, + 0.04185, + -0.01069 + ], + [ + -0.00602, + 0.01787, + -0.00251 + ], + [ + -0.0074, + 0.02326, + 0.02337 + ], + [ + -0.00488, + 0.04044, + -0.00212 + ], + [ + 0.00346, + 0.01414, + 0.0139 + ], + [ + 0.00522, + 0.02847, + -0.01111 + ], + [ + 0.00527, + 0.00569, + -0.00172 + ] + ], + [ + [ + 0.01408, + 0.05363, + -0.00026 + ], + [ + -0.01232, + 0.00736, + 0.01936 + ], + [ + 0.01496, + 0.01141, + -0.00463 + ], + [ + -0.00556, + 0.03939, + 0.01853 + ], + [ + -0.00482, + 0.02561, + -0.00086 + ], + [ + -0.00358, + 0.00366, + -0.00284 + ], + [ + 0.01186, + 0.03202, + -0.00338 + ], + [ + -0.0002, + 0.04649, + 0.00018 + ], + [ + -0.00834, + 0.01404, + 0.00644 + ], + [ + 0.00555, + 0.01005, + 0.00408 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_06": 8.396507483964648 + }, + { + "J_Sec_Hair1_06": 8.396507483964648 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_07", + "J_Sec_Hair2_07" + ], + "tips": [ + [ + -0.0, + 0.04662, + -0.0 + ], + [ + -0.00341, + 0.05907, + 0.00954 + ] + ], + "hulls": [ + [ + [ + 0.00668, + 0.04005, + 0.0237 + ], + [ + -0.01595, + 0.00928, + -0.01259 + ], + [ + 0.00647, + 0.00349, + 0.0247 + ], + [ + -0.01419, + 0.04194, + -0.00218 + ], + [ + 0.00596, + 0.01773, + -0.00252 + ], + [ + 0.00483, + 0.04013, + -0.00215 + ], + [ + -0.00246, + 0.01728, + 0.0159 + ], + [ + -0.00537, + 0.02827, + -0.01102 + ], + [ + -0.0066, + 0.00744, + 0.00285 + ], + [ + 0.00794, + 0.02899, + 0.01163 + ] + ], + [ + [ + -0.01459, + 0.05372, + -0.00063 + ], + [ + 0.01183, + 0.00123, + 0.01952 + ], + [ + -0.01493, + 0.01132, + -0.00484 + ], + [ + 0.00635, + 0.03459, + 0.01645 + ], + [ + -0.0109, + 0.03244, + 0.00179 + ], + [ + 0.0035, + 0.00359, + -0.0029 + ], + [ + 0.00415, + 0.02554, + -0.00153 + ], + [ + 0.00021, + 0.04647, + 0.00017 + ], + [ + 0.00387, + 0.01318, + 0.00851 + ], + [ + -0.00699, + 0.0222, + -0.00216 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_07": 8.387276555993644 + }, + { + "J_Sec_Hair1_07": 8.387276555993644 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_08", + "J_Sec_Hair2_08", + "J_Sec_Hair3_08", + "J_Sec_Hair4_08" + ], + "tips": [ + [ + -0.0, + 0.10496, + 0.0 + ], + [ + -0.0, + 0.10547, + -0.0 + ], + [ + -0.0, + 0.10554, + -0.0 + ], + [ + 0.01605, + 0.09703, + 0.06583 + ] + ], + "hulls": [ + [ + [ + -0.07153, + 0.10743, + 0.01136 + ], + [ + 0.06404, + 0.00327, + 0.00358 + ], + [ + -0.05611, + -0.00021, + 0.00811 + ], + [ + 0.03184, + 0.10221, + -0.00142 + ], + [ + 0.00697, + 0.04137, + 0.03228 + ], + [ + -0.0442, + 0.0594, + -0.01228 + ], + [ + -0.02144, + 0.09165, + 0.02908 + ], + [ + 0.0631, + 0.05723, + -0.00107 + ], + [ + 0.00291, + 0.00036, + 0.00243 + ], + [ + 0.03631, + 0.08221, + 0.03787 + ] + ], + [ + [ + -0.09161, + 0.10225, + -0.00107 + ], + [ + 0.06914, + 0.0124, + 0.01082 + ], + [ + -0.04301, + 0.00087, + 0.02207 + ], + [ + 0.0331, + 0.09992, + 0.06227 + ], + [ + 0.00729, + 0.05557, + 0.00233 + ], + [ + 0.06579, + 0.10144, + -0.00492 + ], + [ + -0.04659, + 0.07612, + 0.04976 + ], + [ + -0.07923, + 0.04122, + -0.00863 + ], + [ + -0.03052, + 0.10194, + -0.00235 + ], + [ + 0.01152, + 0.01006, + 0.03891 + ] + ], + [ + [ + 0.07388, + 0.12158, + 0.057 + ], + [ + -0.09419, + 0.0045, + -0.00035 + ], + [ + 0.06616, + 0.00696, + -0.00356 + ], + [ + -0.05004, + 0.1064, + 0.05473 + ], + [ + -0.01029, + 0.00661, + 0.06143 + ], + [ + 0.02326, + 0.07918, + 0.00042 + ], + [ + -0.12052, + 0.07684, + 0.02644 + ], + [ + 0.05954, + 0.0501, + 0.05852 + ], + [ + -0.04165, + 0.05332, + 0.00092 + ], + [ + 0.00708, + 0.08094, + 0.06213 + ] + ], + [ + [ + -0.10796, + 0.05015, + 0.05267 + ], + [ + 0.09532, + 0.08732, + -0.00437 + ], + [ + 0.01438, + -0.01201, + 0.06067 + ], + [ + -0.01579, + 0.09651, + 0.00338 + ], + [ + 0.08815, + -0.00924, + 0.00912 + ], + [ + -0.04214, + 0.0078, + 0.011 + ], + [ + 0.02619, + 0.06354, + 0.06116 + ], + [ + -0.04095, + 0.06844, + 0.06988 + ], + [ + 0.03418, + 0.0353, + 0.00184 + ], + [ + -0.05753, + 0.00659, + 0.07191 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_08": 84.55824972109777 + }, + { + "J_Sec_Hair1_08": 84.55824972109777, + "J_Sec_Hair3_08": 86.75028206731109 + }, + { + "J_Sec_Hair4_08": 87.00580237252505, + "J_Sec_Hair2_08": 86.75028206731109 + }, + { + "J_Sec_Hair3_08": 87.00580237252505 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_09", + "J_Sec_Hair2_09", + "J_Sec_Hair3_09", + "J_Sec_Hair4_09" + ], + "tips": [ + [ + 0.0, + 0.10543, + 0.0 + ], + [ + 0.0, + 0.10472, + 0.0 + ], + [ + 0.0, + 0.10523, + 0.0 + ], + [ + -0.06421, + 0.13784, + 0.03773 + ] + ], + "hulls": [ + [ + [ + -0.06656, + 0.11026, + 0.04817 + ], + [ + 0.01219, + 0.00692, + -0.04538 + ], + [ + -0.03807, + 0.00521, + 0.05267 + ], + [ + -0.02272, + 0.1011, + -0.04851 + ], + [ + -0.00398, + 0.07477, + 0.0205 + ], + [ + -0.04299, + 0.03936, + -0.03427 + ], + [ + -0.05933, + 0.05738, + 0.02592 + ], + [ + -0.00549, + 0.01549, + 0.01019 + ], + [ + 0.01867, + 0.06818, + -0.04032 + ], + [ + -0.03893, + 0.10635, + 0.00977 + ] + ], + [ + [ + -0.0878, + 0.10466, + 0.03983 + ], + [ + 0.0289, + 0.00755, + -0.033 + ], + [ + -0.05281, + -0.00369, + 0.04056 + ], + [ + -0.02026, + 0.10148, + -0.03905 + ], + [ + -0.01598, + 0.05741, + 0.02002 + ], + [ + -0.03865, + 0.02918, + -0.04654 + ], + [ + 0.03076, + 0.09552, + -0.00202 + ], + [ + -0.07421, + 0.06032, + -0.0021 + ], + [ + -0.01649, + 0.00225, + 0.0018 + ], + [ + 0.00572, + 0.06012, + -0.03105 + ] + ], + [ + [ + -0.10645, + 0.09342, + 0.03535 + ], + [ + 0.03714, + 0.00481, + -0.02017 + ], + [ + -0.07792, + -0.0035, + -0.01175 + ], + [ + 0.00466, + 0.10431, + 0.00641 + ], + [ + -0.06083, + 0.07431, + -0.0248 + ], + [ + -0.0224, + 0.03201, + 0.02291 + ], + [ + -0.00924, + 0.0432, + -0.03742 + ], + [ + -0.09792, + 0.03242, + 0.03269 + ], + [ + -0.04999, + 0.08067, + 0.03692 + ], + [ + 0.04142, + 0.06406, + -0.01977 + ] + ], + [ + [ + -0.09347, + 0.12211, + 0.07242 + ], + [ + 0.03302, + 0.00987, + -0.03583 + ], + [ + -0.0617, + 0.00343, + 0.06316 + ], + [ + -0.00214, + 0.11517, + -0.01309 + ], + [ + -0.03951, + 0.03636, + -0.00902 + ], + [ + -0.03006, + 0.07742, + 0.05733 + ], + [ + -0.09269, + 0.07215, + 0.02499 + ], + [ + -0.08565, + 0.13211, + 0.00698 + ], + [ + 0.02019, + 0.05715, + 0.00097 + ], + [ + -0.00875, + 0.0122, + 0.03632 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_09": 78.90760830260274 + }, + { + "J_Sec_Hair1_09": 78.90760830260274, + "J_Sec_Hair3_09": 79.41734449788348 + }, + { + "J_Sec_Hair4_09": 75.41101903626877, + "J_Sec_Hair2_09": 79.41734449788348 + }, + { + "J_Sec_Hair3_09": 75.41101903626877 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_10", + "J_Sec_Hair2_10", + "J_Sec_Hair3_10", + "J_Sec_Hair4_10" + ], + "tips": [ + [ + 0.0, + 0.11079, + 0.0 + ], + [ + -0.0, + 0.11018, + -0.0 + ], + [ + 0.0, + 0.11069, + 0.0 + ], + [ + 0.0529, + 0.14342, + 0.02356 + ] + ], + "hulls": [ + [ + [ + 0.04231, + 0.11172, + -0.04241 + ], + [ + 0.01589, + 0.002, + 0.04537 + ], + [ + -0.00495, + 0.02182, + -0.04958 + ], + [ + 0.04112, + 0.09499, + 0.04301 + ], + [ + -0.02265, + 0.08699, + -0.02233 + ], + [ + 0.03503, + 0.05655, + -0.01278 + ], + [ + -0.0109, + 0.01586, + 0.00172 + ], + [ + 0.05173, + 0.03639, + 0.03113 + ], + [ + 0.00212, + 0.06956, + 0.02191 + ], + [ + 0.02689, + 0.10063, + -0.0017 + ] + ], + [ + [ + 0.0717, + 0.11257, + -0.00053 + ], + [ + -0.02795, + 0.01097, + -0.04021 + ], + [ + 0.04589, + -0.00093, + 0.03575 + ], + [ + -0.03562, + 0.09933, + -0.00866 + ], + [ + 0.04042, + 0.0573, + -0.04529 + ], + [ + 0.02622, + 0.0704, + 0.02681 + ], + [ + 0.01827, + 0.10505, + -0.02344 + ], + [ + 0.06876, + 0.03913, + 0.00376 + ], + [ + 0.03488, + 0.00573, + -0.02818 + ], + [ + -0.0038, + 0.02825, + 0.00528 + ] + ], + [ + [ + 0.09693, + 0.09353, + -0.02119 + ], + [ + -0.04268, + 0.00881, + -0.02346 + ], + [ + 0.06127, + -0.00235, + 0.02821 + ], + [ + -0.01431, + 0.11112, + 0.00088 + ], + [ + 0.03338, + 0.04036, + -0.0398 + ], + [ + 0.04853, + 0.07291, + 0.03372 + ], + [ + -0.00305, + 0.04895, + 0.00868 + ], + [ + 0.03431, + 0.09654, + -0.02439 + ], + [ + 0.08121, + 0.03538, + -0.01683 + ], + [ + -0.04096, + 0.06884, + -0.01959 + ] + ], + [ + [ + 0.08884, + 0.13524, + 0.0072 + ], + [ + -0.03138, + 0.00574, + -0.03145 + ], + [ + 0.05444, + 0.023, + 0.0668 + ], + [ + -0.00629, + 0.10328, + -0.01684 + ], + [ + 0.04799, + 0.01276, + -0.01855 + ], + [ + 0.04867, + 0.10273, + 0.06538 + ], + [ + 0.09698, + 0.06504, + 0.02541 + ], + [ + 0.00261, + 0.05719, + 0.03199 + ], + [ + 0.05271, + 0.08676, + 0.00187 + ], + [ + 0.01432, + 0.00543, + 0.03469 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_10": 62.44316311540696 + }, + { + "J_Sec_Hair1_10": 62.44316311540696, + "J_Sec_Hair3_10": 63.04460060622763 + }, + { + "J_Sec_Hair2_10": 63.04460060622763, + "J_Sec_Hair4_10": 60.580090488886825 + }, + { + "J_Sec_Hair3_10": 60.580090488886825 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_11", + "J_Sec_Hair2_11", + "J_Sec_Hair3_11" + ], + "tips": [ + [ + 0.0, + 0.09743, + -0.0 + ], + [ + 0.0, + 0.09762, + 0.0 + ], + [ + 0.00462, + 0.10146, + -0.01285 + ] + ], + "hulls": [ + [ + [ + -0.01221, + 0.09678, + 0.00949 + ], + [ + 0.00744, + -0.0005, + -0.00825 + ], + [ + 0.01236, + 0.0531, + -0.01826 + ], + [ + -0.01009, + 0.03598, + 0.01136 + ], + [ + 0.01125, + 0.09408, + -0.02381 + ], + [ + -0.01357, + 0.07188, + -0.02131 + ], + [ + -0.0047, + 0.0666, + 0.01137 + ], + [ + -0.01289, + 0.02218, + -0.01468 + ], + [ + -0.00933, + 0.00608, + 0.01185 + ], + [ + -0.01118, + 0.04888, + -0.01063 + ] + ], + [ + [ + 0.00784, + 0.09956, + 0.01381 + ], + [ + -0.01848, + 0.00315, + -0.01861 + ], + [ + -0.0151, + 0.06007, + -0.02411 + ], + [ + 0.00118, + 0.03907, + 0.0139 + ], + [ + 0.02421, + 0.07537, + -0.01586 + ], + [ + 0.01089, + 0.02775, + -0.02591 + ], + [ + -0.01747, + 0.08986, + -0.0102 + ], + [ + 0.00209, + 0.00872, + 0.00641 + ], + [ + 0.00358, + 0.06946, + 0.00799 + ], + [ + -0.02051, + 0.03341, + -0.01797 + ] + ], + [ + [ + -0.01959, + 0.09327, + -0.01552 + ], + [ + 0.02314, + 0.00277, + -0.01465 + ], + [ + 0.01473, + 0.05659, + 0.01522 + ], + [ + -0.02419, + 0.00699, + -0.01011 + ], + [ + 0.02832, + 0.04425, + -0.02271 + ], + [ + -0.0122, + 0.04538, + -0.00939 + ], + [ + 0.01049, + 0.01673, + 0.01335 + ], + [ + 0.00356, + 0.08581, + 0.00547 + ], + [ + 0.01037, + 0.06844, + -0.01763 + ], + [ + 0.00866, + 0.02404, + -0.02235 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_11": 38.39318372494317 + }, + { + "J_Sec_Hair1_11": 38.39318372494317, + "J_Sec_Hair3_11": 38.646242415686416 + }, + { + "J_Sec_Hair2_11": 38.646242415686416 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_12", + "J_Sec_Hair2_12", + "J_Sec_Hair3_12" + ], + "tips": [ + [ + 0.0, + 0.09903, + -0.0 + ], + [ + -0.0, + 0.09923, + 0.0 + ], + [ + -0.00434, + 0.10025, + -0.01349 + ] + ], + "hulls": [ + [ + [ + -0.01146, + 0.0976, + -0.02407 + ], + [ + 0.00785, + 0.00682, + 0.01347 + ], + [ + 0.01436, + 0.05683, + -0.00543 + ], + [ + 0.01163, + 0.09772, + 0.01073 + ], + [ + -0.01269, + 0.02645, + -0.0143 + ], + [ + -0.01314, + 0.06793, + -0.01422 + ], + [ + 0.00134, + 0.03735, + 0.01269 + ], + [ + 0.01321, + 0.08248, + -0.01929 + ], + [ + 0.01237, + 0.01593, + -0.01419 + ], + [ + 0.00027, + 0.07762, + 0.00362 + ] + ], + [ + [ + 0.00535, + 0.09915, + 0.02057 + ], + [ + -0.01048, + 0.00348, + -0.02003 + ], + [ + 0.02038, + 0.05674, + -0.01906 + ], + [ + -0.02382, + 0.08467, + -0.01369 + ], + [ + 0.00891, + 0.02845, + 0.01502 + ], + [ + -0.01737, + 0.04394, + -0.01686 + ], + [ + 0.01498, + 0.09232, + -0.02066 + ], + [ + -0.00218, + 0.06717, + 0.00628 + ], + [ + 0.01722, + 0.02052, + -0.02388 + ], + [ + 0.01373, + 0.00671, + 0.00156 + ] + ], + [ + [ + 0.01692, + 0.09514, + -0.01021 + ], + [ + -0.02117, + -0.00181, + -0.01261 + ], + [ + -0.01463, + 0.04746, + 0.01881 + ], + [ + 0.02529, + 0.00831, + -0.0068 + ], + [ + -0.01671, + 0.06707, + -0.02926 + ], + [ + -0.00603, + 0.00833, + 0.02114 + ], + [ + 0.01362, + 0.0465, + -0.00653 + ], + [ + -0.01052, + 0.07779, + 0.00235 + ], + [ + -0.01405, + 0.03121, + -0.01671 + ], + [ + -0.00321, + 0.0294, + 0.00566 + ] + ] + ], + "neighbours": [ + { + "J_Sec_Hair2_12": 39.49819143834376 + }, + { + "J_Sec_Hair1_12": 39.49819143834376, + "J_Sec_Hair3_12": 39.1444003614134 + }, + { + "J_Sec_Hair2_12": 39.1444003614134 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "J_Sec_Hair1_13" + ], + "tips": [ + [ + -0.01152, + 0.08995, + 0.00465 + ] + ], + "hulls": [ + [ + [ + -0.04045, + 0.06763, + 0.00296 + ], + [ + 0.04104, + 0.03863, + -0.00205 + ], + [ + -0.01976, + 0.00024, + 0.0052 + ], + [ + 0.00695, + 0.07097, + -0.00076 + ], + [ + -0.05262, + 0.02583, + 0.00729 + ], + [ + 0.03523, + 0.06718, + -0.00387 + ], + [ + 0.01979, + 0.02217, + 0.00226 + ], + [ + -0.01592, + 0.05624, + 0.00297 + ], + [ + -0.04328, + 0.04153, + 0.0058 + ], + [ + -0.03547, + 0.01986, + 0.00731 + ] + ] + ], + "neighbours": [ + {} + ] + } + ], + "twist": [], + "colliders": [ + { + "bone": "Hips", + "child": "Spine", + "from": 0.0, + "radius_head": 0.1498, + "radius_tail": 0.1498, + "radius": 0.1498, + "lid": true + }, + { + "bone": "Left leg", + "child": "Left knee", + "from": 0.1, + "radius_head": 0.102, + "radius_tail": 0.057, + "radius": 0.057 + }, + { + "bone": "Right leg", + "child": "Right knee", + "from": 0.1, + "radius_head": 0.102, + "radius_tail": 0.057, + "radius": 0.057 + }, + { + "bone": "Left knee", + "child": "Left ankle", + "from": 0.1, + "radius_head": 0.0883, + "radius_tail": 0.0474, + "radius": 0.0474 + }, + { + "bone": "Right knee", + "child": "Right ankle", + "from": 0.1, + "radius_head": 0.0883, + "radius_tail": 0.0474, + "radius": 0.0474 + } + ], + "weights_authored": true, + "driven_bones": [ + "Chest", + "Head", + "Hips", + "Left ankle", + "Left arm", + "Left elbow", + "Left knee", + "Left leg", + "Left shoulder", + "Left toe", + "Left wrist", + "Neck", + "Right ankle", + "Right arm", + "Right elbow", + "Right knee", + "Right leg", + "Right shoulder", + "Right toe", + "Right wrist", + "Spine" + ], + "surfaces": [ + { + "mesh": "Object_103", + "surface": 0, + "material": "N00_000_Hair_00_HAIR_Instance", + "class": "hair", + "detail": "hair", + "why": "material name says 'hair'", + "verts": 19753, + "textured": true, + "chain_share": { + "hair": 0.619 + } + }, + { + "mesh": "Object_104", + "surface": 0, + "material": "N00_000_00_FaceMouth_00_FACE_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 1053, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_105", + "surface": 0, + "material": "N00_000_00_EyeIris_00_EYE_Instance", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 212, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_106", + "surface": 0, + "material": "N00_000_00_EyeHighlight_00_EYE_Instance", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 26, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_107", + "surface": 0, + "material": "N00_000_00_Face_00_SKIN_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 2266, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_108", + "surface": 0, + "material": "N00_000_00_EyeWhite_00_EYE_Instance", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 290, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_109", + "surface": 0, + "material": "N00_000_00_FaceBrow_00_FACE_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 50, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_110", + "surface": 0, + "material": "N00_000_00_FaceEyelash_00_FACE_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 18, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_111", + "surface": 0, + "material": "N00_000_00_FaceEyeline_00_FACE_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 118, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_112", + "surface": 0, + "material": "N00_000_00_Body_00_SKIN_Instance", + "class": "body", + "detail": "skin", + "why": "material name says 'skin'", + "verts": 5870, + "textured": true, + "chain_share": { + "breast": 0.038 + } + }, + { + "mesh": "Object_113", + "surface": 0, + "material": "N00_001_03_Bottoms_01_CLOTH_Instance", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 742, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_114", + "surface": 0, + "material": "N00_004_01_Shoes_01_CLOTH_Instance", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 600, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_115", + "surface": 0, + "material": "N00_000_00_HairBack_00_HAIR_Instance", + "class": "hair", + "detail": "hair", + "why": "material name says 'hair'", + "verts": 760, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_116", + "surface": 0, + "material": "N00_002_02_Tops_01_CLOTH_Instance", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 512, + "textured": true, + "chain_share": { + "breast": 0.117 + } + }, + { + "mesh": "Object_117", + "surface": 0, + "material": "N00_010_01_Onepiece_00_CLOTH_Instance", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 52, + "textured": true, + "chain_share": { + "breast": 0.115 + } + } + ] +} \ No newline at end of file diff --git a/assets/characters/skins/aria_Image_0.png b/assets/characters/skins/aria_Image_0.png new file mode 100644 index 0000000..9f09547 Binary files /dev/null and b/assets/characters/skins/aria_Image_0.png differ diff --git a/assets/characters/skins/aria_Image_1.png b/assets/characters/skins/aria_Image_1.png new file mode 100644 index 0000000..c350b42 Binary files /dev/null and b/assets/characters/skins/aria_Image_1.png differ diff --git a/assets/characters/skins/aria_Image_10.png b/assets/characters/skins/aria_Image_10.png new file mode 100644 index 0000000..0ca5d26 Binary files /dev/null and b/assets/characters/skins/aria_Image_10.png differ diff --git a/assets/characters/skins/aria_Image_11.png b/assets/characters/skins/aria_Image_11.png new file mode 100644 index 0000000..6e597e3 Binary files /dev/null and b/assets/characters/skins/aria_Image_11.png differ diff --git a/assets/characters/skins/aria_Image_12.png b/assets/characters/skins/aria_Image_12.png new file mode 100644 index 0000000..74abd9e Binary files /dev/null and b/assets/characters/skins/aria_Image_12.png differ diff --git a/assets/characters/skins/aria_Image_13.png b/assets/characters/skins/aria_Image_13.png new file mode 100644 index 0000000..34f55cf Binary files /dev/null and b/assets/characters/skins/aria_Image_13.png differ diff --git a/assets/characters/skins/aria_Image_14.png b/assets/characters/skins/aria_Image_14.png new file mode 100644 index 0000000..01ef27b Binary files /dev/null and b/assets/characters/skins/aria_Image_14.png differ diff --git a/assets/characters/skins/aria_Image_2.png b/assets/characters/skins/aria_Image_2.png new file mode 100644 index 0000000..65949ca Binary files /dev/null and b/assets/characters/skins/aria_Image_2.png differ diff --git a/assets/characters/skins/aria_Image_3.png b/assets/characters/skins/aria_Image_3.png new file mode 100644 index 0000000..8a3c4a2 Binary files /dev/null and b/assets/characters/skins/aria_Image_3.png differ diff --git a/assets/characters/skins/aria_Image_4.png b/assets/characters/skins/aria_Image_4.png new file mode 100644 index 0000000..4db5f8c Binary files /dev/null and b/assets/characters/skins/aria_Image_4.png differ diff --git a/assets/characters/skins/aria_Image_5.png b/assets/characters/skins/aria_Image_5.png new file mode 100644 index 0000000..1c38583 Binary files /dev/null and b/assets/characters/skins/aria_Image_5.png differ diff --git a/assets/characters/skins/aria_Image_6.png b/assets/characters/skins/aria_Image_6.png new file mode 100644 index 0000000..c9b6829 Binary files /dev/null and b/assets/characters/skins/aria_Image_6.png differ diff --git a/assets/characters/skins/aria_Image_7.png b/assets/characters/skins/aria_Image_7.png new file mode 100644 index 0000000..4647df8 Binary files /dev/null and b/assets/characters/skins/aria_Image_7.png differ diff --git a/assets/characters/skins/aria_Image_8.png b/assets/characters/skins/aria_Image_8.png new file mode 100644 index 0000000..07fcff7 Binary files /dev/null and b/assets/characters/skins/aria_Image_8.png differ diff --git a/assets/characters/skins/aria_Image_9.png b/assets/characters/skins/aria_Image_9.png new file mode 100644 index 0000000..011b6bb Binary files /dev/null and b/assets/characters/skins/aria_Image_9.png differ diff --git a/assets/characters/skins/kiyoko.glb b/assets/characters/skins/kiyoko.glb new file mode 100644 index 0000000..a41e624 Binary files /dev/null and b/assets/characters/skins/kiyoko.glb differ diff --git a/assets/characters/skins/kiyoko.license.json b/assets/characters/skins/kiyoko.license.json new file mode 100644 index 0000000..e6a893e --- /dev/null +++ b/assets/characters/skins/kiyoko.license.json @@ -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" +} \ No newline at end of file diff --git a/assets/characters/skins/kiyoko.rig.json b/assets/characters/skins/kiyoko.rig.json new file mode 100644 index 0000000..cd91178 --- /dev/null +++ b/assets/characters/skins/kiyoko.rig.json @@ -0,0 +1,3274 @@ +{ + "roles": { + "hips": "J_Bip_C_Hips", + "head": "J_Adj_L_FaceEye", + "neck": "J_Bip_C_Neck", + "spine": [ + "J_Bip_C_Spine", + "J_Bip_C_Chest", + "J_Bip_C_UpperChest", + "J_Bip_C_Neck", + "J_Bip_C_Head", + "J_Adj_L_FaceEye" + ], + "shin.R": "J_Bip_R_LowerLeg", + "shin.L": "J_Bip_L_LowerLeg", + "thigh.L": "J_Bip_L_UpperLeg", + "thigh.R": "J_Bip_R_UpperLeg", + "shoulder.R": "J_Bip_R_Shoulder", + "shoulder.L": "J_Bip_L_Shoulder", + "forearm.L": "J_Bip_L_LowerArm", + "forearm.R": "J_Bip_R_LowerArm", + "upper_arm.R": "J_Bip_R_UpperArm", + "upper_arm.L": "J_Bip_L_UpperArm", + "toe.R": "J_Bip_R_ToeBase", + "toe.L": "J_Bip_L_ToeBase", + "foot.L": "J_Bip_L_Foot", + "foot.R": "J_Bip_R_Foot", + "hand.R": "J_Bip_R_Hand", + "hand.L": "J_Bip_L_Hand" + }, + "fingers": { + "thumb.L": [ + "J_Bip_L_Thumb1", + "J_Bip_L_Thumb2", + "J_Bip_L_Thumb3" + ], + "index.L": [ + "J_Bip_L_Index1", + "J_Bip_L_Index2", + "J_Bip_L_Index3" + ], + "middle.L": [ + "J_Bip_L_Middle1", + "J_Bip_L_Middle2", + "J_Bip_L_Middle3" + ], + "ring.L": [ + "J_Bip_L_Ring1", + "J_Bip_L_Ring2", + "J_Bip_L_Ring3" + ], + "pinky.L": [ + "J_Bip_L_Little1", + "J_Bip_L_Little2", + "J_Bip_L_Little3" + ], + "thumb.R": [ + "J_Bip_R_Thumb1", + "J_Bip_R_Thumb2", + "J_Bip_R_Thumb3" + ], + "index.R": [ + "J_Bip_R_Index1", + "J_Bip_R_Index2", + "J_Bip_R_Index3" + ], + "middle.R": [ + "J_Bip_R_Middle1", + "J_Bip_R_Middle2", + "J_Bip_R_Middle3" + ], + "ring.R": [ + "J_Bip_R_Ring1", + "J_Bip_R_Ring2", + "J_Bip_R_Ring3" + ], + "pinky.R": [ + "J_Bip_R_Little1", + "J_Bip_R_Little2", + "J_Bip_R_Little3" + ] + }, + "chains": [ + { + "class": "bust", + "root_parent": "J_Bip_C_UpperChest", + "bones": [ + "J_Sec_L_Bust1", + "J_Sec_L_Bust2" + ], + "tips": [ + [ + -0.02822, + 0.05487, + 0.00675 + ], + [ + -0.02822, + 0.05487, + 0.00675 + ] + ], + "hulls": [ + [ + [ + -0.06907, + 0.01709, + 0.03093 + ], + [ + 0.01313, + 0.06773, + -0.02415 + ], + [ + -0.0464, + 0.04364, + -0.02388 + ], + [ + -0.01596, + 0.05683, + 0.03057 + ], + [ + -0.04145, + 0.02998, + 0.04945 + ], + [ + -0.05482, + 0.0409, + 0.01062 + ], + [ + 0.01149, + 0.06713, + 0.01269 + ], + [ + -0.01336, + 0.06822, + -0.00635 + ], + [ + -0.00726, + 0.05979, + -0.04593 + ], + [ + -0.03102, + 0.0483, + -0.04574 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_L_Bust2": 1.1574203600849464, + "J_Sec_R_Bust1": 0.11661945163003545 + }, + { + "J_Sec_L_Bust1": 1.1574203600849464 + } + ] + }, + { + "class": "bust", + "root_parent": "J_Bip_C_UpperChest", + "bones": [ + "J_Sec_R_Bust1", + "J_Sec_R_Bust2" + ], + "tips": [ + [ + 0.02822, + 0.05487, + 0.00675 + ], + [ + 0.02822, + 0.05487, + 0.00675 + ] + ], + "hulls": [ + [ + [ + 0.06907, + 0.01709, + 0.03093 + ], + [ + -0.01313, + 0.06773, + -0.02415 + ], + [ + 0.0464, + 0.04364, + -0.02388 + ], + [ + 0.01596, + 0.05683, + 0.03057 + ], + [ + 0.04145, + 0.02998, + 0.04945 + ], + [ + 0.05482, + 0.0409, + 0.01062 + ], + [ + -0.01149, + 0.06713, + 0.01269 + ], + [ + 0.01336, + 0.06822, + -0.00635 + ], + [ + 0.00726, + 0.05979, + -0.04593 + ], + [ + 0.03102, + 0.0483, + -0.04574 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_R_Bust2": 1.1574203600849464, + "J_Sec_L_Bust1": 0.11661945163003545 + }, + { + "J_Sec_R_Bust1": 1.1574203600849464 + } + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_01", + "J_Sec_Hair2_01", + "J_Sec_Hair3_01" + ], + "tips": [ + [ + -0.00179, + 0.03486, + 0.00055 + ], + [ + -0.01116, + 0.03272, + 0.00086 + ], + [ + -0.01116, + 0.03272, + 0.00086 + ] + ], + "hulls": [ + [ + [ + -0.00846, + 0.02453, + 0.00427 + ], + [ + -0.00252, + 0.00336, + -0.00789 + ], + [ + -0.00858, + 0.00443, + 0.00697 + ], + [ + -0.00097, + 0.01453, + 0.00074 + ], + [ + -0.00426, + 0.02468, + -0.00724 + ], + [ + -0.0001, + 0.00419, + 0.00175 + ], + [ + -0.0085, + 0.01455, + 0.00554 + ], + [ + -0.00327, + 0.01394, + -0.00788 + ], + [ + -0.00215, + 0.02485, + 5e-05 + ] + ], + [ + [ + -0.01247, + 0.03034, + -0.00101 + ], + [ + -0.00214, + 0.00042, + -0.00073 + ], + [ + -0.00604, + 0.0106, + -0.00492 + ], + [ + -0.00989, + 0.02021, + 0.00049 + ], + [ + -0.00806, + 0.01005, + 0.00167 + ], + [ + -0.00397, + 0.00036, + -0.00646 + ], + [ + -0.007, + -0.00021, + 0.00274 + ], + [ + -0.00467, + 0.01074, + -0.00086 + ], + [ + -0.0089, + 0.02064, + -0.00308 + ], + [ + -0.00813, + 0.02077, + -0.0009 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_01": 1.806053266471615 + }, + { + "J_Sec_Hair1_01": 1.806053266471615 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_02", + "J_Sec_Hair2_02", + "J_Sec_Hair3_02" + ], + "tips": [ + [ + 0.0112, + 0.03153, + 0.00487 + ], + [ + -0.01067, + 0.02305, + -0.02196 + ], + [ + -0.01067, + 0.02305, + -0.02196 + ] + ], + "hulls": [ + [ + [ + 0.0016, + 0.04157, + -0.01528 + ], + [ + 0.00699, + 0.00051, + 0.01678 + ], + [ + -0.01119, + 0.0059, + -0.01882 + ], + [ + 0.00854, + 0.02338, + 0.00234 + ], + [ + 0.01364, + 0.01905, + 0.02425 + ], + [ + 0.00383, + 0.02047, + -0.01766 + ], + [ + -0.01111, + 0.0194, + -0.00462 + ], + [ + -8e-05, + 0.00405, + -0.0011 + ], + [ + -0.00373, + 0.03054, + -0.01763 + ], + [ + 0.00353, + 0.0308, + -0.00627 + ] + ], + [ + [ + -0.00745, + 0.02625, + -0.04543 + ], + [ + -0.02883, + 0.00742, + 0.0102 + ], + [ + 0.02313, + -0.00623, + -0.01403 + ], + [ + -0.00785, + 0.01457, + -0.01602 + ], + [ + 0.00493, + 0.00057, + -0.02812 + ], + [ + 0.00058, + 0.00063, + -0.00206 + ], + [ + 0.01191, + 0.00336, + -0.0137 + ], + [ + 0.0002, + 0.01312, + -0.03115 + ], + [ + 0.00719, + -0.00665, + -0.01942 + ], + [ + 0.01758, + 0.00074, + -0.02281 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_02": 9.416776602017269 + }, + { + "J_Sec_Hair1_02": 9.416776602017269 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_03", + "J_Sec_Hair2_03", + "J_Sec_Hair3_03" + ], + "tips": [ + [ + 0.01112, + 0.04107, + 0.00338 + ], + [ + 0.00799, + 0.02983, + -0.02835 + ], + [ + 0.00799, + 0.02983, + -0.02835 + ] + ], + "hulls": [ + [ + [ + -0.0201, + 0.0489, + 0.00678 + ], + [ + 0.03387, + 0.00448, + -0.00477 + ], + [ + -0.02309, + 0.00126, + 0.00401 + ], + [ + 0.01553, + 0.03579, + 0.00278 + ], + [ + -0.0101, + 0.02591, + -0.00442 + ], + [ + 0.01098, + 0.00463, + 0.00707 + ], + [ + 0.02709, + 0.01896, + 0.00995 + ], + [ + -0.00051, + 0.04773, + 0.00211 + ], + [ + -0.00605, + 0.00842, + -0.00208 + ], + [ + -0.02312, + 0.02504, + 0.00687 + ] + ], + [ + [ + 0.01449, + 0.05794, + -0.02204 + ], + [ + -0.00187, + -0.02794, + -0.0191 + ], + [ + -0.00591, + 0.02187, + 0.00675 + ], + [ + 0.00484, + 0.01429, + -0.02831 + ], + [ + 0.00503, + -0.00808, + -0.00465 + ], + [ + 0.00022, + 0.03427, + -0.01267 + ], + [ + 0.0103, + 0.03444, + -0.03176 + ], + [ + 0.0049, + 0.01219, + -0.00826 + ], + [ + -0.00244, + -0.01093, + -0.02778 + ], + [ + 0.00672, + 0.04546, + -0.02065 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_03": 28.652466934563993 + }, + { + "J_Sec_Hair1_03": 28.652466934563993 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_04", + "J_Sec_Hair2_04", + "J_Sec_Hair3_04" + ], + "tips": [ + [ + 0.01086, + 0.03113, + -0.00479 + ], + [ + 0.01071, + 0.02966, + -0.01083 + ], + [ + 0.01071, + 0.02966, + -0.01083 + ] + ], + "hulls": [ + [ + [ + -0.00945, + 0.03644, + 0.00941 + ], + [ + 0.02187, + -0.0017, + -0.01224 + ], + [ + 0.01478, + 0.02758, + -0.00153 + ], + [ + -0.00945, + 0.00935, + 0.00976 + ], + [ + 0.01038, + 0.00411, + 0.0024 + ], + [ + 0.02431, + 0.02073, + -0.01319 + ], + [ + -0.01538, + 0.02371, + 0.00337 + ], + [ + -0.0216, + 0.01668, + 0.01518 + ], + [ + 0.02096, + 0.01062, + -0.00436 + ], + [ + -0.00229, + 0.02614, + 0.00381 + ] + ], + [ + [ + 0.01085, + 0.03503, + -0.01287 + ], + [ + -0.00752, + 0.00495, + 0.0055 + ], + [ + 0.0142, + 0.00072, + -0.00944 + ], + [ + -0.00839, + 0.02898, + 0.00731 + ], + [ + 0.01006, + 0.01734, + -0.00898 + ], + [ + 0.00142, + 0.02481, + -0.0021 + ], + [ + 0.00507, + 0.00977, + -0.00136 + ], + [ + -0.00395, + 0.01829, + 0.00677 + ], + [ + -0.01318, + 0.01289, + 0.00909 + ], + [ + 0.00095, + 0.00791, + 0.00626 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_04": 9.739047031025505 + }, + { + "J_Sec_Hair1_04": 9.739047031025505 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_05", + "J_Sec_Hair2_05", + "J_Sec_Hair3_05" + ], + "tips": [ + [ + -0.00267, + 0.03995, + 2e-05 + ], + [ + 0.01236, + 0.03728, + 0.00232 + ], + [ + 0.01236, + 0.03728, + 0.00232 + ] + ], + "hulls": [ + [ + [ + 0.01342, + 0.03693, + 0.01143 + ], + [ + -0.01062, + -0.00285, + -0.00904 + ], + [ + -0.01069, + 0.03266, + -0.01642 + ], + [ + 0.02722, + 0.00967, + 0.02045 + ], + [ + 0.01016, + 0.00729, + 0.00108 + ], + [ + -0.00572, + 0.02245, + 0.00357 + ], + [ + 0.01374, + 0.01868, + 0.01467 + ], + [ + -0.00023, + 0.01371, + -0.00985 + ], + [ + -0.00337, + -0.00126, + 0.00426 + ], + [ + 0.00218, + 0.0344, + 0.00068 + ] + ], + [ + [ + 0.01231, + 0.03734, + 0.00225 + ], + [ + -0.0092, + 0.00498, + -0.0157 + ], + [ + 0.02241, + 0.00086, + 0.0176 + ], + [ + 0.00663, + 0.01264, + 0.00269 + ], + [ + -0.00152, + 0.01836, + -0.00391 + ], + [ + 0.00801, + 0.0264, + 0.00212 + ], + [ + 0.00515, + 0.00107, + 0.00152 + ], + [ + 0.02508, + 0.01179, + 0.01744 + ], + [ + -0.00541, + 0.00665, + -0.00533 + ], + [ + 0.0129, + 0.00733, + 0.00845 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_05": 12.136693552290065 + }, + { + "J_Sec_Hair1_05": 12.136693552290065 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_06", + "J_Sec_Hair2_06", + "J_Sec_Hair3_06" + ], + "tips": [ + [ + 0.00979, + 0.06992, + -0.00277 + ], + [ + 0.03558, + 0.05906, + 0.00267 + ], + [ + 0.03558, + 0.05906, + 0.00267 + ] + ], + "hulls": [ + [ + [ + -0.00796, + 0.07334, + -0.01193 + ], + [ + 0.00803, + 0.00715, + 0.03115 + ], + [ + -0.01274, + 0.01999, + -0.01263 + ], + [ + 0.02153, + 0.05703, + 0.02492 + ], + [ + 0.00966, + 0.04728, + -0.00447 + ], + [ + 0.00102, + 0.00127, + 0.00487 + ], + [ + 0.01548, + 0.02331, + 0.01261 + ], + [ + 0.00672, + 0.03904, + 0.02892 + ], + [ + -0.01272, + 0.04146, + -0.0089 + ], + [ + -0.01088, + -0.00143, + -0.01251 + ] + ], + [ + [ + 0.05446, + 0.06037, + 0.01606 + ], + [ + -0.01448, + 0.01371, + -0.00892 + ], + [ + 0.01615, + 0.01833, + 0.03113 + ], + [ + 0.01785, + 0.04612, + -0.00383 + ], + [ + 0.03933, + 0.03937, + 0.03083 + ], + [ + 0.00474, + 0.00067, + 0.01014 + ], + [ + 0.01234, + 0.02208, + -0.00204 + ], + [ + 0.03558, + 0.05902, + 0.00277 + ], + [ + 0.02745, + 0.02677, + 0.01376 + ], + [ + 0.00781, + -0.00083, + 0.03146 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_06": 17.151499337137746 + }, + { + "J_Sec_Hair1_06": 17.151499337137746 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_07", + "J_Sec_Hair2_07", + "J_Sec_Hair3_07" + ], + "tips": [ + [ + -0.01413, + 0.04253, + 0.0056 + ], + [ + 0.01383, + 0.03096, + -0.02833 + ], + [ + 0.01383, + 0.03096, + -0.02833 + ] + ], + "hulls": [ + [ + [ + -0.00417, + 0.04397, + -0.00148 + ], + [ + -0.0007, + 6e-05, + 0.00679 + ], + [ + -0.0121, + 0.0206, + 0.00982 + ], + [ + 0.00261, + 0.0234, + -0.00292 + ], + [ + -0.01812, + 0.03812, + 0.01188 + ], + [ + 0.0051, + 0.00909, + -0.00314 + ], + [ + -0.0076, + 0.01139, + 0.00272 + ], + [ + -0.00912, + 0.03704, + 0.00536 + ], + [ + -0.00468, + 0.02742, + 0.00401 + ], + [ + -0.00924, + 0.00382, + 0.00885 + ] + ], + [ + [ + 0.00322, + 0.03511, + -0.0422 + ], + [ + -0.00339, + -0.00787, + -0.00791 + ], + [ + 0.00161, + 0.00906, + -0.03076 + ], + [ + 0.00874, + 0.00816, + 0.00108 + ], + [ + 0.01385, + 0.03095, + -0.02832 + ], + [ + 0.00455, + 0.01593, + -0.01755 + ], + [ + -0.00295, + -7e-05, + -0.02029 + ], + [ + 0.00259, + 0.02129, + -0.03849 + ], + [ + 0.00314, + 0.00665, + -0.01192 + ], + [ + -0.00018, + 0.00311, + -0.00208 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_07": 12.95251969089636 + }, + { + "J_Sec_Hair1_07": 12.95251969089636 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_08", + "J_Sec_Hair2_08", + "J_Sec_Hair3_08" + ], + "tips": [ + [ + -0.01414, + 0.06968, + -0.00406 + ], + [ + -0.04212, + 0.05622, + 0.0006 + ], + [ + -0.04212, + 0.05622, + 0.0006 + ] + ], + "hulls": [ + [ + [ + 0.01293, + 0.07861, + -0.02833 + ], + [ + -0.01331, + 0.00442, + 0.01001 + ], + [ + 0.02195, + 0.02537, + -0.02522 + ], + [ + -0.02324, + 0.05659, + 0.00872 + ], + [ + 0.00267, + 0.03296, + 0.00146 + ], + [ + 0.01345, + -0.00146, + -0.01281 + ], + [ + -0.00786, + 0.06491, + -0.01448 + ], + [ + 0.01031, + 0.04654, + -0.02964 + ], + [ + -0.02007, + 0.03646, + -0.0058 + ], + [ + -0.00207, + 0.01709, + -0.01415 + ] + ], + [ + [ + -0.04208, + 0.05627, + 0.00061 + ], + [ + 0.0224, + 0.01875, + -0.02406 + ], + [ + -0.01816, + 0.00616, + 0.014 + ], + [ + -0.00929, + 0.04589, + -0.02215 + ], + [ + -0.03867, + 0.03033, + 0.02021 + ], + [ + -0.00358, + 0.01706, + -0.00987 + ], + [ + -0.0157, + 0.03652, + 0.00136 + ], + [ + -0.01592, + 0.02578, + 0.02254 + ], + [ + 0.00962, + 0.03631, + -0.02138 + ], + [ + -0.0233, + 0.05486, + -0.01258 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_08": 22.67897996497351 + }, + { + "J_Sec_Hair1_08": 22.67897996497351 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_09", + "J_Sec_Hair2_09", + "J_Sec_Hair3_09", + "J_Sec_Hair4_09" + ], + "tips": [ + [ + 0.00656, + 0.0474, + 0.00209 + ], + [ + 0.01706, + 0.0446, + 0.00299 + ], + [ + 0.02854, + 0.03812, + 0.00482 + ], + [ + 0.02854, + 0.03812, + 0.00482 + ] + ], + "hulls": [ + [ + [ + 0.0041, + 0.04654, + -0.04275 + ], + [ + -0.00212, + 0.00195, + 0.02043 + ], + [ + -0.01037, + 0.00453, + -0.03435 + ], + [ + 0.01098, + 0.04256, + 0.01272 + ], + [ + 0.00989, + 0.01657, + -0.00669 + ], + [ + 0.00547, + 0.02398, + -0.03392 + ], + [ + 0.01109, + 0.02125, + 0.02163 + ], + [ + -7e-05, + 0.04024, + -0.02371 + ], + [ + -0.00138, + 0.03938, + -0.00175 + ], + [ + -0.0025, + 0.01464, + -0.04513 + ] + ], + [ + [ + 0.01155, + 0.04899, + -0.02492 + ], + [ + 0.01373, + 0.01337, + 0.01953 + ], + [ + 0.00042, + 0.00972, + -0.04317 + ], + [ + 0.00737, + 0.03226, + -0.00042 + ], + [ + 0.00988, + 0.00928, + -0.00588 + ], + [ + 0.01089, + 0.02663, + -0.03046 + ], + [ + 0.01694, + 0.03448, + 0.01905 + ], + [ + -0.00177, + 0.00778, + -0.02453 + ], + [ + 0.00883, + 0.03371, + -0.01582 + ], + [ + -0.00258, + 0.0207, + -0.0326 + ] + ], + [ + [ + 0.02849, + 0.0382, + 0.00456 + ], + [ + -0.00149, + 0.00458, + -0.03428 + ], + [ + 0.00607, + 0.00195, + 0.00791 + ], + [ + 0.01332, + 0.02759, + -0.01492 + ], + [ + 0.017, + 0.0198, + 0.00588 + ], + [ + 0.0021, + 0.00722, + -0.01197 + ], + [ + -0.00011, + 0.01283, + -0.02387 + ], + [ + 0.02521, + 0.02306, + 0.0148 + ], + [ + 0.00731, + 0.01733, + -0.01594 + ], + [ + 0.01358, + 0.00607, + 0.01594 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_09": 6.085030456593955 + }, + { + "J_Sec_Hair1_09": 6.085030456593955, + "J_Sec_Hair3_09": 6.217334650162728 + }, + { + "J_Sec_Hair2_09": 6.217334650162728 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_10", + "J_Sec_Hair2_10", + "J_Sec_Hair3_10", + "J_Sec_Hair4_10" + ], + "tips": [ + [ + -0.00523, + 0.04681, + 0.00168 + ], + [ + -0.01541, + 0.04425, + 0.00372 + ], + [ + -0.02703, + 0.03779, + 0.00697 + ], + [ + -0.02703, + 0.03779, + 0.00697 + ] + ], + "hulls": [ + [ + [ + 0.00254, + 0.03996, + -0.03885 + ], + [ + -0.0085, + 0.00607, + 0.02523 + ], + [ + 0.01355, + -0.00332, + -0.03069 + ], + [ + 0.00262, + 0.04386, + 0.00354 + ], + [ + 0.00857, + 0.00125, + 0.00306 + ], + [ + -0.00882, + 0.02119, + -0.00174 + ], + [ + -0.00108, + 0.01702, + -0.03077 + ], + [ + -0.00035, + 0.02781, + 0.02349 + ], + [ + 0.00106, + 0.03774, + -0.01836 + ], + [ + -0.00997, + 0.04078, + 0.02213 + ] + ], + [ + [ + -0.02137, + 0.03805, + 0.02335 + ], + [ + 0.00232, + 0.01406, + -0.03706 + ], + [ + -0.00047, + 0.00152, + 0.02164 + ], + [ + -0.00768, + 0.03901, + -0.01181 + ], + [ + -0.00924, + 0.01479, + -0.00068 + ], + [ + -0.00484, + 0.03418, + -0.03234 + ], + [ + -0.00787, + 0.02115, + 0.02217 + ], + [ + 0.00087, + 0.01924, + -0.01671 + ], + [ + -0.00652, + 0.03796, + 0.0041 + ], + [ + 0.00204, + 0.01813, + 0.00269 + ] + ], + [ + [ + -0.03056, + 0.04392, + 0.00527 + ], + [ + 0.0016, + 0.0091, + -0.02955 + ], + [ + -0.00748, + 0.00801, + 0.01125 + ], + [ + -0.00885, + 0.02284, + -0.01325 + ], + [ + -0.01882, + 0.02575, + 0.00806 + ], + [ + -0.02055, + 0.04066, + -0.00934 + ], + [ + -0.02723, + 0.02881, + 0.0177 + ], + [ + 0.00206, + 0.00531, + -0.01729 + ], + [ + -0.00303, + 0.01298, + -0.00933 + ], + [ + -0.01558, + 0.01188, + 0.01886 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_10": 4.348037023628208 + }, + { + "J_Sec_Hair1_10": 4.348037023628208, + "J_Sec_Hair3_10": 4.307469985860632 + }, + { + "J_Sec_Hair2_10": 4.307469985860632 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_11", + "J_Sec_Hair2_11", + "J_Sec_Hair3_11", + "J_Sec_Hair4_11" + ], + "tips": [ + [ + 0.01001, + 0.05484, + -0.0104 + ], + [ + 0.00594, + 0.05596, + -0.00663 + ], + [ + 0.00284, + 0.05652, + -0.00375 + ], + [ + 0.00284, + 0.05652, + -0.00375 + ] + ], + "hulls": [ + [ + [ + -0.0336, + 0.05073, + -0.02585 + ], + [ + 0.03431, + 0.00309, + 0.02653 + ], + [ + 0.03101, + 0.04289, + -0.01762 + ], + [ + -0.00462, + -0.0013, + -0.01728 + ], + [ + -0.05014, + 0.00712, + -0.03009 + ], + [ + 0.00588, + 0.02005, + 0.0094 + ], + [ + -0.00129, + 0.04205, + -0.0166 + ], + [ + 0.03881, + 0.03532, + 0.01253 + ], + [ + 0.02742, + 0.00285, + -0.00672 + ], + [ + -0.02026, + 0.01388, + -0.03785 + ] + ], + [ + [ + 0.01152, + 0.05436, + -0.0143 + ], + [ + -0.03654, + 0.00851, + -0.01442 + ], + [ + 0.02489, + 0.00155, + 0.01241 + ], + [ + -0.00134, + 0.01164, + -0.01049 + ], + [ + -0.01899, + 0.03823, + -0.01455 + ], + [ + 0.02563, + 0.03094, + 0.0062 + ], + [ + -0.00124, + 0.04081, + 0.0012 + ], + [ + 0.02009, + 0.02067, + -0.0136 + ], + [ + -0.01384, + -0.00132, + -0.02476 + ], + [ + 0.0111, + 0.01937, + 0.01747 + ] + ], + [ + [ + 0.01809, + 0.06474, + 0.00451 + ], + [ + -0.00252, + -0.00034, + -0.00919 + ], + [ + -0.01443, + 0.04127, + -0.00749 + ], + [ + 0.02423, + 0.02907, + 0.00268 + ], + [ + 0.01752, + 0.00581, + 0.0013 + ], + [ + -0.01333, + 0.01982, + -0.00015 + ], + [ + 0.01209, + 0.04863, + -0.00886 + ], + [ + 0.00612, + 0.03733, + 0.00917 + ], + [ + -1e-05, + 0.02768, + -0.01425 + ], + [ + -0.01285, + 0.06118, + -0.00357 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_11": 20.1695800288937 + }, + { + "J_Sec_Hair1_11": 20.1695800288937, + "J_Sec_Hair3_11": 18.79479094472742 + }, + { + "J_Sec_Hair2_11": 18.79479094472742 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_12", + "J_Sec_Hair2_12", + "J_Sec_Hair3_12", + "J_Sec_Hair4_12" + ], + "tips": [ + [ + -0.0098, + 0.05611, + -0.01118 + ], + [ + -0.00567, + 0.05729, + -0.00697 + ], + [ + -0.0024, + 0.05789, + -0.00363 + ], + [ + -0.0024, + 0.05789, + -0.00363 + ] + ], + "hulls": [ + [ + [ + 0.035, + 0.05517, + -0.02733 + ], + [ + -0.03495, + 0.00451, + 0.02597 + ], + [ + 0.00651, + 0.00181, + -0.01847 + ], + [ + -0.03053, + 0.04456, + -0.01868 + ], + [ + 0.05194, + 0.01062, + -0.031 + ], + [ + -0.00549, + 0.02112, + 0.00883 + ], + [ + -0.02727, + 0.00374, + -0.00708 + ], + [ + -0.03845, + 0.03769, + 0.01136 + ], + [ + 0.00835, + 0.03947, + -0.0201 + ], + [ + -0.01009, + 0.05696, + 0.00016 + ] + ], + [ + [ + -0.01724, + 0.05565, + -0.0071 + ], + [ + 0.03655, + 0.01314, + -0.01521 + ], + [ + -0.02476, + 0.00201, + 0.01213 + ], + [ + 0.00092, + 0.01441, + -0.01105 + ], + [ + 0.01819, + 0.04209, + -0.01466 + ], + [ + -0.00506, + 0.0267, + 0.01481 + ], + [ + 0.0236, + 0.00453, + 0.00578 + ], + [ + -0.02433, + 0.01446, + -0.00795 + ], + [ + 0.01407, + 0.00225, + -0.02557 + ], + [ + 0.00066, + 0.04337, + 0.00041 + ] + ], + [ + [ + -0.00061, + 0.06864, + -0.00452 + ], + [ + -0.00632, + -0.00079, + -0.00813 + ], + [ + 0.00826, + 0.03388, + -0.01943 + ], + [ + -0.02358, + 0.03881, + 0.00234 + ], + [ + 0.0134, + 0.01709, + -0.00064 + ], + [ + 0.0104, + 0.04449, + 0.00057 + ], + [ + -0.00671, + 0.01917, + 0.00805 + ], + [ + -0.01297, + 0.01892, + -0.01268 + ], + [ + -0.01932, + 0.06422, + 0.00449 + ], + [ + -0.01306, + 0.04876, + -0.00903 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_12": 20.797115504793354 + }, + { + "J_Sec_Hair1_12": 20.797115504793354, + "J_Sec_Hair3_12": 20.753898724733332 + }, + { + "J_Sec_Hair2_12": 20.753898724733332 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_13", + "J_Sec_Hair2_13", + "J_Sec_Hair3_13" + ], + "tips": [ + [ + 0.02011, + 0.09181, + -0.00286 + ], + [ + 0.03975, + 0.05486, + -0.00267 + ], + [ + 0.03975, + 0.05486, + -0.00267 + ] + ], + "hulls": [ + [ + [ + 0.01278, + 0.09433, + 0.00743 + ], + [ + 0.00503, + -0.00394, + -0.02307 + ], + [ + 0.03257, + 0.04406, + -0.00514 + ], + [ + 0.01058, + 0.01845, + 0.00721 + ], + [ + 0.01238, + 0.07186, + -0.0156 + ], + [ + 0.01675, + 0.02279, + -0.02178 + ], + [ + 0.02799, + 0.0669, + 0.0055 + ], + [ + 0.0045, + -0.00404, + 0.00515 + ], + [ + 0.0101, + 0.04025, + -0.00087 + ], + [ + 0.03093, + 0.08206, + -0.01168 + ] + ], + [ + [ + 0.03347, + 0.06049, + -0.00477 + ], + [ + -0.01279, + 0.00778, + -0.00465 + ], + [ + -0.00957, + 0.05038, + 0.01515 + ], + [ + 0.02852, + 0.02198, + -0.0276 + ], + [ + -0.00084, + 0.03441, + -0.00966 + ], + [ + -0.00561, + 0.02163, + 0.01707 + ], + [ + 0.00605, + 0.05881, + -0.00897 + ], + [ + 0.00839, + -0.00286, + -0.00961 + ], + [ + 0.00835, + 0.06527, + 0.00993 + ], + [ + 0.01485, + 0.03519, + -0.02093 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_13": 28.349896885998273 + }, + { + "J_Sec_Hair1_13": 28.349896885998273 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_14", + "J_Sec_Hair2_14", + "J_Sec_Hair3_14" + ], + "tips": [ + [ + -0.02011, + 0.09181, + -0.00286 + ], + [ + -0.03975, + 0.05486, + -0.00267 + ], + [ + -0.03975, + 0.05486, + -0.00267 + ] + ], + "hulls": [ + [ + [ + -0.01256, + 0.0943, + 0.00048 + ], + [ + -0.00487, + -0.00404, + -0.02413 + ], + [ + -0.02955, + 0.03699, + 0.00229 + ], + [ + -0.01589, + 0.06506, + -0.01716 + ], + [ + -0.00892, + 0.0113, + 0.00738 + ], + [ + -0.01583, + 0.02999, + -0.0233 + ], + [ + -0.03173, + 0.067, + 0.00648 + ], + [ + -0.01225, + 0.0508, + 0.00085 + ], + [ + -0.02915, + 0.08183, + -0.00816 + ], + [ + -0.00797, + 0.03032, + 0.00049 + ] + ], + [ + [ + -0.02436, + 0.06451, + -0.00364 + ], + [ + -0.00762, + -0.00295, + -0.00432 + ], + [ + 0.01095, + 0.03578, + 0.01561 + ], + [ + -0.02764, + 0.02899, + -0.02757 + ], + [ + 0.00397, + 0.0245, + -0.01162 + ], + [ + 0.00189, + 0.06375, + 0.01059 + ], + [ + -0.004, + 0.05114, + -0.00914 + ], + [ + 0.0006, + 0.01537, + 0.0133 + ], + [ + 0.0135, + 0.00781, + -0.00586 + ], + [ + -0.03979, + 0.05491, + -0.00254 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_14": 28.360807644433685 + }, + { + "J_Sec_Hair1_14": 28.360807644433685 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_15", + "J_Sec_Hair2_15", + "J_Sec_Hair3_15" + ], + "tips": [ + [ + -0.01211, + 0.05321, + 0.01422 + ], + [ + -0.01309, + 0.05293, + 0.01456 + ], + [ + -0.01309, + 0.05293, + 0.01456 + ] + ], + "hulls": [ + [ + [ + -0.0267, + 0.05046, + -0.00606 + ], + [ + -0.00062, + 0.00414, + -0.00789 + ], + [ + -0.00667, + 0.03155, + 0.00125 + ], + [ + -0.01534, + 0.01553, + -0.00251 + ], + [ + -0.013, + 0.04884, + 0.00424 + ], + [ + -0.02028, + 0.03313, + -0.00813 + ], + [ + -0.01922, + 0.03774, + 0.00385 + ], + [ + -0.00317, + 0.01852, + -0.00357 + ], + [ + -0.02513, + 0.0499, + 0.0063 + ], + [ + -0.01121, + 0.02662, + -0.00923 + ] + ], + [ + [ + -0.03068, + 0.05762, + 0.02615 + ], + [ + -0.0165, + 0.0015, + -0.01958 + ], + [ + 0.00662, + 0.03993, + -0.00269 + ], + [ + -0.03151, + 0.03456, + -0.00473 + ], + [ + -0.00954, + 0.05841, + 0.00746 + ], + [ + -0.02504, + 0.01517, + -0.00152 + ], + [ + -0.02627, + 0.04302, + 0.01308 + ], + [ + -0.02356, + 0.04756, + -0.00138 + ], + [ + -0.01502, + 0.05269, + 0.02041 + ], + [ + -0.02537, + 0.02337, + -0.01417 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_15": 26.225395679503343 + }, + { + "J_Sec_Hair1_15": 26.225395679503343 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_16", + "J_Sec_Hair2_16", + "J_Sec_Hair3_16" + ], + "tips": [ + [ + 0.01211, + 0.05321, + 0.01422 + ], + [ + 0.01309, + 0.05293, + 0.01456 + ], + [ + 0.01309, + 0.05293, + 0.01456 + ] + ], + "hulls": [ + [ + [ + 0.02113, + 0.05354, + -0.00554 + ], + [ + 0.00076, + 0.00423, + -0.00969 + ], + [ + 0.01547, + 0.02449, + 0.00187 + ], + [ + 0.00814, + 0.04121, + 0.00081 + ], + [ + 0.01089, + 0.01686, + -0.0105 + ], + [ + 0.01191, + 0.00639, + -0.0002 + ], + [ + 0.02305, + 0.04065, + 0.00039 + ], + [ + 0.00967, + 0.0323, + -0.00837 + ], + [ + 0.00387, + 0.02312, + -0.00284 + ], + [ + 0.01576, + 0.0526, + 0.00473 + ] + ], + [ + [ + 0.03068, + 0.05762, + 0.02615 + ], + [ + 0.01647, + 0.0008, + -0.01661 + ], + [ + -0.00663, + 0.03986, + -0.00258 + ], + [ + 0.03067, + 0.03647, + -0.00831 + ], + [ + 0.00894, + 0.0606, + 0.01156 + ], + [ + 0.02504, + 0.01517, + -0.00152 + ], + [ + 0.03677, + 0.04015, + 0.01336 + ], + [ + 0.02325, + 0.05038, + 0.00229 + ], + [ + 0.00499, + 0.04553, + 0.01218 + ], + [ + 0.02024, + 0.01552, + -0.01657 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_16": 26.322509970647936 + }, + { + "J_Sec_Hair1_16": 26.322509970647936 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_17", + "J_Sec_Hair2_17", + "J_Sec_Hair3_17" + ], + "tips": [ + [ + 0.02348, + 0.0957, + -0.02381 + ], + [ + -0.00217, + 0.10077, + -0.01388 + ], + [ + -0.00217, + 0.10077, + -0.01388 + ] + ], + "hulls": [ + [ + [ + 0.05658, + 0.08838, + -0.03346 + ], + [ + 0.00273, + -0.00065, + -5e-05 + ], + [ + 0.00587, + 0.05197, + -0.03052 + ], + [ + 0.02929, + 0.03537, + 0.00239 + ], + [ + 0.01178, + 0.09133, + -0.04513 + ], + [ + 0.02602, + 0.08072, + -0.00753 + ], + [ + 0.04559, + 0.05799, + -0.02396 + ], + [ + 0.01376, + 0.02351, + -0.02037 + ], + [ + 0.02303, + 0.07156, + -0.03101 + ], + [ + 0.03541, + 0.0901, + -0.04576 + ] + ], + [ + [ + -0.00135, + 0.11116, + -0.01126 + ], + [ + 0.02807, + 0.00059, + 0.01304 + ], + [ + -0.00773, + 0.04692, + -0.03614 + ], + [ + 0.03907, + 0.07185, + -0.02125 + ], + [ + 0.01188, + 0.04645, + 0.01195 + ], + [ + -0.01439, + 0.00065, + -0.00596 + ], + [ + 0.01794, + 0.01334, + -0.02856 + ], + [ + -0.02031, + 0.08883, + -0.02963 + ], + [ + 0.04174, + 0.03605, + -0.01935 + ], + [ + 0.00605, + 0.07751, + 0.00105 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_17": 20.076729074867217 + }, + { + "J_Sec_Hair1_17": 20.076729074867217 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "J_Bip_C_Head", + "bones": [ + "J_Sec_Hair1_18", + "J_Sec_Hair2_18", + "J_Sec_Hair3_18" + ], + "tips": [ + [ + -0.02373, + 0.0961, + -0.02398 + ], + [ + 0.00214, + 0.10123, + -0.01396 + ], + [ + 0.00214, + 0.10123, + -0.01396 + ] + ], + "hulls": [ + [ + [ + -0.05372, + 0.0887, + -0.03821 + ], + [ + -0.00155, + 0.0015, + 0.0044 + ], + [ + -0.0045, + 0.05349, + -0.02979 + ], + [ + -0.04112, + 0.0547, + -0.00352 + ], + [ + -0.00889, + 0.09313, + -0.04332 + ], + [ + -0.02936, + 0.09161, + -0.00708 + ], + [ + -0.02751, + 0.02093, + -0.01345 + ], + [ + 0.00019, + 0.02767, + -0.00731 + ], + [ + -0.02593, + 0.07379, + -0.03643 + ], + [ + -0.05391, + 0.08442, + -0.01154 + ] + ], + [ + [ + 0.00123, + 0.11161, + -0.01115 + ], + [ + -0.03379, + -0.00147, + 0.00928 + ], + [ + 0.01417, + 0.03707, + -0.03033 + ], + [ + -0.04251, + 0.06227, + -0.012 + ], + [ + -0.00944, + 0.03602, + 0.01124 + ], + [ + 0.01272, + 0.00165, + -0.00377 + ], + [ + 0.01934, + 0.07901, + -0.0333 + ], + [ + -0.03593, + 0.02393, + -0.02433 + ], + [ + -0.00213, + 0.07633, + 0.00866 + ], + [ + -0.03081, + 0.09597, + -0.01973 + ] + ], + [] + ], + "neighbours": [ + { + "J_Sec_Hair2_18": 20.05058973935537 + }, + { + "J_Sec_Hair1_18": 20.05058973935537 + }, + {} + ] + } + ], + "twist": [], + "colliders": [ + { + "bone": "J_Bip_C_Hips", + "child": "J_Bip_C_Spine", + "from": 0.0, + "radius_head": 0.1447, + "radius_tail": 0.1447, + "radius": 0.1447, + "lid": true + }, + { + "bone": "J_Bip_L_UpperLeg", + "child": "J_Bip_L_LowerLeg", + "from": 0.1, + "radius_head": 0.0974, + "radius_tail": 0.0554, + "radius": 0.0554 + }, + { + "bone": "J_Bip_R_UpperLeg", + "child": "J_Bip_R_LowerLeg", + "from": 0.1, + "radius_head": 0.0974, + "radius_tail": 0.0554, + "radius": 0.0554 + }, + { + "bone": "J_Bip_L_LowerLeg", + "child": "J_Bip_L_Foot", + "from": 0.1, + "radius_head": 0.0922, + "radius_tail": 0.0493, + "radius": 0.0493 + }, + { + "bone": "J_Bip_R_LowerLeg", + "child": "J_Bip_R_Foot", + "from": 0.1, + "radius_head": 0.0922, + "radius_tail": 0.0493, + "radius": 0.0493 + } + ], + "weights_authored": true, + "driven_bones": [ + "J_Adj_L_FaceEye", + "J_Bip_C_Chest", + "J_Bip_C_Head", + "J_Bip_C_Hips", + "J_Bip_C_Neck", + "J_Bip_C_Spine", + "J_Bip_C_UpperChest", + "J_Bip_L_Foot", + "J_Bip_L_Hand", + "J_Bip_L_LowerArm", + "J_Bip_L_LowerLeg", + "J_Bip_L_Shoulder", + "J_Bip_L_ToeBase", + "J_Bip_L_UpperArm", + "J_Bip_L_UpperLeg", + "J_Bip_R_Foot", + "J_Bip_R_Hand", + "J_Bip_R_LowerArm", + "J_Bip_R_LowerLeg", + "J_Bip_R_Shoulder", + "J_Bip_R_ToeBase", + "J_Bip_R_UpperArm", + "J_Bip_R_UpperLeg", + "Root" + ], + "surfaces": [ + { + "mesh": "Object_10", + "surface": 0, + "material": "N00_001_03_Bottoms_01_CLOTH_Instance", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 742, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_11", + "surface": 0, + "material": "N00_001_01_Shoes_01_CLOTH_Instance", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 410, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_13", + "surface": 0, + "material": "N00_000_00_FaceMouth_00_FACE_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 1073, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_14", + "surface": 0, + "material": "N00_000_00_EyeIris_00_EYE_Instance", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 268, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_15", + "surface": 0, + "material": "N00_000_00_EyeHighlight_00_EYE_Instance", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 200, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_16", + "surface": 0, + "material": "N00_000_00_Face_00_SKIN_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 2266, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_17", + "surface": 0, + "material": "N00_000_00_EyeWhite_00_EYE_Instance", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 290, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_18", + "surface": 0, + "material": "N00_000_00_FaceBrow_00_FACE_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 52, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_19", + "surface": 0, + "material": "N00_000_00_FaceEyeline_00_FACE_Instance", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 168, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_21", + "surface": 0, + "material": "N00_000_Hair_00_HAIR_Instance", + "class": "hair", + "detail": "hair", + "why": "material name says 'hair'", + "verts": 22432, + "textured": true, + "chain_share": { + "hair": 0.281 + } + }, + { + "mesh": "Object_7", + "surface": 0, + "material": "N00_000_00_Body_00_SKIN_Instance", + "class": "body", + "detail": "skin", + "why": "material name says 'skin'", + "verts": 5103, + "textured": true, + "chain_share": { + "bust": 0.001 + } + }, + { + "mesh": "Object_8", + "surface": 0, + "material": "N00_000_00_HairBack_00_HAIR_Instance", + "class": "hair", + "detail": "hair", + "why": "material name says 'hair'", + "verts": 700, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_9", + "surface": 0, + "material": "N00_007_01_Tops_01_CLOTH_Instance", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 814, + "textured": true, + "chain_share": { + "bust": 0.061 + } + } + ] +} \ No newline at end of file diff --git a/assets/characters/skins/kiyoko_Image_0.png b/assets/characters/skins/kiyoko_Image_0.png new file mode 100644 index 0000000..6422ae2 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_0.png differ diff --git a/assets/characters/skins/kiyoko_Image_1.png b/assets/characters/skins/kiyoko_Image_1.png new file mode 100644 index 0000000..13e0d06 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_1.png differ diff --git a/assets/characters/skins/kiyoko_Image_10.png b/assets/characters/skins/kiyoko_Image_10.png new file mode 100644 index 0000000..1830c02 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_10.png differ diff --git a/assets/characters/skins/kiyoko_Image_11.png b/assets/characters/skins/kiyoko_Image_11.png new file mode 100644 index 0000000..8241d50 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_11.png differ diff --git a/assets/characters/skins/kiyoko_Image_12.png b/assets/characters/skins/kiyoko_Image_12.png new file mode 100644 index 0000000..e7504ab Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_12.png differ diff --git a/assets/characters/skins/kiyoko_Image_13.png b/assets/characters/skins/kiyoko_Image_13.png new file mode 100644 index 0000000..49d84d9 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_13.png differ diff --git a/assets/characters/skins/kiyoko_Image_14.png b/assets/characters/skins/kiyoko_Image_14.png new file mode 100644 index 0000000..9d741a2 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_14.png differ diff --git a/assets/characters/skins/kiyoko_Image_15.png b/assets/characters/skins/kiyoko_Image_15.png new file mode 100644 index 0000000..51857f6 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_15.png differ diff --git a/assets/characters/skins/kiyoko_Image_16.png b/assets/characters/skins/kiyoko_Image_16.png new file mode 100644 index 0000000..4e480e5 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_16.png differ diff --git a/assets/characters/skins/kiyoko_Image_17.png b/assets/characters/skins/kiyoko_Image_17.png new file mode 100644 index 0000000..aaecbc5 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_17.png differ diff --git a/assets/characters/skins/kiyoko_Image_18.png b/assets/characters/skins/kiyoko_Image_18.png new file mode 100644 index 0000000..505f953 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_18.png differ diff --git a/assets/characters/skins/kiyoko_Image_19.png b/assets/characters/skins/kiyoko_Image_19.png new file mode 100644 index 0000000..ff84526 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_19.png differ diff --git a/assets/characters/skins/kiyoko_Image_2.png b/assets/characters/skins/kiyoko_Image_2.png new file mode 100644 index 0000000..4bbe4eb Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_2.png differ diff --git a/assets/characters/skins/kiyoko_Image_20.png b/assets/characters/skins/kiyoko_Image_20.png new file mode 100644 index 0000000..f06d0c3 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_20.png differ diff --git a/assets/characters/skins/kiyoko_Image_3.png b/assets/characters/skins/kiyoko_Image_3.png new file mode 100644 index 0000000..296717d Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_3.png differ diff --git a/assets/characters/skins/kiyoko_Image_4.png b/assets/characters/skins/kiyoko_Image_4.png new file mode 100644 index 0000000..bc420f8 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_4.png differ diff --git a/assets/characters/skins/kiyoko_Image_5.png b/assets/characters/skins/kiyoko_Image_5.png new file mode 100644 index 0000000..4edc525 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_5.png differ diff --git a/assets/characters/skins/kiyoko_Image_6.png b/assets/characters/skins/kiyoko_Image_6.png new file mode 100644 index 0000000..6ba30d9 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_6.png differ diff --git a/assets/characters/skins/kiyoko_Image_7.png b/assets/characters/skins/kiyoko_Image_7.png new file mode 100644 index 0000000..cddd94b Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_7.png differ diff --git a/assets/characters/skins/kiyoko_Image_8.png b/assets/characters/skins/kiyoko_Image_8.png new file mode 100644 index 0000000..bbd1c4e Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_8.png differ diff --git a/assets/characters/skins/kiyoko_Image_9.png b/assets/characters/skins/kiyoko_Image_9.png new file mode 100644 index 0000000..c350b42 Binary files /dev/null and b/assets/characters/skins/kiyoko_Image_9.png differ diff --git a/assets/characters/skins/mannequin.glb b/assets/characters/skins/mannequin.glb new file mode 100644 index 0000000..c6e997a Binary files /dev/null and b/assets/characters/skins/mannequin.glb differ diff --git a/assets/characters/skins/mannequin.license.json b/assets/characters/skins/mannequin.license.json new file mode 100644 index 0000000..4ac3c59 --- /dev/null +++ b/assets/characters/skins/mannequin.license.json @@ -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." +} \ No newline at end of file diff --git a/assets/characters/skins/mannequin.rig.json b/assets/characters/skins/mannequin.rig.json new file mode 100644 index 0000000..da5493e --- /dev/null +++ b/assets/characters/skins/mannequin.rig.json @@ -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": {} + } + ] +} \ No newline at end of file diff --git a/assets/characters/skins/miku.glb b/assets/characters/skins/miku.glb index df9ea41..8523280 100644 Binary files a/assets/characters/skins/miku.glb and b/assets/characters/skins/miku.glb differ diff --git a/assets/characters/skins/miku.rig.json b/assets/characters/skins/miku.rig.json new file mode 100644 index 0000000..c8f9d15 --- /dev/null +++ b/assets/characters/skins/miku.rig.json @@ -0,0 +1,229 @@ +{ + "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.L": "DEF-upper_arm.L", + "upper_arm.R": "DEF-upper_arm.R", + "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.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": {} + } + ] +} \ No newline at end of file diff --git a/assets/characters/skins/momo.glb b/assets/characters/skins/momo.glb new file mode 100644 index 0000000..bf661e3 Binary files /dev/null and b/assets/characters/skins/momo.glb differ diff --git a/assets/characters/skins/momo.license.json b/assets/characters/skins/momo.license.json new file mode 100644 index 0000000..f0bc2e1 --- /dev/null +++ b/assets/characters/skins/momo.license.json @@ -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" +} \ No newline at end of file diff --git a/assets/characters/skins/momo.rig.json b/assets/characters/skins/momo.rig.json new file mode 100644 index 0000000..44e2a4f --- /dev/null +++ b/assets/characters/skins/momo.rig.json @@ -0,0 +1,1927 @@ +{ + "roles": { + "hips": "Hips", + "head": "Unused_Noname_010", + "neck": "Neck", + "spine": [ + "Spine", + "Chest", + "Neck", + "Head", + "Unused_Noname_004", + "Unused_Noname_010" + ], + "shoulder.L": "Left shoulder", + "shoulder.R": "Right shoulder", + "foot.L": "Left ankle", + "foot.R": "Right ankle", + "hand.L": "Left wrist", + "hand.R": "Right wrist", + "forearm.R": "Right elbow", + "forearm.L": "Left elbow", + "shin.R": "Right knee", + "shin.L": "Left knee", + "toe.R": "Toes4_R", + "toe.L": "Toes4_L", + "upper_arm.R": "Right arm", + "upper_arm.L": "Left arm", + "thigh.L": "Left leg", + "thigh.R": "Right leg" + }, + "fingers": { + "thumb.L": [ + "Thumb0_L", + "Thumb1_L", + "Thumb2_L" + ], + "index.L": [ + "IndexFinger1_L", + "IndexFinger2_L", + "IndexFinger3_L" + ], + "middle.L": [ + "MiddleFinger1_L", + "MiddleFinger2_L", + "MiddleFinger3_L" + ], + "ring.L": [ + "RingFinger1_L", + "RingFinger2_L", + "RingFinger3_L" + ], + "pinky.L": [ + "LittleFinger1_L", + "LittleFinger2_L", + "LittleFinger3_L" + ], + "thumb.R": [ + "Thumb0_R", + "Thumb1_R", + "Thumb2_R" + ], + "index.R": [ + "IndexFinger1_R", + "IndexFinger2_R", + "IndexFinger3_R" + ], + "middle.R": [ + "MiddleFinger1_R", + "MiddleFinger2_R", + "MiddleFinger3_R" + ], + "ring.R": [ + "RingFinger1_R", + "RingFinger2_R", + "RingFinger3_R" + ], + "pinky.R": [ + "LittleFinger1_R", + "LittleFinger2_R", + "LittleFinger3_R" + ] + }, + "chains": [ + { + "class": "bust", + "root_parent": "Chest", + "bones": [ + "Bust03_L_001", + "Bust03_L_001.seg1", + "Bust03_L_001.seg2", + "Bust03_L_001.seg3" + ], + "tips": [ + [ + -0.0, + 0.00499, + 0.0 + ], + [ + -0.0, + 0.00499, + -0.0 + ], + [ + -0.0, + 0.00499, + 0.0 + ], + [ + -0.04786, + 0.03009, + -0.00987 + ] + ], + "hulls": [ + [ + [ + 0.04633, + -0.04498, + -0.01881 + ], + [ + 0.04429, + 0.0312, + 0.0252 + ], + [ + -0.03544, + -0.03722, + -0.02393 + ], + [ + -0.01034, + -0.00311, + 0.01941 + ], + [ + 0.03922, + -0.01299, + 0.01519 + ], + [ + 0.00226, + -0.03668, + -0.00227 + ], + [ + -0.00221, + -0.04468, + -0.03252 + ], + [ + 0.02021, + 0.01297, + 0.02357 + ], + [ + -0.02483, + -0.02224, + 0.00711 + ], + [ + 0.01419, + -0.01764, + 0.01819 + ] + ], + [ + [ + -0.03304, + -0.0253, + -0.03656 + ], + [ + 0.02219, + 0.02698, + 0.02558 + ], + [ + -0.01842, + -0.01287, + 0.01309 + ], + [ + -0.03167, + -0.02281, + -0.01434 + ], + [ + 0.01486, + 0.01924, + 0.02155 + ], + [ + -0.0281, + -0.01998, + -0.00439 + ], + [ + -0.03316, + -0.0247, + -0.02601 + ], + [ + -0.02363, + -0.01653, + 0.00511 + ] + ], + [ + [ + -0.04191, + -0.02902, + -0.00775 + ], + [ + 0.02138, + 0.02973, + 0.01524 + ], + [ + -0.01001, + 0.00052, + 0.02539 + ], + [ + 0.00623, + 0.01154, + 0.02665 + ], + [ + -0.00684, + 0.00428, + 0.02012 + ], + [ + 0.00366, + 0.00932, + 0.02186 + ] + ], + [ + [ + -0.04767, + -0.0252, + -0.03253 + ], + [ + 0.01504, + 0.04595, + 0.01354 + ], + [ + -0.0166, + -0.00573, + 0.01624 + ], + [ + -0.03357, + 0.02518, + -0.02554 + ], + [ + -0.01636, + 0.03061, + 0.00722 + ], + [ + -0.04063, + -0.0104, + -0.00069 + ], + [ + -0.0493, + 0.00465, + -0.0215 + ], + [ + 0.01032, + 0.0206, + 0.01634 + ], + [ + -0.03573, + 0.01456, + 0.00058 + ], + [ + -0.00267, + 0.04461, + -0.00642 + ] + ] + ], + "neighbours": [ + { + "Bust02_L.seg3": 10.684019018217892, + "Bust03_L_001.seg1": 0.39247250024091307, + "Bust01_L_001.seg1": 4.800724986178796, + "Bust02_L": 6.635007128308069, + "Bust01_L_001": 1.3835900670950232, + "Bust02_L.seg2": 1.1628704178966145, + "Bust02_L.seg1": 1.2647811147863781, + "Bust01_L_001.seg2": 1.6922467906349197 + }, + { + "Bust03_L_001.seg2": 0.7403938854892498, + "Bust02_L.seg3": 0.5296733698751332, + "Bust03_L_001": 0.39247250024091307, + "Bust02_L": 0.6099544862060277, + "Bust01_L_001": 0.3548198778082869, + "Bust02_L.seg1": 0.051393940012049555 + }, + { + "Bust03_L_001.seg1": 0.7403938854892498, + "Bust02_L.seg3": 0.4899941747474428, + "Bust03_L_001.seg3": 1.023675833835116, + "Bust02_L": 0.3201858651144034, + "Bust01_L_001": 0.26777733789898783, + "Bust02_L.seg1": 0.008267936081229 + }, + { + "Bust02_L": 7.855898180459636, + "Bust01_L_001": 8.371310126734542, + "Bust02_L.seg2": 1.5247301951670538, + "Bust02_L.seg1": 1.282291840066493, + "Bust02_L.seg3": 7.879450940680849, + "Bust03_L_001.seg2": 1.023675833835116 + } + ] + }, + { + "class": "bust", + "root_parent": "Chest", + "bones": [ + "Bust02_L", + "Bust02_L.seg1", + "Bust02_L.seg2", + "Bust02_L.seg3" + ], + "tips": [ + [ + 0.0, + 0.00671, + 0.0 + ], + [ + 0.0, + 0.00671, + 0.0 + ], + [ + -0.0, + 0.00671, + -0.0 + ], + [ + 0.0039, + 0.01862, + 0.0891 + ] + ], + "hulls": [ + [ + [ + -0.05329, + -0.06204, + 0.01442 + ], + [ + 0.03808, + 0.05407, + -0.02505 + ], + [ + 0.04119, + -0.04707, + -0.04114 + ], + [ + -0.00491, + -0.0594, + -0.0193 + ], + [ + 0.05435, + 0.00526, + -0.03349 + ], + [ + 0.03347, + -0.01547, + -0.04407 + ], + [ + -0.03478, + -0.04433, + 0.03204 + ], + [ + -0.02065, + -0.0593, + 0.00525 + ], + [ + 0.04598, + 0.03063, + -0.04143 + ], + [ + 0.01604, + -0.0615, + -0.03614 + ] + ], + [ + [ + -0.03762, + -0.04372, + 0.03863 + ], + [ + 0.04338, + 0.03738, + -0.02103 + ], + [ + 0.04475, + 0.01715, + -0.01642 + ], + [ + 0.0376, + 0.03055, + -0.01704 + ] + ], + [ + [ + -0.04425, + -0.03467, + 0.04791 + ], + [ + 0.03636, + 0.04794, + -0.01339 + ], + [ + 0.03518, + 0.0335, + -0.00628 + ], + [ + -0.04076, + -0.04296, + 0.04344 + ], + [ + 0.04017, + 0.03788, + -0.00918 + ] + ], + [ + [ + -0.03022, + -0.01453, + 0.07085 + ], + [ + 0.03357, + 0.04759, + -0.00158 + ], + [ + 0.02215, + 0.01615, + 0.05052 + ], + [ + -0.03306, + -0.04202, + 0.04804 + ], + [ + 0.03502, + 0.02209, + 0.01952 + ], + [ + -0.00246, + 0.01072, + 0.0669 + ], + [ + 0.02175, + 0.04171, + 0.0353 + ], + [ + -0.00812, + -0.01468, + 0.05663 + ], + [ + 0.04415, + 0.02105, + -0.00302 + ], + [ + -0.04002, + -0.02482, + 0.05624 + ] + ] + ], + "neighbours": [ + { + "Bust02_R.seg1": 0.005960952082285087, + "Bust02_R": 3.915824087943572, + "Bust01_R_001": 0.31005864655387483, + "Bust02_L.seg1": 0.4461314780366022, + "Bust01_L_001": 6.613423885382499, + "Bust03_L_001.seg3": 7.855898180459636, + "Bust01_L_001.seg1": 4.352280333812645, + "Bust01_L_001.seg2": 0.8579342939684047, + "Bust03_L_001": 6.635007128308069, + "Bust03_L_001.seg2": 0.3201858651144034, + "Bust03_L_001.seg1": 0.6099544862060277 + }, + { + "Bust02_L.seg2": 0.38188457635027917, + "Bust02_R": 0.3747821533537301, + "Bust02_L": 0.4461314780366022, + "Bust01_L_001": 0.5235603986584445, + "Bust03_L_001.seg3": 1.282291840066493, + "Bust01_L_001.seg1": 0.5806351635478039, + "Bust01_L_001.seg2": 0.4524824916068204, + "Bust03_L_001.seg2": 0.008267936081229, + "Bust03_L_001.seg1": 0.051393940012049555, + "Bust03_L_001": 1.2647811147863781, + "Bust02_R.seg1": 0.05764958974084089, + "Bust01_R_001": 0.025810371362056328 + }, + { + "Bust02_L.seg3": 0.49236435356060004, + "Bust02_R": 0.41566034136591967, + "Bust02_R.seg1": 0.09971818586097242, + "Bust02_L.seg1": 0.38188457635027917, + "Bust01_L_001": 0.6073705560446581, + "Bust03_L_001.seg3": 1.5247301951670538, + "Bust01_L_001.seg1": 0.45601591565223715, + "Bust01_L_001.seg2": 0.5283447766357731, + "Bust03_L_001": 1.1628704178966145, + "Bust02_R.seg2": 0.022343147561200094, + "Bust01_R_001": 0.011205419963657398 + }, + { + "Bust02_L.seg2": 0.49236435356060004, + "Bust02_R": 0.45644401532108525, + "Bust02_R.seg1": 0.48457604242424607, + "Bust01_L_001": 6.729600693488217, + "Bust03_L_001.seg3": 7.879450940680849, + "Bust03_L_001.seg2": 0.4899941747474428, + "Bust03_L_001.seg1": 0.5296733698751332, + "Bust03_L_001": 10.684019018217892, + "Bust01_L_001.seg1": 3.965650537698678, + "Bust01_L_001.seg2": 3.4417638093538288, + "Bust02_R.seg2": 0.16906718151336297, + "Bust02_R.seg3": 0.05393812309813728, + "Bust01_R_001": 0.01809775013429321 + } + ] + }, + { + "class": "bust", + "root_parent": "Chest", + "bones": [ + "Bust01_L_001", + "Bust01_L_001.seg1", + "Bust01_L_001.seg2", + "Bust01_L_001.seg3" + ], + "tips": [ + [ + 0.0, + 0.03306, + -0.0 + ], + [ + 0.0, + 0.03306, + -0.0 + ], + [ + 0.0, + 0.03306, + -0.0 + ], + [ + 0.0, + 0.03306, + -0.0 + ] + ], + "hulls": [ + [], + [], + [], + [] + ], + "neighbours": [ + { + "Bust02_L.seg3": 6.729600693488217, + "Bust02_L.seg2": 0.6073705560446581, + "Bust02_R": 0.6135937533841633, + "Bust02_L.seg1": 0.5235603986584445, + "Bust02_L": 6.613423885382499, + "Bust03_L_001.seg3": 8.371310126734542, + "Bust01_L_001.seg1": 0.38629788478024046, + "Bust03_L_001": 1.3835900670950232, + "Bust03_L_001.seg2": 0.26777733789898783, + "Bust03_L_001.seg1": 0.3548198778082869, + "Bust02_R.seg1": 0.009288447555037216 + }, + { + "Bust02_L.seg3": 3.965650537698678, + "Bust03_L_001": 4.800724986178796, + "Bust01_L_001.seg2": 2.2278659449511196, + "Bust02_L.seg2": 0.45601591565223715, + "Bust02_L": 4.352280333812645, + "Bust02_L.seg1": 0.5806351635478039, + "Bust01_L_001": 0.38629788478024046 + }, + { + "Bust01_L_001.seg1": 2.2278659449511196, + "Bust02_L.seg2": 0.5283447766357731, + "Bust02_L": 0.8579342939684047, + "Bust02_L.seg1": 0.4524824916068204, + "Bust02_L.seg3": 3.4417638093538288, + "Bust03_L_001": 1.6922467906349197 + }, + {} + ] + }, + { + "class": "bust", + "root_parent": "Chest", + "bones": [ + "Bust03_R_001", + "Bust03_R_001.seg1", + "Bust03_R_001.seg2", + "Bust03_R_001.seg3" + ], + "tips": [ + [ + -0.0, + 0.00502, + -0.0 + ], + [ + 0.0, + 0.00502, + -0.0 + ], + [ + 0.0, + 0.00502, + 0.0 + ], + [ + 0.04626, + 0.03105, + -0.01089 + ] + ], + "hulls": [ + [ + [ + -0.0442, + -0.04569, + -0.01868 + ], + [ + -0.04566, + 0.03105, + 0.02103 + ], + [ + 0.03437, + -0.03689, + -0.02435 + ], + [ + 0.01014, + -0.00238, + 0.01961 + ], + [ + -0.03916, + -0.01332, + 0.01505 + ], + [ + -0.00154, + -0.03677, + -0.00128 + ], + [ + 0.00348, + -0.0455, + -0.03125 + ], + [ + -0.02076, + 0.01322, + 0.02295 + ], + [ + 0.02519, + -0.02186, + 0.00864 + ], + [ + -0.01409, + -0.01741, + 0.01849 + ] + ], + [ + [ + -0.03367, + 0.0381, + 0.01234 + ], + [ + 0.02844, + -0.01959, + -0.00363 + ], + [ + -0.00677, + 0.01702, + 0.02547 + ], + [ + 0.0184, + -0.01219, + 0.01353 + ], + [ + -0.02276, + 0.02786, + 0.02271 + ], + [ + -0.0156, + 0.01949, + 0.02069 + ], + [ + 0.02377, + -0.01597, + 0.00572 + ] + ], + [ + [ + 0.04198, + -0.03518, + -0.02675 + ], + [ + -0.02232, + 0.02964, + 0.01387 + ], + [ + 0.00984, + 0.0011, + 0.02547 + ], + [ + 0.04078, + -0.02864, + -0.008 + ], + [ + -0.0043, + 0.00976, + 0.02127 + ], + [ + 0.03219, + -0.02764, + -0.01345 + ], + [ + 0.03386, + -0.02981, + -0.02504 + ], + [ + 0.00631, + 0.00488, + 0.0198 + ] + ], + [ + [ + 0.04812, + -0.02642, + -0.03085 + ], + [ + -0.01636, + 0.0459, + 0.01167 + ], + [ + 0.01621, + -0.00508, + 0.01619 + ], + [ + 0.03313, + 0.02501, + -0.02622 + ], + [ + 0.0154, + 0.031, + 0.00617 + ], + [ + 0.04022, + -0.01015, + -0.00026 + ], + [ + 0.04159, + -0.00016, + -0.0305 + ], + [ + -0.01121, + 0.02073, + 0.01522 + ], + [ + 0.03515, + 0.01515, + 0.00022 + ], + [ + 0.00163, + 0.04435, + -0.00802 + ] + ] + ], + "neighbours": [ + { + "Bust02_R": 5.307509138452929, + "Bust01_R_001": 1.4136093184462317, + "Bust01_R_001.seg1": 4.873491615786004, + "Bust02_R.seg3": 11.991912180052568, + "Bust02_R.seg2": 1.2955009556263646, + "Bust02_R.seg1": 1.502616046949964, + "Bust03_R_001.seg1": 0.4782062616681064, + "Bust01_R_001.seg2": 1.7041195119539072 + }, + { + "Bust03_R_001": 0.4782062616681064, + "Bust03_R_001.seg2": 1.003560123198177, + "Bust02_R.seg3": 0.5816850788738794, + "Bust02_R": 0.3973616822628325, + "Bust02_R.seg1": 0.047119399907472825, + "Bust01_R_001": 0.309535936388295 + }, + { + "Bust03_R_001.seg3": 0.8463580589684323, + "Bust03_R_001.seg1": 1.003560123198177, + "Bust02_R.seg3": 0.5084140222547837, + "Bust01_R_001": 0.3455534938015109, + "Bust02_R": 0.5067806721295243, + "Bust02_R.seg1": 0.02246419977765446, + "Bust02_R.seg2": 0.015858820704345522 + }, + { + "Bust02_R": 10.011569689474808, + "Bust01_R_001": 8.207608152240955, + "Bust02_R.seg3": 4.943467353681944, + "Bust02_R.seg2": 1.7124571917050189, + "Bust02_R.seg1": 1.6439859359300746, + "Bust03_R_001.seg2": 0.8463580589684323 + } + ] + }, + { + "class": "bust", + "root_parent": "Chest", + "bones": [ + "Bust02_R", + "Bust02_R.seg1", + "Bust02_R.seg2", + "Bust02_R.seg3" + ], + "tips": [ + [ + -0.0, + 0.00727, + 0.0 + ], + [ + -0.0, + 0.00727, + 0.0 + ], + [ + -0.0, + 0.00727, + 0.0 + ], + [ + -0.01121, + 0.02484, + 0.08191 + ] + ], + "hulls": [ + [ + [ + 0.04401, + -0.06689, + 0.02491 + ], + [ + -0.03462, + 0.05156, + -0.04028 + ], + [ + -0.03463, + -0.04498, + -0.0439 + ], + [ + 0.00361, + -0.0687, + -0.00615 + ], + [ + -0.0368, + 0.00366, + -0.04301 + ], + [ + 0.03404, + -0.03799, + 0.04267 + ], + [ + -0.00954, + -0.05169, + -0.03263 + ], + [ + 0.02183, + -0.06161, + 0.01115 + ], + [ + -0.04391, + 0.02797, + -0.03912 + ], + [ + -0.0543, + -0.01331, + -0.03926 + ] + ], + [ + [ + -0.03205, + 0.04907, + -0.03145 + ], + [ + -0.04008, + 0.02024, + -0.02208 + ], + [ + -0.02309, + 0.04357, + -0.01934 + ], + [ + -0.03183, + 0.03314, + -0.02048 + ], + [ + -0.03919, + 0.03885, + -0.02992 + ], + [ + -0.04254, + 0.02721, + -0.02709 + ], + [ + -0.03185, + 0.02969, + -0.02512 + ] + ], + [ + [ + 0.03185, + -0.03665, + 0.05212 + ], + [ + -0.02993, + 0.04536, + -0.02259 + ], + [ + -0.03998, + 0.01677, + -0.01623 + ], + [ + -0.03089, + 0.02896, + -0.01571 + ], + [ + -0.02242, + 0.04091, + -0.0147 + ], + [ + -0.03683, + 0.0363, + -0.01945 + ] + ], + [ + [ + 0.02768, + -0.02522, + 0.07202 + ], + [ + -0.02744, + 0.04326, + -0.01054 + ], + [ + -0.01191, + 0.03169, + 0.05578 + ], + [ + -0.02916, + 0.02393, + 0.02116 + ], + [ + 0.00317, + -0.00364, + 0.05832 + ], + [ + -0.02141, + 0.05154, + 0.02865 + ], + [ + -0.03679, + 0.01737, + -0.00467 + ], + [ + 0.02152, + -0.04126, + 0.05256 + ], + [ + -0.02212, + 0.03952, + 0.00942 + ], + [ + -0.00917, + 0.01028, + 0.04994 + ] + ] + ], + "neighbours": [ + { + "Bust02_L.seg3": 0.45644401532108525, + "Bust02_L.seg2": 0.41566034136591967, + "Bust02_R.seg1": 0.4601587836668959, + "Bust02_L.seg1": 0.3747821533537301, + "Bust02_L": 3.915824087943572, + "Bust01_R_001": 7.0890085884225496, + "Bust01_L_001": 0.6135937533841633, + "Bust03_R_001.seg3": 10.011569689474808, + "Bust01_R_001.seg1": 3.203896606274064, + "Bust03_R_001": 5.307509138452929, + "Bust01_R_001.seg2": 0.2931302984772375, + "Bust03_R_001.seg2": 0.5067806721295243, + "Bust03_R_001.seg1": 0.3973616822628325 + }, + { + "Bust02_L.seg3": 0.48457604242424607, + "Bust02_L.seg2": 0.09971818586097242, + "Bust02_R": 0.4601587836668959, + "Bust02_R.seg2": 0.4511130851945411, + "Bust01_R_001": 0.6168202296418669, + "Bust02_L": 0.005960952082285087, + "Bust03_R_001.seg3": 1.6439859359300746, + "Bust01_R_001.seg1": 0.9091350649963715, + "Bust01_R_001.seg2": 0.38717467091078317, + "Bust03_R_001": 1.502616046949964, + "Bust03_R_001.seg2": 0.02246419977765446, + "Bust03_R_001.seg1": 0.047119399907472825, + "Bust02_L.seg1": 0.05764958974084089, + "Bust01_L_001": 0.009288447555037216 + }, + { + "Bust02_R.seg1": 0.4511130851945411, + "Bust01_R_001": 0.7206833667025079, + "Bust02_R.seg3": 0.4693402085530754, + "Bust03_R_001.seg3": 1.7124571917050189, + "Bust03_R_001": 1.2955009556263646, + "Bust01_R_001.seg1": 0.7197488227225909, + "Bust01_R_001.seg2": 0.48970259372865915, + "Bust02_L.seg2": 0.022343147561200094, + "Bust02_L.seg3": 0.16906718151336297, + "Bust03_R_001.seg2": 0.015858820704345522 + }, + { + "Bust03_R_001.seg3": 4.943467353681944, + "Bust01_R_001": 4.914154174777157, + "Bust02_R.seg2": 0.4693402085530754, + "Bust03_R_001": 11.991912180052568, + "Bust01_R_001.seg1": 4.62673858015464, + "Bust03_R_001.seg2": 0.5084140222547837, + "Bust03_R_001.seg1": 0.5816850788738794, + "Bust01_R_001.seg2": 4.197806614286925, + "Bust02_L.seg3": 0.05393812309813728 + } + ] + }, + { + "class": "bust", + "root_parent": "Chest", + "bones": [ + "Bust01_R_001", + "Bust01_R_001.seg1", + "Bust01_R_001.seg2", + "Bust01_R_001.seg3" + ], + "tips": [ + [ + -0.0, + 0.03325, + -0.0 + ], + [ + 0.0, + 0.03325, + -0.0 + ], + [ + 0.0, + 0.03325, + -0.0 + ], + [ + 0.0, + 0.03325, + -0.0 + ] + ], + "hulls": [ + [], + [ + [ + -0.02064, + 0.02212, + -0.01718 + ], + [ + -0.00536, + 0.02826, + 0.00773 + ], + [ + -0.01888, + 0.02651, + -0.00485 + ], + [ + -0.00745, + 0.02622, + -0.00108 + ], + [ + -0.01978, + 0.02523, + -0.00909 + ], + [ + -0.00652, + 0.02729, + 0.0031 + ] + ], + [], + [] + ], + "neighbours": [ + { + "Bust02_R.seg2": 0.7206833667025079, + "Bust02_R.seg1": 0.6168202296418669, + "Bust02_L": 0.31005864655387483, + "Bust02_R": 7.0890085884225496, + "Bust03_R_001.seg3": 8.207608152240955, + "Bust02_R.seg3": 4.914154174777157, + "Bust01_R_001.seg1": 0.38291045211652436, + "Bust03_R_001": 1.4136093184462317, + "Bust03_R_001.seg2": 0.3455534938015109, + "Bust03_R_001.seg1": 0.309535936388295, + "Bust02_L.seg1": 0.025810371362056328, + "Bust02_L.seg2": 0.011205419963657398, + "Bust02_L.seg3": 0.01809775013429321 + }, + { + "Bust02_R": 3.203896606274064, + "Bust01_R_001": 0.38291045211652436, + "Bust03_R_001": 4.873491615786004, + "Bust01_R_001.seg2": 2.315045669707698, + "Bust02_R.seg1": 0.9091350649963715, + "Bust02_R.seg3": 4.62673858015464, + "Bust02_R.seg2": 0.7197488227225909 + }, + { + "Bust01_R_001.seg1": 2.315045669707698, + "Bust02_R.seg1": 0.38717467091078317, + "Bust02_R.seg3": 4.197806614286925, + "Bust03_R_001": 1.7041195119539072, + "Bust02_R.seg2": 0.48970259372865915, + "Bust02_R": 0.2931302984772375 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "HairF_Top", + "HairFL_00", + "HairFL_01" + ], + "tips": [ + [ + 0.0, + 0.12695, + -0.0 + ], + [ + 0.0, + 0.02817, + 0.0 + ], + [ + 0.0, + 0.02817, + 0.0 + ] + ], + "hulls": [ + [ + [ + -0.0709, + 0.19701, + -0.02448 + ], + [ + -0.01219, + 0.03468, + 0.00089 + ], + [ + -0.00316, + 0.12791, + -0.02292 + ], + [ + -0.0051, + 0.07784, + -0.03084 + ], + [ + -0.02866, + 0.16772, + -0.03377 + ], + [ + -0.02224, + 0.14248, + -0.04227 + ], + [ + 0.00084, + 0.05853, + -0.01463 + ], + [ + 0.0047, + 0.10031, + -0.02643 + ], + [ + -0.04913, + 0.18521, + -0.02467 + ], + [ + -0.01094, + 0.11379, + -0.04102 + ] + ], + [], + [] + ], + "neighbours": [ + { + "HairFL_00": 0.05267075423349632 + }, + { + "HairF_Top": 0.05267075423349632 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "HairF_Top_001", + "HairFR_02_00", + "HairFR_02_01", + "HairFR_02_02" + ], + "tips": [ + [ + -0.0, + 0.20762, + -0.0 + ], + [ + -0.0, + 0.04704, + 0.0 + ], + [ + 0.0, + 0.06538, + -0.0 + ], + [ + 0.0, + 0.06538, + -0.0 + ] + ], + "hulls": [ + [ + [ + -0.00288, + 0.22283, + 0.013 + ], + [ + 0.00889, + 0.03929, + -0.01939 + ], + [ + -0.02264, + 0.13514, + -0.04835 + ], + [ + -0.02427, + 0.09846, + 0.0138 + ], + [ + 0.00019, + 0.204, + -0.05523 + ], + [ + -0.02084, + 0.15999, + 0.02109 + ], + [ + -0.01409, + 0.08541, + -0.03676 + ], + [ + -0.0253, + 0.18142, + -0.02376 + ], + [ + -0.0335, + 0.13128, + -0.0098 + ], + [ + -0.0196, + 0.06556, + -0.00155 + ] + ], + [ + [ + 0.02007, + 0.05938, + -0.04047 + ], + [ + -0.00686, + 0.02392, + 0.01915 + ], + [ + -0.00724, + 0.06091, + -0.00245 + ], + [ + 0.00043, + 0.02599, + -0.02315 + ], + [ + -0.00422, + 0.06112, + 0.02235 + ], + [ + 0.01078, + 0.05041, + -0.0206 + ], + [ + 0.01842, + 0.03651, + -0.04542 + ], + [ + -0.01235, + 0.03465, + 0.00185 + ], + [ + -0.01321, + 0.04676, + 0.01771 + ], + [ + -0.00091, + 0.03762, + -0.00943 + ] + ], + [ + [ + -0.00679, + 0.10408, + 0.0075 + ], + [ + 0.00039, + 0.01538, + -0.0133 + ], + [ + 0.01456, + 0.06402, + -0.02416 + ], + [ + -0.01345, + 0.0494, + 0.02085 + ], + [ + -0.00778, + 0.07451, + 0.00301 + ], + [ + -0.00182, + 0.04668, + -0.00654 + ], + [ + -0.01943, + 0.02489, + 0.01069 + ], + [ + 0.01626, + 0.03975, + -0.03271 + ], + [ + 0.00807, + 0.08816, + -0.01896 + ], + [ + -0.00331, + 0.02975, + -0.00083 + ] + ], + [] + ], + "neighbours": [ + { + "HairFR_02_00": 11.198780429524012, + "HairFR_02_01": 1.68590918343183, + "HairFR_02_02": 0.01914253633767224 + }, + { + "HairF_Top_001": 11.198780429524012, + "HairFR_02_01": 14.30398476346928, + "HairFR_02_02": 0.6820841702186262 + }, + { + "HairF_Top_001": 1.68590918343183, + "HairFR_02_00": 14.30398476346928, + "HairFR_02_02": 12.428229786449679 + }, + { + "HairFR_02_01": 12.428229786449679, + "HairFR_02_00": 0.6820841702186262, + "HairF_Top_001": 0.01914253633767224 + } + ] + }, + { + "class": "hair", + "root_parent": "Head", + "bones": [ + "HairF_Top_002", + "HairFR_00", + "HairFR_01", + "HairFR_02" + ], + "tips": [ + [ + -0.0, + 0.12133, + 0.0 + ], + [ + 0.0, + 0.02307, + -0.0 + ], + [ + 0.0, + 0.02981, + -0.0 + ], + [ + 0.0, + 0.02981, + -0.0 + ] + ], + "hulls": [ + [ + [ + -0.05014, + 0.12632, + -0.01759 + ], + [ + -0.03167, + 0.00333, + -0.03768 + ], + [ + -0.04825, + 0.05919, + -0.00628 + ], + [ + -0.04605, + 0.09631, + -0.00308 + ], + [ + -0.03867, + 0.02863, + -0.01278 + ], + [ + -0.056, + 0.08241, + -0.01369 + ], + [ + -0.05546, + 0.10631, + -0.01526 + ], + [ + -0.03607, + 0.0151, + -0.02392 + ], + [ + -0.04399, + 0.11512, + -0.00607 + ], + [ + -0.04627, + 0.04432, + -0.01442 + ] + ], + [ + [ + -0.03139, + 0.01458, + -0.03774 + ], + [ + -0.02316, + 0.0231, + -0.01229 + ], + [ + -0.03061, + 0.02285, + -0.02646 + ], + [ + -0.02709, + 0.01232, + -0.02493 + ], + [ + -0.02925, + 0.02335, + -0.01823 + ], + [ + -0.02453, + 0.013, + -0.03198 + ], + [ + -0.02469, + 0.01954, + -0.0246 + ], + [ + -0.03143, + 0.01883, + -0.03237 + ], + [ + -0.03219, + 0.01133, + -0.03244 + ], + [ + -0.02541, + 0.01284, + -0.03788 + ] + ], + [ + [ + -0.02626, + 0.00464, + -0.01368 + ], + [ + -0.0101, + 0.00782, + 0.00737 + ], + [ + -0.01803, + 0.00459, + -0.00296 + ], + [ + -0.02014, + 0.00414, + -0.01004 + ], + [ + -0.01431, + 0.00762, + 0.00157 + ], + [ + -0.02189, + 0.00626, + -0.00694 + ], + [ + -0.02398, + 0.00295, + -0.01635 + ], + [ + -0.01261, + 0.00713, + 0.00436 + ], + [ + -0.0208, + 0.00235, + -0.00737 + ], + [ + -0.02368, + 0.00442, + -0.00806 + ] + ], + [] + ], + "neighbours": [ + { + "HairFR_00": 6.522659186510956, + "HairFR_01": 2.4443696705936615, + "HairFR_02": 0.012604948497175639 + }, + { + "HairF_Top_002": 6.522659186510956, + "HairFR_01": 10.866074148113151, + "HairFR_02": 0.8757130731296292 + }, + { + "HairF_Top_002": 2.4443696705936615, + "HairFR_00": 10.866074148113151, + "HairFR_02": 0.9474554435243647 + }, + { + "HairFR_01": 0.9474554435243647, + "HairFR_00": 0.8757130731296292, + "HairF_Top_002": 0.012604948497175639 + } + ] + } + ], + "twist": [], + "colliders": [ + { + "bone": "Hips", + "child": "Spine", + "from": 0.0, + "radius_head": 0.128, + "radius_tail": 0.128, + "radius": 0.128, + "lid": true + }, + { + "bone": "Left leg", + "child": "Left knee", + "from": 0.1, + "radius_head": 0.1203, + "radius_tail": 0.0758, + "radius": 0.0758 + }, + { + "bone": "Right leg", + "child": "Right knee", + "from": 0.1, + "radius_head": 0.1205, + "radius_tail": 0.0756, + "radius": 0.0756 + }, + { + "bone": "Left knee", + "child": "Left ankle", + "from": 0.1, + "radius_head": 0.1305, + "radius_tail": 0.1204, + "radius": 0.1204 + }, + { + "bone": "Right knee", + "child": "Right ankle", + "from": 0.1, + "radius_head": 0.1304, + "radius_tail": 0.1204, + "radius": 0.1204 + } + ], + "weights_authored": true, + "driven_bones": [ + "Chest", + "Head", + "Hips", + "Left ankle", + "Left arm", + "Left elbow", + "Left knee", + "Left leg", + "Left shoulder", + "Left wrist", + "Neck", + "Neck_001", + "Right ankle", + "Right arm", + "Right elbow", + "Right knee", + "Right leg", + "Right shoulder", + "Right wrist", + "Root_001", + "Root_002", + "Root_003", + "Root_004", + "Root_005", + "Root_006", + "Root_007", + "Spine", + "Toes4_L", + "Toes4_R", + "Unused_Noname_004", + "Unused_Noname_010" + ], + "surfaces": [ + { + "mesh": "Object_262", + "surface": 0, + "material": "Face", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 9620, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_263", + "surface": 0, + "material": "Clothes", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 12846, + "textured": true, + "chain_share": { + "bust": 0.059 + } + }, + { + "mesh": "Object_264", + "surface": 0, + "material": "Hair_A", + "class": "hair", + "detail": "hair", + "why": "material name says 'hair'", + "verts": 15552, + "textured": true, + "chain_share": { + "hair": 0.018 + } + }, + { + "mesh": "Object_265", + "surface": 0, + "material": "Hair_B", + "class": "hair", + "detail": "hair", + "why": "material name says 'hair'", + "verts": 5281, + "textured": true, + "chain_share": { + "hair": 0.094 + } + }, + { + "mesh": "Object_266", + "surface": 0, + "material": "Iris", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 260, + "textured": true, + "chain_share": {} + } + ] +} \ No newline at end of file diff --git a/assets/characters/skins/momo_Image_0.png b/assets/characters/skins/momo_Image_0.png new file mode 100644 index 0000000..9d7b916 Binary files /dev/null and b/assets/characters/skins/momo_Image_0.png differ diff --git a/assets/characters/skins/momo_Image_1.png b/assets/characters/skins/momo_Image_1.png new file mode 100644 index 0000000..0cb82fc Binary files /dev/null and b/assets/characters/skins/momo_Image_1.png differ diff --git a/assets/characters/skins/momo_Image_2.png b/assets/characters/skins/momo_Image_2.png new file mode 100644 index 0000000..4f1ddb5 Binary files /dev/null and b/assets/characters/skins/momo_Image_2.png differ diff --git a/assets/characters/skins/momo_Image_3.png b/assets/characters/skins/momo_Image_3.png new file mode 100644 index 0000000..4aab3e9 Binary files /dev/null and b/assets/characters/skins/momo_Image_3.png differ diff --git a/assets/characters/skins/momo_Image_4.png b/assets/characters/skins/momo_Image_4.png new file mode 100644 index 0000000..0f37d65 Binary files /dev/null and b/assets/characters/skins/momo_Image_4.png differ diff --git a/assets/characters/skins/skins.json b/assets/characters/skins/skins.json index c38eb06..4029775 100644 --- a/assets/characters/skins/skins.json +++ b/assets/characters/skins/skins.json @@ -10,9 +10,37 @@ { "id": "taila", "name": "Taila", - "description": "Original anime character by Partaevil (CC-BY)", + "description": "", "model": "res://assets/characters/skins/taila.glb", "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 } ] } \ No newline at end of file diff --git a/assets/characters/skins/taila.glb b/assets/characters/skins/taila.glb index 8ef0eb8..49d45d1 100644 Binary files a/assets/characters/skins/taila.glb and b/assets/characters/skins/taila.glb differ diff --git a/assets/characters/skins/taila.rig.json b/assets/characters/skins/taila.rig.json new file mode 100644 index 0000000..077b535 --- /dev/null +++ b/assets/characters/skins/taila.rig.json @@ -0,0 +1,8232 @@ +{ + "roles": { + "hips": "DEF-spine", + "head": "DEF-spine.006", + "neck": "DEF-spine.005", + "spine": [ + "DEF-spine.001", + "DEF-spine.002", + "DEF-spine.003", + "DEF-spine.004", + "DEF-spine.005", + "DEF-spine.006" + ], + "shoulder.R": "DEF-shoulder.R", + "shoulder.L": "DEF-shoulder.L", + "upper_arm.L": "DEF-upper_arm.L", + "upper_arm.R": "DEF-upper_arm.R", + "forearm.L": "DEF-forearm.L", + "forearm.R": "DEF-forearm.R", + "thigh.R": "DEF-thigh.R", + "thigh.L": "DEF-thigh.L", + "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.L": "DEF-toe.L", + "toe.R": "DEF-toe.R" + }, + "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": [ + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair", + "DEF-hair.001", + "DEF-hair.002" + ], + "tips": [ + [ + -0.0, + 0.06838, + 0.0 + ], + [ + -0.0, + 0.04506, + 0.0 + ], + [ + -0.01032, + 0.00854, + 0.01182 + ] + ], + "hulls": [ + [ + [ + 0.01674, + 0.10291, + 0.02973 + ], + [ + 0.03451, + 0.03231, + 0.00016 + ], + [ + -0.01716, + 0.05337, + -0.00299 + ], + [ + 0.03264, + 0.08202, + 0.01591 + ], + [ + 0.00803, + 0.0577, + 0.0108 + ], + [ + 0.03992, + 0.05758, + 0.00882 + ], + [ + 0.02314, + 0.05921, + 0.0009 + ], + [ + -0.00304, + 0.05285, + 0.00139 + ], + [ + 0.01035, + 0.06042, + -0.00529 + ], + [ + 0.01928, + 0.07988, + 0.01229 + ] + ], + [ + [ + -0.00899, + 0.04442, + -0.00085 + ], + [ + -0.02404, + 0.01036, + 0.01139 + ], + [ + 0.01712, + 0.01993, + 0.005 + ], + [ + -0.00513, + 0.01451, + -0.00241 + ], + [ + 0.00984, + 0.03774, + -0.00153 + ], + [ + 0.00415, + 0.01792, + 0.0089 + ], + [ + -0.01645, + 0.0083, + -0.00014 + ], + [ + 0.00046, + 0.03505, + 0.00649 + ], + [ + -0.01208, + 0.00937, + 0.01203 + ], + [ + -0.00743, + 0.03312, + -0.00319 + ] + ], + [ + [ + -0.01394, + 0.02572, + 0.00011 + ], + [ + -0.0018, + -0.01608, + 0.01364 + ], + [ + 0.00391, + 0.0038, + -0.00163 + ], + [ + -0.01374, + 0.00288, + 0.0104 + ], + [ + 0.00701, + 0.02313, + 0.00607 + ], + [ + -0.02015, + -0.01109, + 0.01284 + ], + [ + -0.00695, + 0.01233, + 0.00213 + ], + [ + -0.00142, + -0.00478, + 0.00609 + ], + [ + -0.00975, + -0.01068, + 0.01879 + ], + [ + 5e-05, + 0.01019, + 0.00929 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.001": 6.444893450622218, + "DEF-hair.007": 2.1405753868633206, + "DEF-hair.004": 4.305379068442997, + "DEF-hair.003": 3.737794440612138, + "DEF-hair.002": 2.852879727367223 + }, + { + "DEF-hair.002": 10.932883673705392, + "DEF-hair": 6.444893450622218, + "DEF-hair.004": 0.5635176735066056, + "DEF-hair.003": 0.15824819220502323, + "DEF-hair.007": 0.3355432027270542 + }, + { + "DEF-hair.001": 10.932883673705392, + "DEF-hair": 2.852879727367223, + "DEF-hair.007": 0.03886162555068284 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.003", + "DEF-hair.004" + ], + "tips": [ + [ + 0.0, + 0.06123, + -0.0 + ], + [ + -0.00017, + 0.04439, + -0.00029 + ] + ], + "hulls": [ + [ + [ + 0.00113, + 0.06236, + -0.00227 + ], + [ + 0.00701, + 0.02584, + -0.00787 + ], + [ + -0.00614, + 0.04412, + -0.00738 + ], + [ + 0.00784, + 0.04471, + 0.00317 + ], + [ + 0.00988, + 0.02683, + 0.003 + ], + [ + 0.00477, + 0.04586, + -0.00738 + ], + [ + 0.00445, + 0.05938, + 0.00744 + ], + [ + -0.00921, + 0.05971, + -0.00256 + ], + [ + 0.00043, + 0.02596, + 0.0005 + ], + [ + -0.00134, + 0.04362, + 0.00076 + ] + ], + [ + [ + 0.00022, + 0.03937, + -0.00024 + ], + [ + 0.00511, + 0.01787, + 0.00141 + ], + [ + -0.00567, + 0.01832, + -0.00298 + ], + [ + -0.00236, + 0.01841, + 0.00283 + ], + [ + 0.00218, + 0.0177, + -0.00326 + ], + [ + -0.00314, + 0.01799, + -0.00474 + ], + [ + 0.00415, + 0.01817, + 0.00434 + ], + [ + -0.00465, + 0.01808, + -0.00268 + ], + [ + 0.00404, + 0.01779, + 0.0016 + ], + [ + -0.00301, + 0.0179, + -0.00366 + ] + ] + ], + "neighbours": [ + { + "DEF-hair": 3.737794440612138, + "DEF-hair.004": 0.48107030537866957, + "DEF-hair.001": 0.15824819220502323 + }, + { + "DEF-hair": 4.305379068442997, + "DEF-hair.001": 0.5635176735066056, + "DEF-hair.003": 0.48107030537866957 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.007", + "DEF-hair.005" + ], + "tips": [ + [ + 0.0, + 0.04186, + -0.0 + ], + [ + 0.00621, + 0.02144, + 0.00318 + ] + ], + "hulls": [ + [ + [ + -0.02112, + 0.04918, + 0.02422 + ], + [ + 0.00107, + -0.01033, + 0.00095 + ], + [ + -0.00347, + 0.03222, + -0.00397 + ], + [ + -0.00948, + 0.01364, + 0.01086 + ], + [ + 0.01133, + 0.01422, + -0.00041 + ], + [ + -0.00915, + 0.03367, + 0.01549 + ], + [ + -0.0059, + -0.00788, + 0.01565 + ], + [ + -0.00287, + 0.01257, + -0.00376 + ], + [ + 0.00363, + 0.03034, + 0.00838 + ], + [ + 0.00801, + -0.00529, + 0.01351 + ] + ], + [ + [ + -0.00018, + 0.03655, + -0.00043 + ], + [ + 0.01031, + -0.00915, + -4e-05 + ], + [ + -0.00729, + 0.01104, + 0.00395 + ], + [ + 0.0085, + 0.01128, + -0.00185 + ], + [ + 0.0031, + 0.01248, + 0.00607 + ], + [ + -0.00257, + 0.00994, + -0.00246 + ], + [ + 0.01434, + -0.00662, + 0.00254 + ], + [ + -0.00639, + 0.01211, + 0.00782 + ], + [ + 0.00527, + 0.01035, + -0.00419 + ], + [ + -0.00016, + 0.03547, + -0.00033 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.005": 3.2375640964822265, + "DEF-hair": 2.1405753868633206, + "DEF-hair.001": 0.3355432027270542, + "DEF-hair.002": 0.03886162555068284 + }, + { + "DEF-hair.007": 3.2375640964822265 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.008", + "DEF-hair.009", + "DEF-hair.010", + "DEF-hair.011" + ], + "tips": [ + [ + -0.0, + 0.08774, + -0.0 + ], + [ + 0.0, + 0.05443, + -0.0 + ], + [ + -0.0, + 0.03074, + 0.0 + ], + [ + -0.00258, + 0.02087, + 0.00039 + ] + ], + "hulls": [ + [ + [ + 0.02765, + 0.09489, + -0.00831 + ], + [ + 0.01218, + -0.00528, + -0.01352 + ], + [ + -0.00966, + 0.04947, + 0.0296 + ], + [ + 0.03232, + 0.03961, + -0.02464 + ], + [ + -0.00344, + 0.06915, + -0.00733 + ], + [ + 0.01392, + 0.01818, + 0.01576 + ], + [ + -0.01562, + 0.08459, + 0.02502 + ], + [ + 0.01659, + 0.07392, + 0.02003 + ], + [ + 0.02263, + 0.06867, + -0.02538 + ], + [ + -0.00898, + 0.01997, + -0.0017 + ] + ], + [ + [ + 0.03852, + 0.0474, + -0.02382 + ], + [ + -0.01561, + 0.01392, + 0.02178 + ], + [ + -0.01735, + 0.05484, + -0.01292 + ], + [ + 0.01606, + 0.01503, + -0.0077 + ], + [ + 0.00483, + 0.03686, + 0.01325 + ], + [ + 0.01213, + 0.05777, + -0.01159 + ], + [ + -0.00464, + 0.03242, + -0.01137 + ], + [ + -0.01895, + 0.0337, + 0.00761 + ], + [ + -0.01409, + 0.06012, + 0.00746 + ], + [ + 0.01101, + 0.01666, + 0.01597 + ] + ], + [ + [ + -0.01869, + 0.01611, + 0.00917 + ], + [ + -0.00763, + 0.01686, + -0.01105 + ], + [ + -0.0002, + 0.01687, + 0.01034 + ], + [ + -0.01846, + 0.0161, + 0.00208 + ], + [ + -0.00718, + 0.01676, + -0.01006 + ], + [ + -0.01785, + 0.01605, + 0.00846 + ], + [ + -0.01743, + 0.01605, + 0.00244 + ], + [ + -0.0005, + 0.0168, + 0.00929 + ] + ], + [ + [ + 0.00088, + 0.03163, + -0.00099 + ], + [ + -0.01318, + 0.00392, + 0.00628 + ], + [ + 0.01083, + 0.00678, + -0.00118 + ], + [ + -0.00504, + 0.00595, + -0.00867 + ], + [ + 0.00037, + 0.00517, + 0.00747 + ], + [ + 0.00756, + 0.0066, + -0.00563 + ], + [ + -0.01301, + 0.00416, + 0.00091 + ], + [ + -0.01235, + 0.00379, + 0.00558 + ], + [ + 0.00012, + 0.00504, + 0.00641 + ], + [ + 0.00708, + 0.00616, + -0.00476 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.009": 5.0612108830893465, + "DEF-hair.L.008": 0.10078104744782834, + "DEF-hair.R.012": 9.298894414270677 + }, + { + "DEF-hair.010": 2.072245581610017, + "DEF-hair.008": 5.0612108830893465 + }, + { + "DEF-hair.009": 2.072245581610017 + }, + {} + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.L.008", + "DEF-hair.L.009", + "DEF-hair.L.010", + "DEF-hair.L.011" + ], + "tips": [ + [ + -0.0, + 0.15518, + 0.0 + ], + [ + 0.0, + 0.1139, + 0.0 + ], + [ + -0.0, + 0.0683, + -0.0 + ], + [ + -0.00184, + 0.09275, + -0.00073 + ] + ], + "hulls": [ + [ + [ + -0.0201, + 0.12899, + -0.03061 + ], + [ + -0.02432, + 0.079, + 0.02303 + ], + [ + -0.02055, + 0.07496, + -0.03044 + ], + [ + -0.01427, + 0.12502, + 0.01645 + ], + [ + -0.00353, + 0.07731, + -0.0035 + ], + [ + -0.03306, + 0.07625, + -0.00346 + ], + [ + -0.03185, + 0.12798, + -0.00534 + ], + [ + -0.00435, + 0.12595, + -0.00541 + ], + [ + -0.01423, + 0.07892, + 0.01983 + ], + [ + -0.01092, + 0.07553, + -0.02619 + ] + ], + [ + [ + -0.01584, + 0.11898, + 0.02156 + ], + [ + -0.01937, + 0.01511, + -0.02701 + ], + [ + -0.01421, + 0.07061, + -0.01786 + ], + [ + -0.02173, + 0.01636, + 0.01885 + ], + [ + -0.01499, + 0.11514, + -0.01949 + ], + [ + -0.02382, + 0.06925, + 0.0219 + ], + [ + -0.00429, + 0.01801, + -0.00334 + ], + [ + -0.02958, + 0.01445, + -0.00452 + ], + [ + -0.03155, + 0.07078, + -0.00053 + ], + [ + -0.02325, + 0.1195, + 0.00065 + ] + ], + [ + [ + -0.00944, + 0.07223, + 0.01619 + ], + [ + -0.00096, + -0.00272, + 0.00095 + ], + [ + -0.02097, + 0.02867, + 0.02652 + ], + [ + -0.02263, + 0.03074, + -0.00958 + ], + [ + -0.0118, + 0.06831, + -0.00751 + ], + [ + -0.00862, + 0.02933, + 0.0079 + ], + [ + -0.029, + 0.02986, + 0.00881 + ], + [ + -0.01507, + 0.07231, + 0.00445 + ], + [ + -0.01532, + 0.03041, + -0.00731 + ], + [ + -0.01399, + 0.02864, + 0.02361 + ] + ], + [ + [ + -0.00073, + 0.05526, + -0.00107 + ], + [ + -0.00219, + -0.00378, + 0.00364 + ], + [ + -0.00111, + -0.00363, + 0.00361 + ], + [ + -0.00086, + 0.05418, + -0.00098 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.008": 0.10078104744782834, + "DEF-hair.L": 0.07930559596010497, + "DEF-hair.L.009": 1.529110640133334, + "DEF-hair.L.012": 3.1007271781263652, + "DEF-hair.L.013": 0.021517533900501484 + }, + { + "DEF-hair.L.010": 2.697011560446442, + "DEF-hair.L.008": 1.529110640133334 + }, + { + "DEF-hair.L.011": 3.3977685462242633, + "DEF-hair.L.009": 2.697011560446442 + }, + { + "DEF-hair.L.010": 3.3977685462242633 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.L.012", + "DEF-hair.L.013", + "DEF-hair.L.014" + ], + "tips": [ + [ + 0.0, + 0.105, + -0.0 + ], + [ + 0.0, + 0.05073, + 0.0 + ], + [ + -0.0067, + 0.04095, + 0.00485 + ] + ], + "hulls": [ + [ + [ + -0.02527, + 0.09, + 0.0122 + ], + [ + -0.01623, + 0.01641, + -0.012 + ], + [ + -0.00057, + 0.05122, + 0.0168 + ], + [ + -0.02063, + 0.05217, + -0.01658 + ], + [ + -0.01205, + 0.0868, + -0.0162 + ], + [ + -0.00573, + 0.08579, + 0.01506 + ], + [ + -0.00293, + 0.05454, + -0.00049 + ], + [ + -0.00287, + 0.0856, + -0.00186 + ], + [ + -0.01003, + 0.06304, + 0.01981 + ], + [ + -0.00815, + 0.01761, + -0.01073 + ] + ], + [ + [ + -0.01738, + 0.05399, + 0.00728 + ], + [ + -0.01851, + 0.00751, + -0.01353 + ], + [ + -0.01059, + 0.00565, + 0.02048 + ], + [ + -0.02089, + 0.03301, + -0.00948 + ], + [ + -0.01369, + 0.03096, + 0.02149 + ], + [ + -0.00248, + 0.00933, + 0.00082 + ], + [ + -0.00592, + 0.03143, + 0.00333 + ], + [ + -0.02149, + 0.00584, + 0.00598 + ], + [ + -0.02351, + 0.03225, + 0.00747 + ], + [ + -0.01105, + 0.04818, + -0.00687 + ] + ], + [ + [ + 0.00167, + 0.03852, + -0.0 + ], + [ + -0.00624, + 0.00804, + 0.01374 + ], + [ + -0.01075, + 0.00706, + -0.00464 + ], + [ + -0.00133, + 0.00698, + 0.00283 + ], + [ + -0.01224, + 0.00774, + 0.00546 + ], + [ + -0.00636, + 0.00686, + -0.00431 + ], + [ + -0.0025, + 0.0077, + 0.01144 + ], + [ + -0.00238, + 0.00686, + 0.00309 + ], + [ + -0.01002, + 0.00687, + -0.00385 + ], + [ + -0.00595, + 0.00775, + 0.01272 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.L.008": 3.1007271781263652, + "DEF-hair.L.013": 2.0607935679774254, + "DEF-hair.L.018": 5.976813796675678, + "DEF-hair.L.025": 0.040129953350502845 + }, + { + "DEF-hair.L.014": 3.4327523504035433, + "DEF-hair.L.012": 2.0607935679774254, + "DEF-hair.L.008": 0.021517533900501484 + }, + { + "DEF-hair.L.013": 3.4327523504035433 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.L.018", + "DEF-hair.L.019", + "DEF-hair.L.020", + "DEF-hair.L.021" + ], + "tips": [ + [ + 0.0, + 0.06312, + 0.0 + ], + [ + -0.0, + 0.05056, + 0.0 + ], + [ + 0.0, + 0.02034, + -0.0 + ], + [ + 0.0048, + 0.02461, + -0.00168 + ] + ], + "hulls": [ + [ + [ + -0.02612, + 0.14192, + -0.003 + ], + [ + -0.02929, + -0.03494, + 7e-05 + ], + [ + 0.00844, + 0.05412, + 0.0319 + ], + [ + -0.01794, + 0.08142, + -0.01905 + ], + [ + -0.01421, + 0.01826, + -0.00923 + ], + [ + -0.00185, + 0.10668, + 0.02716 + ], + [ + 0.01647, + 0.12274, + -0.02448 + ], + [ + -0.00038, + -0.01378, + 0.03151 + ], + [ + 0.01097, + 0.01946, + 0.02163 + ], + [ + 0.0037, + 0.04788, + -0.003 + ] + ], + [ + [ + -0.02288, + 0.05079, + -0.0256 + ], + [ + -0.00914, + -0.01232, + 0.01282 + ], + [ + 0.01774, + 0.04282, + 0.01727 + ], + [ + -0.00945, + 0.0198, + -0.00823 + ], + [ + 0.01435, + 0.00964, + 0.01206 + ], + [ + -0.00794, + 0.04511, + -0.00021 + ], + [ + -0.00348, + 0.01658, + 0.01378 + ], + [ + 0.00623, + 0.05728, + 0.00083 + ], + [ + -0.01301, + 0.04025, + -0.01727 + ], + [ + 0.01291, + 0.04211, + -0.00045 + ] + ], + [ + [ + 0.01476, + 0.03544, + 0.01055 + ], + [ + 0.00952, + -0.00352, + -0.00197 + ], + [ + 0.0258, + 0.01218, + 0.01849 + ], + [ + 0.00063, + 0.01187, + 0.00239 + ], + [ + 0.02179, + 0.00272, + 0.0051 + ], + [ + 0.0095, + 0.02439, + 0.00123 + ], + [ + 0.02216, + 0.02327, + 0.01003 + ], + [ + 0.02259, + -0.0023, + 0.01636 + ], + [ + 0.0127, + 0.01015, + 0.00256 + ], + [ + 0.01403, + 0.00342, + 0.01365 + ] + ], + [ + [ + -0.0004, + 0.02115, + -0.00031 + ], + [ + 0.00701, + 0.01093, + 0.00114 + ], + [ + -0.00091, + 0.00992, + -0.00415 + ], + [ + 0.00074, + 0.00975, + 0.00111 + ], + [ + 0.00552, + 0.01082, + -0.00311 + ], + [ + 0.00155, + 0.01032, + -0.00522 + ], + [ + 0.00567, + 0.01064, + 0.0034 + ], + [ + 0.00474, + 0.01042, + -0.00245 + ], + [ + 6e-05, + 0.00969, + -0.00372 + ], + [ + 0.00154, + 0.00977, + 0.00037 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.L.019": 30.077657354322675, + "DEF-hair.L.012": 5.976813796675678 + }, + { + "DEF-hair.L.018": 30.077657354322675, + "DEF-hair.L.020": 8.790750150303484 + }, + { + "DEF-hair.L.019": 8.790750150303484, + "DEF-hair.L.021": 1.3119401760714409 + }, + { + "DEF-hair.L.020": 1.3119401760714409 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.L.022", + "DEF-hair.L.023", + "DEF-hair.L.024", + "DEF-hair.L.025" + ], + "tips": [ + [ + 0.0, + 0.06757, + -0.0 + ], + [ + 0.0, + 0.05187, + 0.0 + ], + [ + 0.0, + 0.02554, + 0.0 + ], + [ + -0.00718, + 0.05819, + -0.00047 + ] + ], + "hulls": [ + [ + [ + -0.0171, + 0.05533, + -0.00825 + ], + [ + 0.00085, + 0.05042, + 0.01568 + ], + [ + -0.01647, + 0.05217, + 0.00964 + ], + [ + 0.00017, + 0.05294, + -0.00219 + ], + [ + 0.0041, + 0.05091, + 0.01062 + ], + [ + -0.01131, + 0.05508, + -0.00972 + ], + [ + 0.00302, + 0.05085, + 0.01045 + ], + [ + -0.00071, + 0.0528, + -0.00156 + ], + [ + -0.01627, + 0.05521, + -0.00756 + ], + [ + 0.00057, + 0.05054, + 0.01463 + ] + ], + [ + [ + -0.00961, + 0.04836, + -0.01404 + ], + [ + -0.01303, + 0.00389, + -0.01295 + ], + [ + -0.00493, + 0.01951, + 0.00843 + ], + [ + -0.00136, + 0.02665, + -0.01764 + ], + [ + -0.0161, + 0.03114, + -0.01071 + ], + [ + -0.01843, + 0.01304, + 0.00013 + ], + [ + 0.00438, + 0.04454, + -0.00581 + ], + [ + 0.00052, + 0.0112, + -0.00442 + ], + [ + -0.00752, + 0.03146, + 0.00147 + ], + [ + 0.00152, + 0.04372, + -0.02001 + ] + ], + [ + [ + -0.00382, + 0.02522, + -0.00718 + ], + [ + -0.00559, + 0.0012, + -0.00894 + ], + [ + 0.00224, + 0.00661, + -0.00227 + ], + [ + -0.00973, + 0.02269, + 0.00034 + ], + [ + 0.00214, + 0.00447, + -0.01281 + ], + [ + -0.00643, + 0.00293, + 3e-05 + ], + [ + -0.00344, + 0.0247, + 3e-05 + ], + [ + -0.00842, + 0.02339, + -0.00509 + ], + [ + 0.00399, + 0.00592, + -0.00951 + ], + [ + -0.00327, + 0.00453, + 0.00141 + ] + ], + [ + [ + -0.00355, + 0.02964, + -0.00022 + ], + [ + -0.00363, + 0.02855, + -0.00025 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.L.023": 1.3606538529242842 + }, + { + "DEF-hair.L.022": 1.3606538529242842, + "DEF-hair.L.024": 1.7455139142072973 + }, + { + "DEF-hair.L.025": 1.9640462828626353, + "DEF-hair.L.023": 1.7455139142072973 + }, + { + "DEF-hair.L.012": 0.040129953350502845, + "DEF-hair.L.024": 1.9640462828626353 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.R.008", + "DEF-hair.R.009", + "DEF-hair.R.010", + "DEF-hair.R.011" + ], + "tips": [ + [ + 0.0, + 0.15518, + 0.0 + ], + [ + -0.0, + 0.1139, + 0.0 + ], + [ + 0.0, + 0.0683, + 0.0 + ], + [ + 0.00252, + -0.00932, + 0.00663 + ] + ], + "hulls": [ + [ + [ + 0.02108, + 0.12855, + -0.03051 + ], + [ + 0.05777, + 0.0078, + -0.02551 + ], + [ + 0.0253, + 0.07855, + 0.02313 + ], + [ + 0.10638, + 0.04973, + -0.03343 + ], + [ + 0.02152, + 0.07451, + -0.03034 + ], + [ + 0.01525, + 0.12457, + 0.01655 + ], + [ + 0.07178, + 0.03724, + -0.00026 + ], + [ + 0.02707, + 0.01361, + -0.01575 + ], + [ + 0.00451, + 0.07687, + -0.00339 + ], + [ + 0.08192, + 0.03253, + -0.03457 + ] + ], + [ + [ + 0.01656, + 0.11824, + 0.02184 + ], + [ + 0.02009, + 0.01436, + -0.02673 + ], + [ + 0.01493, + 0.06986, + -0.01758 + ], + [ + 0.02245, + 0.01562, + 0.01914 + ], + [ + 0.01571, + 0.11439, + -0.01921 + ], + [ + 0.02454, + 0.0685, + 0.02218 + ], + [ + 0.00501, + 0.01726, + -0.00305 + ], + [ + 0.0303, + 0.01371, + -0.00423 + ], + [ + 0.03227, + 0.07003, + -0.00025 + ], + [ + 0.02397, + 0.11875, + 0.00093 + ] + ], + [ + [ + -0.02348, + 0.11573, + -0.01225 + ], + [ + 0.00105, + -0.00379, + 0.00086 + ], + [ + 0.00953, + 0.07116, + 0.01609 + ], + [ + 0.02106, + 0.0276, + 0.02643 + ], + [ + 0.02271, + 0.02967, + -0.00967 + ], + [ + 0.01189, + 0.06724, + -0.00761 + ], + [ + 0.00871, + 0.02826, + 0.0078 + ], + [ + 0.02909, + 0.02879, + 0.00872 + ], + [ + 0.01515, + 0.07124, + 0.00436 + ], + [ + 0.0154, + 0.02934, + -0.0074 + ] + ], + [] + ], + "neighbours": [ + { + "DEF-hair.R.005": 1.3949822409393602, + "DEF-hair.R": 2.6819264528755653, + "DEF-hair.R.006": 0.08180381100485945, + "DEF-hair.R.009": 5.783332277237857, + "DEF-hair.R.012": 9.98357400601948, + "DEF-hair.R.013": 0.03451450992651994 + }, + { + "DEF-hair.R.010": 2.9875086680951517, + "DEF-hair.R.008": 5.783332277237857 + }, + { + "DEF-hair.R.011": 3.9748911536524965, + "DEF-hair.R.009": 2.9875086680951517 + }, + { + "DEF-hair.R.010": 3.9748911536524965 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.006", + "bones": [ + "DEF-hair.R.012", + "DEF-hair.R.013", + "DEF-hair.R.014" + ], + "tips": [ + [ + 0.0, + 0.105, + 0.0 + ], + [ + -0.0, + 0.05073, + 0.0 + ], + [ + 0.0059, + 0.03897, + 0.00457 + ] + ], + "hulls": [ + [ + [ + 0.02629, + 0.08967, + 0.01231 + ], + [ + 0.02854, + 0.01432, + -0.02129 + ], + [ + 0.00159, + 0.05089, + 0.0169 + ], + [ + 0.02154, + 0.04587, + -0.04861 + ], + [ + 0.01367, + 0.05223, + -0.01523 + ], + [ + 0.01307, + 0.08647, + -0.0161 + ], + [ + 0.03538, + 0.029, + -0.00254 + ], + [ + 0.00917, + 0.01728, + -0.01063 + ], + [ + 0.03284, + 0.03419, + -0.02689 + ], + [ + 0.00675, + 0.08546, + 0.01516 + ] + ], + [ + [ + 0.0181, + 0.0532, + 0.00736 + ], + [ + 0.01924, + 0.00672, + -0.01345 + ], + [ + 0.01131, + 0.00486, + 0.02056 + ], + [ + 0.02162, + 0.03222, + -0.0094 + ], + [ + 0.01442, + 0.03017, + 0.02157 + ], + [ + 0.00321, + 0.00854, + 0.0009 + ], + [ + 0.00665, + 0.03064, + 0.00341 + ], + [ + 0.02221, + 0.00505, + 0.00606 + ], + [ + 0.02424, + 0.03146, + 0.00755 + ], + [ + 0.01178, + 0.04739, + -0.00679 + ] + ], + [ + [ + -0.00207, + 0.03753, + -0.00014 + ], + [ + 0.00584, + 0.00705, + 0.0136 + ], + [ + 0.01035, + 0.00607, + -0.00478 + ], + [ + 0.00093, + 0.00599, + 0.00268 + ], + [ + 0.01184, + 0.00675, + 0.00532 + ], + [ + 0.00596, + 0.00587, + -0.00445 + ], + [ + 0.0021, + 0.00671, + 0.0113 + ], + [ + 0.00198, + 0.00587, + 0.00294 + ], + [ + 0.00555, + 0.00676, + 0.01258 + ], + [ + 0.00962, + 0.00588, + -0.00399 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.R.013": 2.1961626040734146, + "DEF-hair.R.008": 9.98357400601948, + "DEF-hair.008": 9.298894414270677 + }, + { + "DEF-hair.R.014": 4.241960469519606, + "DEF-hair.R.012": 2.1961626040734146, + "DEF-hair.R.008": 0.03451450992651994 + }, + { + "DEF-hair.R.013": 4.241960469519606 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.004", + "bones": [ + "DEF-hair.L", + "DEF-hair.L.001", + "DEF-hair.L.002", + "DEF-hair.L.003" + ], + "tips": [ + [ + -0.0, + 0.11762, + 0.0 + ], + [ + 0.0, + 0.18049, + 0.0 + ], + [ + 0.0, + 0.10823, + -0.0 + ], + [ + -0.04057, + -0.18138, + -0.1173 + ] + ], + "hulls": [ + [ + [ + -0.15137, + 0.17534, + -0.07598 + ], + [ + -0.00863, + -0.01403, + -0.03231 + ], + [ + -0.21243, + 0.04236, + -0.06494 + ], + [ + -0.05177, + 0.08996, + 0.01251 + ], + [ + -0.13274, + 0.06548, + -0.05554 + ], + [ + -0.00623, + 0.09071, + -0.05371 + ], + [ + -0.07673, + -0.03425, + -0.01586 + ], + [ + -0.04578, + 0.02054, + 0.01325 + ], + [ + -0.06815, + 0.05124, + -0.0536 + ], + [ + -0.11547, + 0.09188, + -0.01059 + ] + ], + [ + [ + -0.04022, + 0.19274, + 0.02268 + ], + [ + -0.18808, + -0.01116, + -0.04065 + ], + [ + -0.00948, + 0.03806, + -0.05252 + ], + [ + -0.00011, + 0.12049, + -0.03261 + ], + [ + -0.05524, + 0.03318, + 0.01582 + ], + [ + -0.0526, + 0.12081, + 0.02013 + ], + [ + -0.16581, + 0.05417, + -0.05721 + ], + [ + -0.00179, + 0.18739, + -0.03687 + ], + [ + -0.0086, + 0.0377, + -0.00229 + ], + [ + -0.04574, + 0.03444, + -0.02739 + ] + ], + [ + [ + -0.13171, + -0.13625, + -0.07775 + ], + [ + -0.01593, + 0.10242, + 0.01499 + ], + [ + -0.03483, + 0.00179, + -0.01444 + ], + [ + -0.12119, + -0.04466, + -0.10461 + ], + [ + -0.03919, + 0.04719, + 0.02226 + ], + [ + -0.00011, + 0.04727, + -0.01891 + ], + [ + -0.14944, + -0.07688, + -0.1013 + ], + [ + 0.00414, + 0.00687, + -0.02526 + ], + [ + -0.11494, + -0.1096, + -0.09973 + ], + [ + -0.00516, + 0.09746, + -0.01752 + ] + ], + [ + [ + -0.00177, + -0.27194, + -0.12983 + ], + [ + 0.00148, + 0.04408, + -0.00096 + ], + [ + -0.09496, + -0.20564, + -0.13214 + ], + [ + -0.04621, + -0.22711, + -0.13847 + ], + [ + -0.01361, + 0.01332, + 0.0075 + ], + [ + -0.03276, + -0.24628, + -0.12281 + ], + [ + -0.00278, + 0.01174, + -0.01145 + ], + [ + -0.07862, + -0.20394, + -0.14078 + ], + [ + -0.00561, + -0.27193, + -0.11553 + ], + [ + -0.02874, + -0.24582, + -0.13598 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.L.005": 11.524415420547314, + "DEF-hair.L.006": 0.95175079508802, + "DEF-hair.L.007": 0.02390360394735, + "DEF-hair.L.008": 0.07930559596010497, + "DEF-hair.L.004": 3.6971060377822207, + "DEF-hair.L.001": 1.7201846105818497 + }, + { + "DEF-hair.L.002": 4.3335288549483275, + "DEF-hair.L": 1.7201846105818497, + "DEF-hair.L.005": 1.389903376048438, + "DEF-hair.L.004": 0.12349060048937432, + "DEF-hair.L.003": 2.1324736022343336 + }, + { + "DEF-hair.L.003": 9.736097935389328, + "DEF-hair.L.001": 4.3335288549483275, + "DEF-hair.L.005": 3.6150717720990997 + }, + { + "DEF-hair.L.002": 9.736097935389328, + "DEF-hair.L.005": 4.400683455708296, + "DEF-hair.L.001": 2.1324736022343336 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.004", + "bones": [ + "DEF-hair.R", + "DEF-hair.R.001", + "DEF-hair.R.002", + "DEF-hair.R.003" + ], + "tips": [ + [ + 0.0, + 0.11762, + -0.0 + ], + [ + 0.0, + 0.18049, + 0.0 + ], + [ + 0.0, + 0.10823, + -0.0 + ], + [ + 0.00673, + 0.04894, + -0.00199 + ] + ], + "hulls": [ + [ + [ + 0.10392, + 0.04499, + -0.03143 + ], + [ + -0.00179, + 0.08913, + -0.04032 + ], + [ + 0.01042, + -0.01469, + -0.03154 + ], + [ + 0.05269, + 0.08941, + 0.01263 + ], + [ + 0.0467, + 0.01999, + 0.01337 + ], + [ + 0.00289, + 0.0244, + -0.00341 + ], + [ + 0.03259, + 0.06068, + -0.03311 + ], + [ + 0.00187, + 0.02225, + -0.05257 + ], + [ + 0.06928, + 0.05051, + -0.05454 + ], + [ + 0.03548, + 0.01714, + -0.02935 + ] + ], + [ + [ + 0.04087, + 0.19192, + 0.02292 + ], + [ + 0.01014, + 0.03724, + -0.05229 + ], + [ + 0.00077, + 0.11967, + -0.03237 + ], + [ + 0.05589, + 0.03236, + 0.01606 + ], + [ + 0.05326, + 0.11999, + 0.02036 + ], + [ + 0.00244, + 0.18657, + -0.03663 + ], + [ + 0.00925, + 0.03688, + -0.00205 + ], + [ + 0.0464, + 0.03362, + -0.02716 + ], + [ + 0.04423, + 0.12228, + -0.02088 + ], + [ + 0.033, + 0.19244, + -0.01446 + ] + ], + [ + [ + 0.02337, + 0.10374, + 0.0137 + ], + [ + 0.02848, + -0.00246, + 0.02582 + ], + [ + 0.0076, + 0.04735, + -0.02831 + ], + [ + 0.03954, + 0.04619, + 0.02246 + ], + [ + 0.00572, + 0.04488, + 0.00827 + ], + [ + 0.00551, + 0.09646, + -0.01731 + ], + [ + 0.00242, + 0.00207, + 0.00675 + ], + [ + 0.0336, + 0.04774, + -0.00921 + ], + [ + 0.0059, + 0.07567, + 0.00688 + ], + [ + 0.0273, + 0.0798, + -0.0066 + ] + ], + [ + [ + -0.00144, + 0.04301, + -0.00088 + ], + [ + 0.01365, + 0.01225, + 0.00757 + ], + [ + 0.00282, + 0.01066, + -0.01138 + ], + [ + 0.00127, + 0.00974, + 0.00225 + ], + [ + 0.01206, + 0.0122, + -0.00415 + ], + [ + -0.0003, + 0.00984, + -0.00764 + ], + [ + 0.00895, + 0.0112, + 0.00848 + ], + [ + 0.00295, + 0.01034, + -0.01034 + ], + [ + 0.01117, + 0.01186, + -0.00363 + ], + [ + -0.00118, + 0.04195, + -0.00089 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.R.005": 8.895136039757556, + "DEF-hair.R.006": 0.5874295932403306, + "DEF-hair.R.008": 2.6819264528755653, + "DEF-hair.R.004": 3.0752010115277004, + "DEF-hair.R.001": 1.8037417183725024 + }, + { + "DEF-hair.R.002": 3.682230264940157, + "DEF-hair.R": 1.8037417183725024 + }, + { + "DEF-hair.R.003": 3.8194620543874045, + "DEF-hair.R.001": 3.682230264940157 + }, + { + "DEF-hair.R.002": 3.8194620543874045 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.003", + "bones": [ + "DEF-hair.L.004", + "DEF-hair.L.005", + "DEF-hair.L.006", + "DEF-hair.L.007" + ], + "tips": [ + [ + 0.0, + 0.13941, + -0.0 + ], + [ + -0.0, + 0.20008, + -0.0 + ], + [ + -0.0, + 0.10005, + -0.0 + ], + [ + -0.05539, + -0.26952, + -0.08576 + ] + ], + "hulls": [ + [ + [ + -0.12642, + 0.30292, + 0.00405 + ], + [ + 0.05763, + -0.01675, + -0.04559 + ], + [ + -0.06843, + 0.11737, + -0.0105 + ], + [ + -0.01425, + 0.23819, + 0.01163 + ], + [ + -0.04616, + -0.00216, + -0.04609 + ], + [ + 0.02452, + 0.11463, + -0.05849 + ], + [ + -0.08785, + 0.22711, + -0.05288 + ], + [ + -0.04701, + 0.30847, + -0.0095 + ], + [ + 0.06689, + 0.05756, + -0.04087 + ], + [ + 0.01631, + 0.00124, + -0.00275 + ] + ], + [ + [ + 0.22683, + 0.29103, + 0.02328 + ], + [ + -0.08923, + 0.10069, + -0.15483 + ], + [ + 0.10543, + -0.04097, + -0.04242 + ], + [ + -0.05913, + 0.1578, + 0.02553 + ], + [ + 0.13118, + 0.13005, + 0.05198 + ], + [ + 0.01882, + 0.07286, + -0.05548 + ], + [ + 0.12421, + 0.23997, + 0.00629 + ], + [ + 0.04625, + 0.10805, + -0.14725 + ], + [ + 0.10061, + 0.13139, + -0.04347 + ], + [ + 0.01364, + 0.17206, + -0.05526 + ] + ], + [ + [ + -0.03371, + 0.08882, + 0.00318 + ], + [ + 0.01108, + 0.04109, + -0.04371 + ], + [ + -0.02901, + 0.03766, + 0.01054 + ], + [ + 0.01259, + 0.08166, + -0.02576 + ], + [ + -0.02479, + 0.03974, + -0.03012 + ], + [ + 0.00154, + 0.03876, + -0.00124 + ], + [ + -0.02018, + 0.0886, + -0.02503 + ], + [ + 0.00084, + 0.08176, + -0.00177 + ], + [ + 0.01614, + 0.04051, + -0.03052 + ], + [ + -0.04167, + 0.03777, + 0.00429 + ] + ], + [ + [ + 0.09474, + -0.32132, + -0.06828 + ], + [ + -0.00094, + 0.06392, + 0.00035 + ], + [ + -0.10686, + -0.18513, + -0.02452 + ], + [ + 0.03872, + -0.24274, + -0.13557 + ], + [ + -0.11326, + -0.09615, + 0.0002 + ], + [ + -0.03432, + -0.169, + -0.05155 + ], + [ + 0.09388, + -0.25139, + -0.1342 + ], + [ + -0.02109, + 0.01802, + 0.00233 + ], + [ + -0.07019, + -0.21769, + -0.02149 + ], + [ + -0.09006, + -0.1274, + -0.02563 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.R.004": 4.686255793960349, + "DEF-hair.L.005": 40.2787691692223, + "DEF-hair.R.005": 0.15631888339514832, + "DEF-hair.L": 3.6971060377822207, + "DEF-hair.L.007": 2.0160448574603813, + "DEF-hair.L.006": 1.8851661673028555, + "DEF-hair.L.001": 0.12349060048937432 + }, + { + "DEF-hair.R.004": 0.45132666236203267, + "DEF-hair.L.004": 40.2787691692223, + "DEF-hair.L.006": 18.005348521988193, + "DEF-hair.L.007": 11.200201317515079, + "DEF-hair.L": 11.524415420547314, + "DEF-hair.L.001": 1.389903376048438, + "DEF-hair.L.002": 3.6150717720990997, + "DEF-hair.L.003": 4.400683455708296 + }, + { + "DEF-hair.L.005": 18.005348521988193, + "DEF-hair.L.007": 11.514555192106366, + "DEF-hair.L": 0.95175079508802, + "DEF-hair.L.004": 1.8851661673028555 + }, + { + "DEF-hair.L.005": 11.200201317515079, + "DEF-hair.L.006": 11.514555192106366, + "DEF-hair.L": 0.02390360394735, + "DEF-hair.L.004": 2.0160448574603813 + } + ] + }, + { + "class": "hair", + "root_parent": "DEF-spine.003", + "bones": [ + "DEF-hair.R.004", + "DEF-hair.R.005", + "DEF-hair.R.006", + "DEF-hair.R.007" + ], + "tips": [ + [ + -0.0, + 0.13941, + -0.0 + ], + [ + 0.0, + 0.20008, + -0.0 + ], + [ + 0.0, + 0.10005, + -0.0 + ], + [ + 0.108, + -0.20336, + -0.03794 + ] + ], + "hulls": [ + [ + [ + 0.12748, + 0.30272, + 0.00416 + ], + [ + -0.05658, + -0.01695, + -0.04548 + ], + [ + 0.10849, + 0.10936, + -0.0433 + ], + [ + 0.01637, + 0.2129, + -0.00652 + ], + [ + -0.02347, + 0.11443, + -0.05839 + ], + [ + 0.04721, + -0.00236, + -0.04598 + ], + [ + 0.12972, + 0.2226, + -0.03887 + ], + [ + 0.04806, + 0.30826, + -0.0094 + ], + [ + 0.03544, + 0.11472, + -0.00615 + ], + [ + -0.06583, + 0.05736, + -0.04077 + ] + ], + [ + [ + -0.22587, + 0.29058, + 0.02349 + ], + [ + 0.03823, + 0.00201, + -0.07772 + ], + [ + -0.0914, + 0.12009, + 0.04015 + ], + [ + 0.07244, + 0.14233, + -0.1518 + ], + [ + 0.06008, + 0.15735, + 0.02574 + ], + [ + -0.10791, + -0.04429, + -0.05776 + ], + [ + -0.02672, + 0.08048, + -0.13759 + ], + [ + -0.12325, + 0.23952, + 0.0065 + ], + [ + -0.01313, + 0.17179, + -0.05603 + ], + [ + -0.03432, + 0.04033, + 0.0005 + ] + ], + [ + [ + 0.03454, + 0.08821, + 0.00351 + ], + [ + -0.01025, + 0.04049, + -0.04339 + ], + [ + 0.02984, + 0.03706, + 0.01086 + ], + [ + -0.01176, + 0.08105, + -0.02544 + ], + [ + 0.02562, + 0.03913, + -0.0298 + ], + [ + -0.00071, + 0.03816, + -0.00091 + ], + [ + 0.02101, + 0.08799, + -0.02471 + ], + [ + -1e-05, + 0.08116, + -0.00145 + ], + [ + -0.01531, + 0.03991, + -0.03019 + ], + [ + 0.0425, + 0.03716, + 0.00461 + ] + ], + [ + [ + -0.09428, + -0.32225, + -0.06801 + ], + [ + 0.0014, + 0.06298, + 0.00062 + ], + [ + 0.10733, + -0.18606, + -0.02425 + ], + [ + 0.11373, + -0.09708, + 0.00047 + ], + [ + 0.03479, + -0.16994, + -0.05128 + ], + [ + 0.02156, + 0.01709, + 0.0026 + ], + [ + 0.07066, + -0.21863, + -0.02122 + ], + [ + 0.09053, + -0.12833, + -0.02536 + ], + [ + -0.11638, + -0.28965, + -0.06878 + ], + [ + 0.06006, + -0.19855, + -0.05526 + ] + ] + ], + "neighbours": [ + { + "DEF-hair.R.005": 39.8027606329575, + "DEF-hair.R": 3.0752010115277004, + "DEF-hair.R.007": 2.4225536161899783, + "DEF-hair.R.006": 1.7560717991488746, + "DEF-hair.L.004": 4.686255793960349, + "DEF-hair.L.005": 0.45132666236203267 + }, + { + "DEF-hair.R.006": 16.949170511515604, + "DEF-hair.R.007": 4.844983954094674, + "DEF-hair.R": 8.895136039757556, + "DEF-hair.R.008": 1.3949822409393602, + "DEF-hair.R.004": 39.8027606329575, + "DEF-hair.L.004": 0.15631888339514832, + "DEF-skirt.L.003.seg3": 2.6234847961983174 + }, + { + "DEF-hair.R.005": 16.949170511515604, + "DEF-hair.R.007": 7.382812583882695, + "DEF-hair.R": 0.5874295932403306, + "DEF-hair.R.008": 0.08180381100485945, + "DEF-hair.R.004": 1.7560717991488746 + }, + { + "DEF-hair.R.005": 4.844983954094674, + "DEF-hair.R.006": 7.382812583882695, + "DEF-hair.R.004": 2.4225536161899783 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine.001", + "bones": [ + "DEF-skirt", + "DEF-skirt.seg1", + "DEF-skirt.seg2", + "DEF-skirt.seg3" + ], + "tips": [ + [ + -0.0, + 0.04862, + 0.0 + ], + [ + 0.0, + 0.04862, + 0.0 + ], + [ + 0.0, + 0.04862, + 0.0 + ], + [ + -0.0511, + 0.12938, + -0.02471 + ] + ], + "hulls": [ + [ + [ + -0.01048, + 0.00471, + -0.09838 + ], + [ + 0.00854, + -0.00859, + 0.01809 + ], + [ + -0.0271, + 0.03158, + -0.01766 + ], + [ + -0.0049, + 0.0074, + -0.04266 + ], + [ + -0.00582, + -0.0011, + -0.0058 + ], + [ + -0.00356, + 0.02438, + -0.02161 + ], + [ + -0.02431, + 0.0063, + -0.04071 + ], + [ + 0.00058, + -0.00428, + 0.00657 + ], + [ + -0.01003, + 0.00121, + -0.01887 + ], + [ + -0.0145, + 0.00333, + -0.03183 + ] + ], + [ + [ + 0.03347, + 0.04167, + 0.04521 + ], + [ + 0.00529, + 0.02842, + -0.04709 + ], + [ + -0.00166, + 0.02549, + 0.00886 + ], + [ + 0.03107, + 0.00122, + 0.0163 + ], + [ + 0.02719, + 0.03735, + -0.013 + ], + [ + -0.01386, + 0.01375, + -0.02703 + ], + [ + 0.0171, + 0.01031, + -0.01507 + ], + [ + 0.04061, + 0.03722, + 0.02051 + ], + [ + 0.00867, + 0.01004, + 0.02387 + ], + [ + 0.02478, + 0.02369, + 0.00318 + ] + ], + [ + [ + -0.03953, + 0.03328, + -0.05187 + ], + [ + 0.04098, + 0.02712, + 0.02868 + ], + [ + -0.03136, + 0.01869, + 0.01049 + ], + [ + 0.00783, + 0.03339, + -0.01775 + ], + [ + 0.02203, + 0.01142, + 0.00456 + ], + [ + -0.04381, + 0.01432, + -0.02093 + ], + [ + -0.01168, + 0.02406, + 0.03633 + ], + [ + -0.0128, + 0.04482, + 0.00027 + ], + [ + 0.01566, + 0.0355, + 0.04064 + ], + [ + -0.01628, + 0.04258, + -0.02244 + ] + ], + [ + [ + -0.06106, + 0.11464, + -0.09045 + ], + [ + -0.01295, + 0.03749, + 0.09158 + ], + [ + -0.04234, + 0.03208, + -0.02001 + ], + [ + -0.02317, + 0.11069, + -0.0164 + ], + [ + 0.01719, + 0.00293, + 0.03068 + ], + [ + -0.07535, + 0.03421, + -0.07642 + ], + [ + -0.02094, + 0.09802, + 0.0734 + ], + [ + 0.01737, + 0.0661, + 0.02554 + ], + [ + -0.0279, + 0.0519, + -0.06022 + ], + [ + -0.02057, + 0.03865, + 0.01785 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.L": 10.771234691287294, + "DEF-skirt.L.003": 1.4136845819587607, + "DEF-skirt.003": 10.346675467459745, + "DEF-skirt.007": 0.09543175411841748, + "DEF-skirt.003.seg2": 2.6303551266513883, + "DEF-skirt.003.seg3": 1.595806262183024, + "DEF-skirt.seg1": 1.8893422721806523, + "DEF-skirt.003.seg1": 2.168171665547564, + "DEF-skirt.L.004": 0.19603096959511881, + "DEF-skirt.L.seg1": 0.2516482614836598 + }, + { + "DEF-skirt": 1.8893422721806523, + "DEF-skirt.003.seg2": 3.097181898448024, + "DEF-skirt.003.seg1": 1.512647745279899, + "DEF-skirt.003.seg3": 5.169104631170351, + "DEF-skirt.007.seg3": 0.12245594475491542, + "DEF-skirt.005": 0.16033306364800098, + "DEF-skirt.seg2": 2.786772327315499, + "DEF-skirt.009": 0.09372445010591768, + "DEF-skirt.L.seg1": 2.1407108254297182, + "DEF-skirt.007": 0.04866960387074015, + "DEF-skirt.L.seg2": 1.587180682104046, + "DEF-skirt.L.004": 0.20676807425859, + "DEF-skirt.007.seg2": 0.01650988077651644, + "DEF-skirt.L.003": 0.31053856632945737, + "DEF-skirt.003": 0.0982967789470155, + "DEF-skirt.L": 0.12044016764635926 + }, + { + "DEF-skirt.003.seg3": 5.207492995592394, + "DEF-skirt.seg3": 2.161155452056163, + "DEF-skirt.007.seg2": 0.05601081196344071, + "DEF-skirt.L.seg3": 2.7192597439563233, + "DEF-skirt.007.seg1": 0.09495247018795672, + "DEF-skirt.L.001": 0.07432020535983441, + "DEF-skirt.005": 0.4334825864653958, + "DEF-skirt.L.004": 0.14029811314042995, + "DEF-skirt.009": 0.16869629844680606, + "DEF-skirt.seg1": 2.786772327315499, + "DEF-skirt.011": 0.15320185432908007, + "DEF-skirt.007.seg3": 0.1889945161820596, + "DEF-skirt.009.seg1": 0.0714384640111776, + "DEF-skirt.009.seg2": 0.01027086487236381, + "DEF-skirt.005.seg1": 0.007790076778398158, + "DEF-skirt.L.seg2": 3.2111160715999776, + "DEF-skirt.L.003": 0.7391116369404801, + "DEF-skirt.003.seg1": 0.12276459342907578, + "DEF-skirt.L.seg1": 0.3928760868859329, + "DEF-skirt.003.seg2": 0.2754052277329222 + }, + { + "DEF-skirt.002": 0.25142821495506595, + "DEF-skirt.011.seg1": 5.570096570820647, + "DEF-skirt.L.001": 0.4132729913690881, + "DEF-skirt.011": 4.6961528319649295, + "DEF-skirt.003.seg3": 8.365425825133514, + "DEF-skirt.005.seg2": 4.61498402848288, + "DEF-skirt.009": 0.7582600522037454, + "DEF-skirt.009.seg1": 0.9144382488613035, + "DEF-skirt.seg2": 2.161155452056163, + "DEF-skirt.L.seg3": 4.3393369779026685, + "DEF-skirt.011.seg2": 2.8357110381653126, + "DEF-skirt.006": 0.1217170026805725, + "DEF-skirt.005.seg1": 3.920393819893497, + "DEF-skirt.005": 1.3755075436915636, + "DEF-skirt.L.004": 0.8155590405608446, + "DEF-skirt.007.seg3": 0.42513202420982066, + "DEF-skirt.009.seg2": 0.16737698817810986, + "DEF-skirt.007.seg2": 0.04747602245800686, + "DEF-skirt.011.seg3": 0.14448731559887507, + "DEF-skirt.005.seg3": 0.040614199510271565, + "DEF-skirt.L.004.seg1": 0.029836616482513456, + "DEF-skirt.L.seg2": 0.0966744171849947, + "DEF-skirt.L.003": 0.04019258132871978 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine.001", + "bones": [ + "DEF-skirt.003", + "DEF-skirt.003.seg1", + "DEF-skirt.003.seg2", + "DEF-skirt.003.seg3" + ], + "tips": [ + [ + 0.0, + 0.01789, + -0.0 + ], + [ + 0.0, + 0.01789, + -0.0 + ], + [ + -0.0, + 0.01789, + -0.0 + ], + [ + -0.01933, + 0.17699, + 0.01439 + ] + ], + "hulls": [ + [ + [ + -0.05008, + -0.05696, + -0.04666 + ], + [ + 0.03924, + 0.00423, + 0.05153 + ], + [ + -0.02261, + 0.01376, + -0.00513 + ], + [ + -0.01061, + -0.04128, + -0.00124 + ], + [ + -0.04675, + -0.01389, + -0.0306 + ], + [ + 0.01629, + 0.00121, + 0.01377 + ], + [ + 0.00116, + -0.03008, + 0.03884 + ], + [ + 0.04402, + -0.0359, + 0.03842 + ], + [ + -0.03107, + -0.02404, + -0.06145 + ], + [ + -0.03916, + -0.04651, + -0.02056 + ] + ], + [ + [ + 0.05649, + 0.01409, + -0.02176 + ], + [ + -0.02953, + 0.00775, + -0.02538 + ], + [ + 0.01593, + 0.01651, + -0.02885 + ], + [ + 0.03006, + 0.00965, + 0.00505 + ], + [ + -0.00527, + 0.00065, + -0.02313 + ], + [ + 0.03474, + 0.00541, + -0.02441 + ], + [ + 0.03365, + 0.00732, + -0.01286 + ], + [ + 0.05361, + 0.01518, + -0.01043 + ], + [ + 0.0404, + 0.01194, + -0.01841 + ], + [ + -0.02809, + 0.0097, + -0.01762 + ] + ], + [ + [ + 0.05188, + 0.00935, + -0.01886 + ], + [ + -0.00277, + 0.01551, + 0.0356 + ], + [ + -0.00301, + 0.00995, + -0.03276 + ], + [ + 0.0326, + 0.00707, + 0.03816 + ], + [ + -0.01411, + 0.01538, + -0.00122 + ], + [ + 0.02057, + 0.01059, + 0.03576 + ], + [ + 0.04274, + 0.01002, + -0.02639 + ], + [ + 0.00871, + 0.01341, + 0.03461 + ], + [ + -0.00379, + 0.0099, + -0.02409 + ], + [ + -0.00263, + 0.00945, + -0.03186 + ] + ], + [ + [ + 0.00158, + 0.24736, + -0.0107 + ], + [ + -0.06139, + 0.00862, + 0.01247 + ], + [ + 0.05627, + 0.11884, + 0.02984 + ], + [ + 0.02881, + 0.00607, + -0.03046 + ], + [ + -0.04123, + 0.12774, + 0.04129 + ], + [ + 0.00339, + 0.07794, + -0.01952 + ], + [ + -0.01982, + 0.19296, + 0.03012 + ], + [ + 0.03832, + 0.05577, + 0.03226 + ], + [ + -0.03876, + 0.06343, + 0.04015 + ], + [ + 0.01598, + 0.1495, + 0.00201 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt": 10.346675467459745, + "DEF-skirt.007": 17.306699839384407, + "DEF-skirt.003.seg1": 2.1973733196999894, + "DEF-skirt.L": 7.836036998479067, + "DEF-skirt.seg1": 0.0982967789470155, + "DEF-skirt.L.003": 0.05178392130275811, + "DEF-skirt.R": 1.8390214778296556, + "DEF-skirt.007.seg1": 0.03645670787178257 + }, + { + "DEF-skirt.seg1": 1.512647745279899, + "DEF-skirt": 2.168171665547564, + "DEF-skirt.003.seg2": 1.2503227871553237, + "DEF-skirt.003": 2.1973733196999894, + "DEF-skirt.L.seg2": 0.33299535676595077, + "DEF-skirt.seg2": 0.12276459342907578, + "DEF-skirt.L.seg1": 0.2760094692193936, + "DEF-skirt.L.003": 0.049952687783858335 + }, + { + "DEF-skirt": 2.6303551266513883, + "DEF-skirt.003.seg3": 2.4862878647459423, + "DEF-skirt.seg1": 3.097181898448024, + "DEF-skirt.003.seg1": 1.2503227871553237, + "DEF-skirt.L.seg1": 0.4596588821250581, + "DEF-skirt.007": 0.1699214252576411, + "DEF-skirt.L.seg2": 0.29650027410600566, + "DEF-skirt.seg2": 0.2754052277329222, + "DEF-skirt.L.seg3": 0.024977540935678944, + "DEF-skirt.L.003": 0.03862589214435566 + }, + { + "DEF-skirt.006": 0.32341552001905205, + "DEF-skirt.005.seg1": 4.093617441427922, + "DEF-skirt.005": 2.6745725910706306, + "DEF-skirt.seg3": 8.365425825133514, + "DEF-skirt.005.seg2": 4.8612654356533715, + "DEF-skirt.009": 1.1286794898007495, + "DEF-skirt.009.seg1": 0.8940016644600399, + "DEF-skirt.011.seg1": 1.3719167847603355, + "DEF-skirt.seg2": 5.207492995592394, + "DEF-skirt.007.seg2": 1.980115868442557, + "DEF-skirt.011": 1.6883411692987111, + "DEF-skirt.007.seg1": 2.9822736639827294, + "DEF-skirt.L.seg3": 0.5467618653323603, + "DEF-skirt": 1.595806262183024, + "DEF-skirt.003.seg2": 2.4862878647459423, + "DEF-skirt.seg1": 5.169104631170351, + "DEF-skirt.007.seg3": 2.7651239767046945, + "DEF-skirt.009.seg2": 0.18218562382336984, + "DEF-skirt.R.seg2": 0.02413577489838903, + "DEF-skirt.R.seg1": 0.07982489265778714, + "DEF-skirt.007": 1.5140279785753088, + "DEF-skirt.011.seg2": 0.25246157887255904, + "DEF-skirt.005.seg3": 0.04013802336446326, + "DEF-skirt.R.seg3": 0.018324890406280048, + "DEF-skirt.L.seg2": 0.5537837520574962, + "DEF-skirt.L.003": 0.1310899159343887, + "DEF-skirt.R": 0.058546876452437446, + "DEF-skirt.L.seg1": 0.07840127522781742 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine.001", + "bones": [ + "DEF-skirt.L", + "DEF-skirt.L.seg1", + "DEF-skirt.L.seg2", + "DEF-skirt.L.seg3" + ], + "tips": [ + [ + -0.0, + 0.03781, + -0.0 + ], + [ + -0.0, + 0.03781, + -0.0 + ], + [ + 0.0, + 0.03781, + -0.0 + ], + [ + -0.01353, + 0.14671, + 0.03405 + ] + ], + "hulls": [ + [ + [ + -0.01718, + -0.03886, + 0.07057 + ], + [ + 0.03955, + 0.02731, + -0.04543 + ], + [ + 0.01011, + -0.04105, + -0.01324 + ], + [ + -0.01167, + 0.00334, + 0.01467 + ], + [ + -0.01033, + 0.03067, + -0.02984 + ], + [ + 0.00037, + -0.0048, + 0.0536 + ], + [ + 0.034, + -0.00826, + -0.03108 + ], + [ + 0.01864, + -0.03554, + -0.04963 + ], + [ + -0.01519, + -0.03227, + 0.00973 + ], + [ + -0.00034, + 0.00351, + -0.01547 + ] + ], + [ + [ + -0.01733, + 0.03016, + 0.03129 + ], + [ + 0.00029, + 0.03363, + -0.01135 + ], + [ + 0.01304, + 0.01139, + 0.02869 + ], + [ + -0.0117, + 0.00289, + 0.01837 + ], + [ + -0.00597, + 0.03249, + 0.01887 + ], + [ + 0.002, + 0.03388, + 0.00543 + ], + [ + -0.0113, + 0.00187, + 0.01831 + ], + [ + 0.00094, + 0.03396, + 0.00519 + ], + [ + -0.01822, + 0.03022, + 0.03066 + ], + [ + 0.01244, + 0.01059, + 0.02825 + ] + ], + [ + [ + 0.02797, + 0.02078, + -0.06929 + ], + [ + -0.00319, + 0.02407, + 0.05732 + ], + [ + 0.03008, + 0.0173, + -0.01102 + ], + [ + 0.00404, + 0.00325, + 0.00995 + ], + [ + 0.02352, + 0.03451, + 0.01921 + ], + [ + 0.01962, + 0.01154, + -0.03737 + ], + [ + -0.01275, + 0.00262, + 0.04527 + ], + [ + 0.03356, + 0.02949, + -0.04329 + ], + [ + 0.0172, + 0.00476, + -0.0589 + ], + [ + 0.01944, + 0.01544, + 0.01925 + ] + ], + [ + [ + -0.07888, + 0.14866, + 0.10517 + ], + [ + 0.02098, + 0.00616, + -0.07077 + ], + [ + 0.00728, + 0.17813, + -0.01431 + ], + [ + -0.04021, + 0.01427, + 0.07286 + ], + [ + -0.02487, + 0.08854, + -0.00247 + ], + [ + 0.02708, + 0.01623, + 0.01893 + ], + [ + 0.00583, + 0.14213, + -0.07387 + ], + [ + -0.02766, + 0.13164, + 0.04888 + ], + [ + -0.0686, + 0.06595, + 0.08917 + ], + [ + -0.00615, + 0.02573, + -0.03441 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt": 10.771234691287294, + "DEF-skirt.L.003": 16.419495031726157, + "DEF-skirt.L.003.seg1": 1.5219704851834268, + "DEF-skirt.L.003.seg3": 1.5856033828218843, + "DEF-skirt.R.003": 3.3102703706591687, + "DEF-skirt.003": 7.836036998479067, + "DEF-skirt.L.seg1": 0.8892123245093044, + "DEF-skirt.seg1": 0.12044016764635926, + "DEF-skirt.L.003.seg2": 0.9837299947917074, + "DEF-skirt.R": 0.7833441613586325 + }, + { + "DEF-skirt.L.003": 4.760126868318332, + "DEF-skirt.seg1": 2.1407108254297182, + "DEF-skirt.L.seg2": 4.246796835031998, + "DEF-skirt.003.seg2": 0.4596588821250581, + "DEF-skirt.L.003.seg1": 0.6595836843532097, + "DEF-skirt.seg2": 0.3928760868859329, + "DEF-skirt.003.seg1": 0.2760094692193936, + "DEF-skirt": 0.2516482614836598, + "DEF-skirt.L": 0.8892123245093044, + "DEF-skirt.L.003.seg2": 0.322305533592365, + "DEF-skirt.003.seg3": 0.07840127522781742 + }, + { + "DEF-skirt.L.004": 0.06935733543747569, + "DEF-skirt.L.003": 4.430261283824864, + "DEF-skirt.L.seg3": 3.686576538966275, + "DEF-skirt.L.003.seg3": 0.009721631050302243, + "DEF-skirt.L.003.seg2": 0.19439024437881924, + "DEF-skirt.L.seg1": 4.246796835031998, + "DEF-skirt.seg1": 1.587180682104046, + "DEF-skirt.L.003.seg1": 0.1957218245564052, + "DEF-skirt.seg2": 3.2111160715999776, + "DEF-skirt.003.seg3": 0.5537837520574962, + "DEF-skirt.seg3": 0.0966744171849947, + "DEF-skirt.003.seg1": 0.33299535676595077, + "DEF-skirt.003.seg2": 0.29650027410600566, + "DEF-skirt.L.001": 0.10371684437270923, + "DEF-skirt.011": 0.01208546172060343 + }, + { + "DEF-skirt.L.001.seg2": 0.1699154302186351, + "DEF-skirt.L.002": 0.10102962666415694, + "DEF-skirt.L.001.seg1": 0.19093190472623134, + "DEF-skirt.L.005": 0.04318668795316161, + "DEF-skirt.L.004": 3.8285142497834164, + "DEF-skirt.011.seg3": 4.602221938080493, + "DEF-skirt.011.seg2": 3.722823119417367, + "DEF-skirt.L.004.seg1": 0.9764221426855458, + "DEF-skirt.L.003": 3.6402760699503562, + "DEF-skirt.L.seg2": 3.686576538966275, + "DEF-skirt.L.001": 1.7430155018692777, + "DEF-skirt.seg3": 4.3393369779026685, + "DEF-skirt.seg2": 2.7192597439563233, + "DEF-skirt.003.seg3": 0.5467618653323603, + "DEF-skirt.L.003.seg3": 1.533915214427354, + "DEF-skirt.L.003.seg2": 0.7716510583968516, + "DEF-skirt.005.seg2": 0.03759796665737425, + "DEF-skirt.011.seg1": 0.6236902469398551, + "DEF-skirt.L.003.seg1": 0.7488992127140781, + "DEF-skirt.011": 0.13239584627096912, + "DEF-skirt.003.seg2": 0.024977540935678944 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine.001", + "bones": [ + "DEF-skirt.L.003", + "DEF-skirt.L.003.seg1", + "DEF-skirt.L.003.seg2", + "DEF-skirt.L.003.seg3" + ], + "tips": [ + [ + -0.0, + 0.01473, + -0.0 + ], + [ + 0.0, + 0.01473, + 0.0 + ], + [ + 0.0, + 0.01473, + 0.0 + ], + [ + 0.0279, + 0.14363, + -0.02295 + ] + ], + "hulls": [ + [ + [ + -0.11938, + 0.01472, + 0.08554 + ], + [ + 0.03737, + -0.00762, + -0.02167 + ], + [ + -0.04228, + -0.06138, + 0.03614 + ], + [ + -0.06996, + 0.01341, + 0.01895 + ], + [ + -0.01719, + 0.00218, + -0.0034 + ], + [ + -0.0946, + -0.01653, + 0.05222 + ], + [ + -0.02027, + -0.04183, + -0.005 + ], + [ + -0.11124, + -0.02195, + 0.08811 + ], + [ + -0.00607, + -0.04564, + 0.02964 + ], + [ + 0.02042, + 0.01328, + 0.00447 + ] + ], + [ + [ + 0.09911, + 0.00038, + -0.10791 + ], + [ + -0.02717, + 0.01394, + 0.0105 + ], + [ + 0.04307, + 0.00641, + -0.04658 + ], + [ + 0.03046, + 0.00077, + -0.00282 + ], + [ + 0.07423, + 0.01319, + -0.03945 + ], + [ + -0.00096, + 0.0041, + -0.00347 + ], + [ + -0.01848, + 0.0026, + 0.00091 + ], + [ + 0.00014, + 0.00336, + 0.01308 + ], + [ + 0.02267, + 0.00104, + -0.0143 + ], + [ + -0.02332, + 0.00279, + 0.01466 + ] + ], + [ + [ + 0.08569, + 0.00881, + -0.09381 + ], + [ + -0.04002, + 0.00864, + 0.02482 + ], + [ + 0.02842, + 0.00921, + -0.02168 + ], + [ + 0.06602, + 0.00044, + -0.04476 + ], + [ + -0.00207, + 0.01085, + 0.01334 + ], + [ + 0.03399, + 0.0046, + -0.00424 + ], + [ + 0.03981, + 0.00639, + -0.03647 + ], + [ + -0.00247, + 0.01096, + 0.01233 + ], + [ + -0.04035, + 0.00881, + 0.02379 + ], + [ + 0.08488, + 0.00908, + -0.0945 + ] + ], + [ + [ + -0.1601, + 0.2389, + 0.17881 + ], + [ + 0.18049, + 0.03004, + -0.17856 + ], + [ + -0.044, + 0.01497, + 0.02135 + ], + [ + 0.06877, + 0.11585, + -0.07637 + ], + [ + 0.08415, + 0.01272, + -0.07912 + ], + [ + 0.03725, + 0.00859, + -0.00617 + ], + [ + 0.15246, + 0.07652, + -0.13417 + ], + [ + -0.11244, + 0.19171, + 0.16551 + ], + [ + 0.05084, + 0.0605, + -0.06074 + ], + [ + 0.12019, + 0.02429, + -0.12566 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt": 1.4136845819587607, + "DEF-skirt.L": 16.419495031726157, + "DEF-skirt.L.004": 0.21646333191305667, + "DEF-skirt.L.seg3": 3.6402760699503562, + "DEF-skirt.L.seg2": 4.430261283824864, + "DEF-skirt.L.001": 0.23974937651991812, + "DEF-skirt.L.003.seg1": 6.056427661191389, + "DEF-skirt.R.003": 3.8759857822260786, + "DEF-skirt.L.seg1": 4.760126868318332, + "DEF-skirt.seg2": 0.7391116369404801, + "DEF-skirt.003.seg3": 0.1310899159343887, + "DEF-skirt.seg3": 0.04019258132871978, + "DEF-skirt.seg1": 0.31053856632945737, + "DEF-skirt.003.seg1": 0.049952687783858335, + "DEF-skirt.003.seg2": 0.03862589214435566, + "DEF-skirt.R": 0.7233312277085479, + "DEF-skirt.003": 0.05178392130275811 + }, + { + "DEF-skirt.L.003": 6.056427661191389, + "DEF-skirt.R.003": 3.5362091427452844, + "DEF-skirt.L.003.seg2": 1.4465578586045362, + "DEF-skirt.R.003.seg1": 0.0075091120423149416, + "DEF-skirt.L.004": 0.3003293425343978, + "DEF-skirt.L.seg3": 0.7488992127140781, + "DEF-skirt.L.seg2": 0.1957218245564052, + "DEF-skirt.L.seg1": 0.6595836843532097, + "DEF-skirt.L": 1.5219704851834268, + "DEF-skirt.R": 0.6615938216234099 + }, + { + "DEF-skirt.L.003.seg3": 1.7624733424013248, + "DEF-skirt.R.003": 5.005937962569201, + "DEF-skirt.L.004": 0.677365454643255, + "DEF-skirt.L.seg3": 0.7716510583968516, + "DEF-skirt.L.seg2": 0.19439024437881924, + "DEF-skirt.L.003.seg1": 1.4465578586045362, + "DEF-skirt.R.003.seg1": 0.1158529500045482, + "DEF-skirt.L": 0.9837299947917074, + "DEF-skirt.L.seg1": 0.322305533592365, + "DEF-skirt.R": 0.7641415590885685 + }, + { + "DEF-skirt.L.003.seg2": 1.7624733424013248, + "DEF-skirt.R.003": 4.763912732133256, + "DEF-skirt.L.005.seg2": 0.07865966284521508, + "DEF-skirt.L.004.seg3": 0.5971418419198388, + "DEF-skirt.L.005.seg1": 0.09606776911003378, + "DEF-skirt.R.005.seg3": 0.5377347293486077, + "DEF-skirt.L.005": 1.1878016405318974, + "DEF-skirt.L.004.seg2": 5.510255386232652, + "DEF-skirt.R.003.seg3": 5.539891594630126, + "DEF-skirt.L.004.seg1": 7.552187895272386, + "DEF-skirt.R.004": 1.6662561376102858, + "DEF-skirt.L.004": 5.274030080809885, + "DEF-skirt.L.seg3": 1.533915214427354, + "DEF-skirt.L.seg2": 0.009721631050302243, + "DEF-skirt.R.004.seg1": 0.2699483450459348, + "DEF-skirt.R.003.seg2": 1.2716413510540925, + "DEF-skirt.R.003.seg1": 1.7362462282224314, + "DEF-skirt.L": 1.5856033828218843, + "DEF-skirt.R": 0.9611470727391278, + "DEF-skirt.R.005": 0.04733610412909939, + "DEF-hair.R.005": 2.6234847961983174 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine.001", + "bones": [ + "DEF-skirt.R", + "DEF-skirt.R.seg1", + "DEF-skirt.R.seg2", + "DEF-skirt.R.seg3" + ], + "tips": [ + [ + -0.0, + 0.05443, + -0.0 + ], + [ + -0.0, + 0.05443, + 0.0 + ], + [ + -0.0, + 0.05443, + 0.0 + ], + [ + -0.01241, + 0.17736, + -0.03549 + ] + ], + "hulls": [ + [ + [ + -0.02114, + 0.03224, + -0.06298 + ], + [ + 0.01753, + -0.04064, + 0.03519 + ], + [ + -0.03099, + -0.03708, + -0.04283 + ], + [ + -0.01695, + 0.00322, + 0.00621 + ], + [ + -0.04181, + 0.00588, + -0.02927 + ], + [ + -0.01038, + 0.04966, + -0.02862 + ], + [ + 0.0017, + -0.02936, + -0.02451 + ], + [ + 0.0158, + -0.00613, + 0.04503 + ], + [ + -0.00272, + -0.04123, + 0.00572 + ], + [ + 0.01175, + 0.0006, + 0.01497 + ] + ], + [ + [ + -0.0338, + 0.02373, + -0.06959 + ], + [ + 0.03166, + 0.01198, + 0.06806 + ], + [ + -0.03022, + 0.02067, + 0.00739 + ], + [ + 0.01239, + 0.02598, + 0.0297 + ], + [ + -0.01945, + 0.05274, + -0.03983 + ], + [ + -0.00359, + 0.01483, + -0.02065 + ], + [ + -0.00875, + 0.00158, + 0.04019 + ], + [ + -0.01189, + 0.04561, + -0.00244 + ], + [ + -0.03758, + 0.02729, + -0.03186 + ], + [ + -0.01054, + 0.01781, + -0.06306 + ] + ], + [ + [ + 0.03154, + 0.02968, + 0.0552 + ], + [ + -0.04492, + 0.00877, + -0.04155 + ], + [ + -0.00477, + 0.03216, + 0.00435 + ], + [ + -0.0023, + 0.02461, + -0.05262 + ], + [ + -0.03492, + 0.00104, + 0.00234 + ], + [ + -0.01097, + 0.02919, + -0.03038 + ], + [ + -0.00754, + 0.01281, + -0.00936 + ], + [ + 0.01201, + 0.03077, + 0.01999 + ], + [ + 0.02955, + 0.015, + 0.03963 + ], + [ + -0.01696, + 0.03229, + -0.01065 + ] + ], + [ + [ + -0.03429, + 0.15006, + -0.0334 + ], + [ + 0.03093, + 0.03534, + 0.06112 + ], + [ + -0.01386, + 0.02495, + -0.0922 + ], + [ + 0.02118, + 0.12149, + 0.04485 + ], + [ + -0.02544, + 0.03965, + -0.01748 + ], + [ + -0.01071, + 0.09867, + -0.07886 + ], + [ + -0.01355, + 0.09263, + -0.00563 + ], + [ + 0.01192, + 0.03451, + 0.01784 + ], + [ + 0.01219, + 0.03774, + -0.02933 + ], + [ + 0.02983, + 0.09163, + 0.06656 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.R.003": 21.153115252758028, + "DEF-skirt.007": 18.84494971969905, + "DEF-skirt.007.seg1": 0.7598426687282531, + "DEF-skirt.R.seg1": 3.2886585052114654, + "DEF-skirt.007.seg3": 0.5922937660442745, + "DEF-skirt.R.003.seg1": 0.958425160444825, + "DEF-skirt.003.seg3": 0.058546876452437446, + "DEF-skirt.007.seg2": 0.17227260051985324, + "DEF-skirt.L": 0.7833441613586325, + "DEF-skirt.L.003.seg1": 0.6615938216234099, + "DEF-skirt.L.003.seg3": 0.9611470727391278, + "DEF-skirt.L.003.seg2": 0.7641415590885685, + "DEF-skirt.L.003": 0.7233312277085479, + "DEF-skirt.003": 1.8390214778296556, + "DEF-skirt.R.003.seg2": 0.0144911590928406 + }, + { + "DEF-skirt.R.seg2": 3.9264200827331246, + "DEF-skirt.007.seg3": 5.990418387665851, + "DEF-skirt.003.seg3": 0.07982489265778714, + "DEF-skirt.R.003.seg1": 2.624934006784733, + "DEF-skirt.R": 3.2886585052114654, + "DEF-skirt.R.003.seg2": 0.26822713323056313, + "DEF-skirt.R.003": 1.2574131429974016, + "DEF-skirt.007.seg2": 0.014808934502137577, + "DEF-skirt.R.001": 0.07198854155120338, + "DEF-skirt.R.003.seg3": 0.02249763078136091 + }, + { + "DEF-skirt.R.003.seg3": 1.6634802939376083, + "DEF-skirt.R.003.seg2": 0.7907999176082137, + "DEF-skirt.R.001": 1.5263588944722746, + "DEF-skirt.007.seg3": 2.713153852899096, + "DEF-skirt.R.seg1": 3.9264200827331246, + "DEF-skirt.003.seg3": 0.02413577489838903, + "DEF-skirt.R.004": 0.16426175880538585, + "DEF-skirt.R.seg3": 0.09424409085089636, + "DEF-skirt.R.003.seg1": 0.04449534258909127 + }, + { + "DEF-skirt.R.005": 0.020397376975925585, + "DEF-skirt.R.001.seg1": 0.15912477238749645, + "DEF-skirt.R.004.seg1": 0.19151314001136188, + "DEF-skirt.R.004.seg2": 0.04455171348530562, + "DEF-skirt.009.seg3": 4.171412457713149, + "DEF-skirt.R.003.seg3": 7.040713262058846, + "DEF-skirt.009.seg2": 0.6798349904287918, + "DEF-skirt.009.seg1": 0.03686175164852512, + "DEF-skirt.007.seg3": 0.939187919331689, + "DEF-skirt.R.001": 0.6250004873314858, + "DEF-skirt.R.seg2": 0.09424409085089636, + "DEF-skirt.009": 0.08669143553370695, + "DEF-skirt.R.004": 0.21310817027062778, + "DEF-skirt.003.seg3": 0.018324890406280048 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine.001", + "bones": [ + "DEF-skirt.R.003", + "DEF-skirt.R.003.seg1", + "DEF-skirt.R.003.seg2", + "DEF-skirt.R.003.seg3" + ], + "tips": [ + [ + -0.0, + 0.03347, + -0.0 + ], + [ + -0.0, + 0.03347, + -0.0 + ], + [ + -0.0, + 0.03347, + 0.0 + ], + [ + -0.02389, + 0.20399, + 0.00927 + ] + ], + "hulls": [ + [ + [ + 0.0844, + -0.03265, + 0.07657 + ], + [ + -0.05994, + -0.02462, + -0.01868 + ], + [ + 0.04684, + 0.03075, + -0.00621 + ], + [ + 0.01437, + -0.03527, + -0.00805 + ], + [ + 0.05972, + -0.01943, + 0.03067 + ], + [ + -0.02106, + -0.00097, + 0.013 + ], + [ + 0.08081, + 0.02144, + 0.01002 + ], + [ + -0.02151, + -0.02616, + -0.02293 + ], + [ + 0.05128, + -0.00727, + -0.00235 + ], + [ + -0.05568, + 0.00449, + 0.0053 + ] + ], + [ + [ + -0.06047, + 0.01677, + -0.03231 + ], + [ + 0.00516, + 0.03249, + 0.00244 + ], + [ + -0.02776, + 0.02669, + -0.00895 + ], + [ + -0.04241, + 0.02239, + -0.02278 + ], + [ + -0.01253, + 0.03006, + 0.00094 + ], + [ + 0.00139, + 0.01878, + 0.00639 + ], + [ + -0.06013, + 0.00359, + -0.03558 + ], + [ + -0.04959, + 0.02001, + -0.02242 + ], + [ + -0.05991, + 0.01697, + -0.03323 + ], + [ + -0.04301, + 0.02224, + -0.02188 + ] + ], + [ + [ + -0.10588, + 0.01972, + -0.04355 + ], + [ + 0.02367, + 0.0003, + 0.00112 + ], + [ + 0.02368, + 0.00034, + 3e-05 + ], + [ + -0.1051, + 0.02009, + -0.04422 + ] + ], + [ + [ + -0.0632, + 0.17938, + -0.00054 + ], + [ + 0.06162, + 0.01689, + 0.02182 + ], + [ + -0.08635, + 0.01546, + -0.03002 + ], + [ + 0.04345, + 0.13606, + 0.01532 + ], + [ + -0.0313, + 0.08171, + 0.01205 + ], + [ + -0.03046, + 0.01385, + 0.01574 + ], + [ + -0.07855, + 0.12217, + -0.02139 + ], + [ + -0.01458, + 0.15008, + 0.01746 + ], + [ + 0.0647, + 0.08471, + 0.01586 + ], + [ + -0.09754, + 0.06735, + -0.03116 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.L.003.seg3": 4.763912732133256, + "DEF-skirt.L.003.seg2": 5.005937962569201, + "DEF-skirt.R": 21.153115252758028, + "DEF-skirt.007": 1.0301041294739737, + "DEF-skirt.007.seg1": 0.22632611848096784, + "DEF-skirt.L.003.seg1": 3.5362091427452844, + "DEF-skirt.L.003": 3.8759857822260786, + "DEF-skirt.L": 3.3102703706591687, + "DEF-skirt.R.seg1": 1.2574131429974016, + "DEF-skirt.R.003.seg1": 1.0807317991880538 + }, + { + "DEF-skirt.L.003.seg2": 0.1158529500045482, + "DEF-skirt.L.003.seg1": 0.0075091120423149416, + "DEF-skirt.L.003.seg3": 1.7362462282224314, + "DEF-skirt.R.003.seg2": 2.390378788205351, + "DEF-skirt.R.seg1": 2.624934006784733, + "DEF-skirt.R": 0.958425160444825, + "DEF-skirt.R.003": 1.0807317991880538, + "DEF-skirt.R.seg2": 0.04449534258909127 + }, + { + "DEF-skirt.R.003.seg3": 0.33796963284365766, + "DEF-skirt.R.seg2": 0.7907999176082137, + "DEF-skirt.R.001": 0.15603431496875952, + "DEF-skirt.L.003.seg3": 1.2716413510540925, + "DEF-skirt.R.003.seg1": 2.390378788205351, + "DEF-skirt.R.004": 0.016111401937035363, + "DEF-skirt.R.seg1": 0.26822713323056313, + "DEF-skirt.R": 0.0144911590928406 + }, + { + "DEF-skirt.R.005": 1.091596357605304, + "DEF-skirt.L.005": 0.1715765989223872, + "DEF-skirt.L.003.seg3": 5.539891594630126, + "DEF-skirt.R.005.seg3": 0.1178247570438451, + "DEF-skirt.R.002": 0.012816917290188745, + "DEF-skirt.R.001.seg1": 0.11280987179495439, + "DEF-skirt.L.004.seg2": 5.3545195307750895, + "DEF-skirt.L.004.seg1": 1.9171418898471628, + "DEF-skirt.R.004": 4.464269337643888, + "DEF-skirt.L.004.seg3": 1.2748603054718204, + "DEF-skirt.L.004": 0.845388225157766, + "DEF-skirt.R.004.seg1": 3.6078875543646496, + "DEF-skirt.R.seg3": 7.040713262058846, + "DEF-skirt.R.004.seg2": 3.272260373183556, + "DEF-skirt.R.seg2": 1.6634802939376083, + "DEF-skirt.R.003.seg2": 0.33796963284365766, + "DEF-skirt.R.001": 0.07035853311526641, + "DEF-skirt.R.005.seg1": 0.15873896284533995, + "DEF-skirt.R.seg1": 0.02249763078136091 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine.001", + "bones": [ + "DEF-skirt.007", + "DEF-skirt.007.seg1", + "DEF-skirt.007.seg2", + "DEF-skirt.007.seg3" + ], + "tips": [ + [ + 0.0, + 0.02335, + 0.0 + ], + [ + -0.0, + 0.02335, + -0.0 + ], + [ + -0.0, + 0.02335, + -0.0 + ], + [ + 0.03535, + 0.15778, + -0.0477 + ] + ], + "hulls": [ + [ + [ + 0.07416, + 0.02127, + -0.0527 + ], + [ + -0.02713, + -0.02362, + 0.00114 + ], + [ + 0.04523, + -0.0157, + 0.01881 + ], + [ + 0.029, + 0.01019, + -0.02257 + ], + [ + 0.00178, + 0.0014, + 0.02398 + ], + [ + 0.01602, + -0.0364, + 0.01125 + ], + [ + 0.05427, + -0.02101, + -0.0099 + ], + [ + 0.02523, + 0.01242, + 0.00803 + ], + [ + -0.00408, + -0.00653, + -0.00666 + ], + [ + 0.04906, + 0.00665, + 0.00352 + ] + ], + [ + [ + 0.02545, + 0.01511, + -0.05992 + ], + [ + 0.02253, + 0.01262, + -0.04982 + ], + [ + 0.02635, + 0.0152, + -0.0593 + ], + [ + 0.02174, + 0.01292, + -0.05051 + ] + ], + [ + [ + 0.01812, + 0.00211, + -0.04788 + ], + [ + 0.01567, + 0.01366, + -0.02762 + ], + [ + 0.01857, + 0.00977, + -0.03272 + ], + [ + 0.0163, + 0.01437, + -0.02815 + ], + [ + 0.01752, + 0.01005, + -0.0328 + ], + [ + 0.01709, + 0.00219, + -0.04823 + ] + ], + [ + [ + 0.07896, + 0.05691, + -0.14429 + ], + [ + -0.01373, + 0.01928, + 0.01223 + ], + [ + 0.03479, + 0.13195, + -0.03282 + ], + [ + 0.01653, + 0.03488, + -0.07348 + ], + [ + 0.04884, + 0.09787, + -0.09754 + ], + [ + 0.02565, + 0.07312, + -0.01098 + ], + [ + -0.004, + 6e-05, + -0.03836 + ], + [ + 0.01205, + 0.13481, + 0.00179 + ], + [ + 0.04044, + 0.05296, + -0.0456 + ], + [ + 0.04575, + 0.02487, + -0.09731 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.R": 18.84494971969905, + "DEF-skirt.R.003": 1.0301041294739737, + "DEF-skirt.007.seg1": 1.8201384429565644, + "DEF-skirt": 0.09543175411841748, + "DEF-skirt.003": 17.306699839384407, + "DEF-skirt.003.seg3": 1.5140279785753088, + "DEF-skirt.seg1": 0.04866960387074015, + "DEF-skirt.003.seg2": 0.1699214252576411 + }, + { + "DEF-skirt.R.003": 0.22632611848096784, + "DEF-skirt.R": 0.7598426687282531, + "DEF-skirt.007": 1.8201384429565644, + "DEF-skirt.003.seg3": 2.9822736639827294, + "DEF-skirt.seg2": 0.09495247018795672, + "DEF-skirt.007.seg2": 0.7137936641847316, + "DEF-skirt.003": 0.03645670787178257 + }, + { + "DEF-skirt.seg2": 0.05601081196344071, + "DEF-skirt.003.seg3": 1.980115868442557, + "DEF-skirt.seg3": 0.04747602245800686, + "DEF-skirt.005": 0.016322135250931735, + "DEF-skirt.007.seg1": 0.7137936641847316, + "DEF-skirt.007.seg3": 0.623153370820441, + "DEF-skirt.seg1": 0.01650988077651644, + "DEF-skirt.R": 0.17227260051985324, + "DEF-skirt.R.seg1": 0.014808934502137577 + }, + { + "DEF-skirt.010.seg1": 0.015989096555482363, + "DEF-skirt.R.001": 0.2677451548737494, + "DEF-skirt.010": 0.23355445061974422, + "DEF-skirt.009.seg2": 4.5295165581740635, + "DEF-skirt.009.seg3": 1.5188658246344597, + "DEF-skirt.009.seg1": 2.906074152306811, + "DEF-skirt.003.seg3": 2.7651239767046945, + "DEF-skirt.R.seg2": 2.713153852899096, + "DEF-skirt.R.seg1": 5.990418387665851, + "DEF-skirt.005.seg2": 0.3911211444359659, + "DEF-skirt.005.seg1": 0.7179370267663041, + "DEF-skirt.seg1": 0.12245594475491542, + "DEF-skirt.seg3": 0.42513202420982066, + "DEF-skirt.009": 9.43662584664051, + "DEF-skirt.005": 0.23030644633612807, + "DEF-skirt.R": 0.5922937660442745, + "DEF-skirt.R.seg3": 0.939187919331689, + "DEF-skirt.seg2": 0.1889945161820596, + "DEF-skirt.007.seg2": 0.623153370820441 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.002", + "DEF-skirt.002.seg1", + "DEF-skirt.002.seg2", + "DEF-skirt.002.seg3" + ], + "tips": [ + [ + -0.0, + 0.02131, + 0.0 + ], + [ + 0.0, + 0.02131, + 0.0 + ], + [ + 0.0, + 0.02131, + 0.0 + ], + [ + 0.00417, + 0.04451, + 0.05542 + ] + ], + "hulls": [ + [ + [ + 0.00491, + 0.02036, + 0.02338 + ], + [ + 0.00118, + -0.01008, + -0.00922 + ], + [ + -0.00535, + 0.02023, + 0.02123 + ], + [ + 0.00431, + 0.02083, + 0.0226 + ], + [ + 0.00206, + -0.00953, + -0.00956 + ], + [ + -0.00449, + 0.02079, + 0.02086 + ] + ], + [], + [ + [ + -0.006, + 0.00129, + -0.04552 + ], + [ + 0.00173, + 0.0009, + -0.04551 + ], + [ + 0.00111, + 0.00086, + -0.04461 + ], + [ + -0.00544, + 0.00115, + -0.0446 + ] + ], + [ + [ + 0.00349, + 0.00442, + 0.04343 + ], + [ + -0.0032, + 0.02814, + 0.02127 + ], + [ + 0.00456, + 0.02879, + 0.02285 + ], + [ + -0.00265, + 0.02743, + 0.02189 + ], + [ + 0.00243, + 0.00429, + 0.04321 + ], + [ + 0.00375, + 0.0281, + 0.0231 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.002.seg1": 0.9936210139550372, + "DEF-skirt.011.seg2": 0.6140337423742315, + "DEF-skirt.011.seg1": 1.0432309729624893, + "DEF-skirt.seg3": 0.25142821495506595, + "DEF-skirt.L.001": 0.17433376040275084, + "DEF-skirt.011": 0.17798888515931122 + }, + { + "DEF-skirt.002.seg2": 1.0752501775274976, + "DEF-skirt.011.seg3": 0.3210989021621229, + "DEF-skirt.002": 0.9936210139550372, + "DEF-skirt.011.seg2": 0.5566789920191999, + "DEF-skirt.L.002": 0.09750582234609562, + "DEF-skirt.011.seg1": 0.07562430091019157, + "DEF-skirt.L.001": 0.23053920218779567, + "DEF-skirt.L.001.seg1": 0.07011653736356815 + }, + { + "DEF-skirt.002.seg1": 1.0752501775274976, + "DEF-skirt.011.seg3": 0.5137219364069667, + "DEF-skirt.L.002": 0.08363487573375844, + "DEF-skirt.002.seg3": 0.15905285339786146, + "DEF-skirt.011.seg2": 0.09621669328872662, + "DEF-skirt.L.001.seg1": 0.07726076569748527, + "DEF-skirt.011.seg1": 0.012493393667064645, + "DEF-skirt.L.001": 0.03671082080693666 + }, + { + "DEF-skirt.L.002": 0.5233835144511887, + "DEF-skirt.011.seg3": 0.8674111035481489, + "DEF-skirt.L.001.seg1": 0.45340715496411454, + "DEF-skirt.002.seg2": 0.15905285339786146, + "DEF-skirt.011.seg2": 0.11034352255838153 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.011", + "DEF-skirt.011.seg1", + "DEF-skirt.011.seg2", + "DEF-skirt.011.seg3" + ], + "tips": [ + [ + 0.0, + 0.04148, + -0.0 + ], + [ + 0.0, + 0.04148, + 0.0 + ], + [ + 0.0, + 0.04148, + -0.0 + ], + [ + 0.01816, + 0.02605, + 0.10442 + ] + ], + "hulls": [ + [ + [ + 0.04555, + 0.01719, + 0.05065 + ], + [ + -0.01818, + 0.0315, + -0.01141 + ], + [ + 0.04426, + 0.04093, + 0.00452 + ], + [ + -0.01417, + 0.04083, + 0.02064 + ], + [ + 0.01652, + 0.02853, + 0.01278 + ], + [ + 0.0061, + 0.04136, + -0.0152 + ], + [ + 0.04377, + 0.02788, + 0.02971 + ], + [ + -0.00782, + 0.01914, + 0.01736 + ], + [ + 0.00605, + 0.04099, + -0.01622 + ], + [ + 0.01608, + 0.02948, + 0.01247 + ] + ], + [ + [ + -0.03298, + 0.02109, + -0.1014 + ], + [ + 0.044, + 0.0191, + 0.03982 + ], + [ + -0.02397, + 0.02043, + -0.02027 + ], + [ + -0.01378, + 0.02499, + 0.05045 + ], + [ + 0.01469, + 0.03475, + -0.05242 + ], + [ + -0.02705, + 0.01748, + -0.06127 + ], + [ + 0.00949, + 0.00032, + -0.02296 + ], + [ + -0.02195, + 0.00929, + 0.01784 + ], + [ + 0.00381, + 0.03256, + -0.00438 + ], + [ + 0.02225, + 0.02837, + 0.01891 + ] + ], + [ + [ + 0.00183, + 0.02126, + 0.06646 + ], + [ + -0.00828, + 0.0029, + -0.04347 + ], + [ + 0.05092, + 0.0052, + 0.02605 + ], + [ + 0.0205, + 0.03405, + -0.00684 + ], + [ + -0.00219, + 0.02441, + 0.02832 + ], + [ + -0.01201, + 0.00779, + -0.00708 + ], + [ + 0.02938, + 0.00509, + -0.0261 + ], + [ + 0.02464, + 0.00163, + 0.01023 + ], + [ + -0.03066, + 0.01182, + -0.03711 + ], + [ + 0.0055, + 0.02391, + -0.02374 + ] + ], + [ + [ + 0.00434, + 0.01589, + 0.1013 + ], + [ + 0.03806, + 0.00779, + 0.00774 + ], + [ + -0.00642, + 0.01225, + 0.04932 + ], + [ + 0.00873, + 0.00112, + 0.01833 + ], + [ + 0.01395, + 0.02871, + 0.07745 + ], + [ + -0.00506, + 0.00682, + 0.08185 + ], + [ + 0.0056, + 0.02706, + 0.07629 + ], + [ + 0.01711, + 0.00205, + 0.01798 + ], + [ + 0.00143, + 0.01361, + 0.04976 + ], + [ + -0.00568, + 0.01153, + 0.04966 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.011.seg1": 4.58106049623973, + "DEF-skirt.002": 0.17798888515931122, + "DEF-skirt.seg3": 4.6961528319649295, + "DEF-skirt.003.seg3": 1.6883411692987111, + "DEF-skirt.005.seg2": 1.117578855302317, + "DEF-skirt.005.seg1": 1.9069345583682153, + "DEF-skirt.005": 0.20566438243868845, + "DEF-skirt.L.seg3": 0.13239584627096912, + "DEF-skirt.seg2": 0.15320185432908007, + "DEF-skirt.L.seg2": 0.01208546172060343, + "DEF-skirt.005.seg3": 0.006026445906482214 + }, + { + "DEF-skirt.011": 4.58106049623973, + "DEF-skirt.011.seg2": 7.206079765921628, + "DEF-skirt.L.002": 0.02658579166615116, + "DEF-skirt.002.seg2": 0.012493393667064645, + "DEF-skirt.002.seg1": 0.07562430091019157, + "DEF-skirt.L.001": 1.879150088933239, + "DEF-skirt.L.001.seg1": 0.060376459628942836, + "DEF-skirt.002": 1.0432309729624893, + "DEF-skirt.seg3": 5.570096570820647, + "DEF-skirt.003.seg3": 1.3719167847603355, + "DEF-skirt.005.seg2": 3.0893325526066313, + "DEF-skirt.005.seg1": 1.0448083213115291, + "DEF-skirt.L.seg3": 0.6236902469398551, + "DEF-skirt.005.seg3": 0.031181913198806976 + }, + { + "DEF-skirt.011.seg1": 7.206079765921628, + "DEF-skirt.L.002": 0.1453999495240184, + "DEF-skirt.002.seg3": 0.11034352255838153, + "DEF-skirt.011.seg3": 2.7529092791157663, + "DEF-skirt.002.seg2": 0.09621669328872662, + "DEF-skirt.L.001.seg1": 0.8697016458385258, + "DEF-skirt.002.seg1": 0.5566789920191999, + "DEF-skirt.002": 0.6140337423742315, + "DEF-skirt.L.001": 1.0570456406771491, + "DEF-skirt.L.seg3": 3.722823119417367, + "DEF-skirt.seg3": 2.8357110381653126, + "DEF-skirt.005.seg2": 1.4663347373091402, + "DEF-skirt.005.seg1": 0.06153290296288072, + "DEF-skirt.003.seg3": 0.25246157887255904, + "DEF-skirt.L.001.seg2": 0.02397434757873418 + }, + { + "DEF-skirt.002.seg2": 0.5137219364069667, + "DEF-skirt.002.seg1": 0.3210989021621229, + "DEF-skirt.L.002": 0.2979373224340631, + "DEF-skirt.002.seg3": 0.8674111035481489, + "DEF-skirt.L.001.seg1": 0.43628207869348157, + "DEF-skirt.011.seg2": 2.7529092791157663, + "DEF-skirt.L.seg3": 4.602221938080493, + "DEF-skirt.seg3": 0.14448731559887507, + "DEF-skirt.005.seg2": 0.06419735157119555, + "DEF-skirt.L.001.seg2": 0.020762523308016423 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.006", + "DEF-skirt.006.seg1", + "DEF-skirt.006.seg2", + "DEF-skirt.006.seg3" + ], + "tips": [ + [ + 0.0, + 0.00893, + -0.0 + ], + [ + 0.0, + 0.00893, + 0.0 + ], + [ + 0.0, + 0.00893, + -0.0 + ], + [ + -0.00117, + 0.04461, + -0.0053 + ] + ], + "hulls": [ + [ + [ + 0.00216, + -0.04675, + -0.00554 + ], + [ + 0.00152, + -0.01123, + -0.00176 + ], + [ + 0.00053, + -0.01137, + -0.00133 + ], + [ + 0.00319, + -0.04662, + -0.00587 + ] + ], + [], + [], + [ + [ + -0.00142, + 0.03972, + -0.00011 + ], + [ + -0.00123, + 0.00532, + -0.00385 + ], + [ + 0.00706, + 0.00757, + 0.0056 + ], + [ + -0.00256, + 0.03845, + -0.00764 + ], + [ + 0.00607, + 0.00733, + 0.00522 + ], + [ + -0.002, + 0.03881, + -0.00032 + ], + [ + -0.00168, + 0.00542, + -0.00286 + ], + [ + -0.0029, + 0.03793, + -0.00674 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.005.seg1": 1.2229846542125553, + "DEF-skirt.005.seg2": 0.38039666877214595, + "DEF-skirt.003.seg3": 0.32341552001905205, + "DEF-skirt.005": 0.26069539357345217, + "DEF-skirt.seg3": 0.1217170026805725 + }, + {}, + {}, + { + "DEF-skirt.005.seg2": 0.6104225058278434, + "DEF-skirt.005.seg3": 0.24410953164354598 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.005", + "DEF-skirt.005.seg1", + "DEF-skirt.005.seg2", + "DEF-skirt.005.seg3" + ], + "tips": [ + [ + -0.0, + 0.04557, + 0.0 + ], + [ + -0.0, + 0.04557, + 0.0 + ], + [ + -0.0, + 0.04557, + 0.0 + ], + [ + -0.0, + 0.04557, + 0.0 + ] + ], + "hulls": [ + [], + [ + [ + 0.04663, + 0.01394, + 0.04105 + ], + [ + -0.02303, + 0.03049, + -0.02364 + ], + [ + -0.00475, + 0.0113, + 0.02904 + ], + [ + 0.02439, + 0.03557, + 0.01084 + ], + [ + -0.00047, + 0.01757, + -0.00073 + ], + [ + 0.0183, + 0.01347, + 0.04315 + ], + [ + -0.00173, + 0.03373, + -0.00677 + ], + [ + 0.02397, + 0.01999, + 0.01807 + ], + [ + -0.00389, + 0.01687, + -0.00884 + ], + [ + -0.0011, + 0.01791, + 9e-05 + ] + ], + [ + [ + -0.08284, + 0.01524, + -0.00176 + ], + [ + 0.0806, + 0.01055, + 0.01714 + ], + [ + -0.00087, + 0.0032, + -0.02655 + ], + [ + 0.02081, + 0.01037, + 0.02289 + ], + [ + -0.05224, + 0.01847, + -0.02383 + ], + [ + 0.06023, + 0.0273, + -0.00431 + ], + [ + -0.02686, + 0.00487, + -0.00898 + ], + [ + -0.02613, + 0.02093, + -0.03629 + ], + [ + 0.02407, + 0.02705, + 0.00052 + ], + [ + -0.00454, + 0.02535, + -0.01046 + ] + ], + [] + ], + "neighbours": [ + { + "DEF-skirt.003.seg3": 2.6745725910706306, + "DEF-skirt.006": 0.26069539357345217, + "DEF-skirt.005.seg1": 1.0802518478592633, + "DEF-skirt.seg3": 1.3755075436915636, + "DEF-skirt.009": 0.7109617764468392, + "DEF-skirt.seg2": 0.4334825864653958, + "DEF-skirt.011": 0.20566438243868845, + "DEF-skirt.009.seg1": 0.3450042763204117, + "DEF-skirt.007.seg3": 0.23030644633612807, + "DEF-skirt.seg1": 0.16033306364800098, + "DEF-skirt.007.seg2": 0.016322135250931735, + "DEF-skirt.010": 0.010310972552469655, + "DEF-skirt.009.seg2": 0.05151539510096881 + }, + { + "DEF-skirt.006": 1.2229846542125553, + "DEF-skirt.005.seg2": 2.2839419227847424, + "DEF-skirt.003.seg3": 4.093617441427922, + "DEF-skirt.005": 1.0802518478592633, + "DEF-skirt.seg3": 3.920393819893497, + "DEF-skirt.011.seg1": 1.0448083213115291, + "DEF-skirt.011": 1.9069345583682153, + "DEF-skirt.011.seg2": 0.06153290296288072, + "DEF-skirt.009": 1.1164002439418375, + "DEF-skirt.010": 0.18882265189366965, + "DEF-skirt.009.seg1": 1.364221007427845, + "DEF-skirt.009.seg2": 1.0288586076015256, + "DEF-skirt.007.seg3": 0.7179370267663041, + "DEF-skirt.009.seg3": 0.16007123804476198, + "DEF-skirt.seg2": 0.007790076778398158 + }, + { + "DEF-skirt.006.seg3": 0.6104225058278434, + "DEF-skirt.005.seg3": 0.22486251911275246, + "DEF-skirt.006": 0.38039666877214595, + "DEF-skirt.005.seg1": 2.2839419227847424, + "DEF-skirt.003.seg3": 4.8612654356533715, + "DEF-skirt.seg3": 4.61498402848288, + "DEF-skirt.009": 0.3083252302655602, + "DEF-skirt.009.seg1": 1.2483108134831402, + "DEF-skirt.011.seg1": 3.0893325526066313, + "DEF-skirt.011": 1.117578855302317, + "DEF-skirt.011.seg2": 1.4663347373091402, + "DEF-skirt.L.seg3": 0.03759796665737425, + "DEF-skirt.010": 0.11437498180926176, + "DEF-skirt.009.seg2": 0.8744732184988231, + "DEF-skirt.007.seg3": 0.3911211444359659, + "DEF-skirt.011.seg3": 0.06419735157119555, + "DEF-skirt.009.seg3": 0.08906398621705636 + }, + { + "DEF-skirt.006.seg3": 0.24410953164354598, + "DEF-skirt.005.seg2": 0.22486251911275246, + "DEF-skirt.011.seg1": 0.031181913198806976, + "DEF-skirt.seg3": 0.040614199510271565, + "DEF-skirt.003.seg3": 0.04013802336446326, + "DEF-skirt.011": 0.006026445906482214 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.L.002" + ], + "tips": [ + [ + 0.03121, + 0.10187, + -0.02038 + ] + ], + "hulls": [ + [ + [ + 0.06915, + 0.02444, + -0.07296 + ], + [ + 2e-05, + 0.06885, + 1e-05 + ], + [ + 0.0171, + 0.03783, + 0.00801 + ], + [ + -0.00058, + 0.03804, + -0.00026 + ], + [ + 0.00719, + 0.06892, + 0.00329 + ], + [ + 0.00668, + 0.06797, + 0.00311 + ], + [ + 0.00041, + 0.03801, + 0.00021 + ], + [ + 0.06968, + 0.02446, + -0.07201 + ], + [ + 0.01613, + 0.03765, + 0.00756 + ], + [ + 0.00073, + 0.06811, + 0.00037 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.002.seg3": 0.5233835144511887, + "DEF-skirt.011.seg3": 0.2979373224340631, + "DEF-skirt.L.001.seg1": 1.4627848674895, + "DEF-skirt.L.001.seg3": 0.988772566592522, + "DEF-skirt.L.001.seg2": 1.6368903816027336, + "DEF-skirt.L.005.seg3": 0.26989297982735216, + "DEF-skirt.002.seg2": 0.08363487573375844, + "DEF-skirt.011.seg2": 0.1453999495240184, + "DEF-skirt.L.005.seg2": 0.16870674232060612, + "DEF-skirt.002.seg1": 0.09750582234609562, + "DEF-skirt.011.seg1": 0.02658579166615116, + "DEF-skirt.L.001": 0.08222018431886025, + "DEF-skirt.L.seg3": 0.10102962666415694, + "DEF-skirt.L.005": 0.13331009360929014, + "DEF-skirt.L.004": 0.808795880142356 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.L.001", + "DEF-skirt.L.001.seg1", + "DEF-skirt.L.001.seg2", + "DEF-skirt.L.001.seg3" + ], + "tips": [ + [ + -0.0, + 0.05706, + -0.0 + ], + [ + 0.0, + 0.05706, + 0.0 + ], + [ + -0.0, + 0.05706, + 0.0 + ], + [ + -0.01745, + 0.0433, + 0.00879 + ] + ], + "hulls": [ + [], + [ + [ + 0.0453, + 0.03397, + -0.03352 + ], + [ + -0.02599, + 0.04117, + 0.03719 + ], + [ + 0.01442, + 0.0089, + -0.03226 + ], + [ + 0.03648, + 0.00736, + -0.02149 + ], + [ + 0.02389, + 0.0376, + -0.04501 + ], + [ + -0.03656, + 0.03795, + 0.02107 + ], + [ + 0.02876, + 0.0087, + -0.02573 + ], + [ + 0.03718, + 0.03412, + -0.03711 + ], + [ + 0.04134, + 0.03589, + -0.03638 + ], + [ + 0.03182, + 0.0073, + -0.02352 + ] + ], + [ + [ + -0.02291, + 0.04811, + -0.00045 + ], + [ + -0.02001, + 0.01423, + 0.02762 + ], + [ + -0.03027, + 0.01093, + 0.01132 + ], + [ + -0.02969, + 0.01111, + 0.01223 + ], + [ + -0.02058, + 0.01404, + 0.02671 + ], + [ + -0.02238, + 0.0481, + 0.00051 + ] + ], + [ + [ + -0.0072, + 0.02243, + 0.00665 + ], + [ + -0.01133, + 0.02133, + 2e-05 + ], + [ + -0.01118, + 0.02051, + 0.00071 + ], + [ + -0.00772, + 0.0216, + 0.00617 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.L.002": 0.08222018431886025, + "DEF-skirt.011.seg2": 1.0570456406771491, + "DEF-skirt.002.seg2": 0.03671082080693666, + "DEF-skirt.002.seg1": 0.23053920218779567, + "DEF-skirt.011.seg1": 1.879150088933239, + "DEF-skirt.L.001.seg1": 1.7333563654776996, + "DEF-skirt.002": 0.17433376040275084, + "DEF-skirt.seg3": 0.4132729913690881, + "DEF-skirt.L.seg3": 1.7430155018692777, + "DEF-skirt.seg2": 0.07432020535983441, + "DEF-skirt.L.003": 0.23974937651991812, + "DEF-skirt.L.seg2": 0.10371684437270923 + }, + { + "DEF-skirt.L.002": 1.4627848674895, + "DEF-skirt.002.seg3": 0.45340715496411454, + "DEF-skirt.011.seg3": 0.43628207869348157, + "DEF-skirt.002.seg2": 0.07726076569748527, + "DEF-skirt.011.seg2": 0.8697016458385258, + "DEF-skirt.L.001.seg2": 1.5718474151046895, + "DEF-skirt.002.seg1": 0.07011653736356815, + "DEF-skirt.011.seg1": 0.060376459628942836, + "DEF-skirt.L.001": 1.7333563654776996, + "DEF-skirt.L.seg3": 0.19093190472623134, + "DEF-skirt.L.005": 0.36357547421065073, + "DEF-skirt.L.004": 0.8051463519527622 + }, + { + "DEF-skirt.L.002": 1.6368903816027336, + "DEF-skirt.L.001.seg3": 0.4428292313472455, + "DEF-skirt.L.005.seg3": 0.0646526762887677, + "DEF-skirt.L.001.seg1": 1.5718474151046895, + "DEF-skirt.L.005.seg2": 0.21270272007869018, + "DEF-skirt.L.seg3": 0.1699154302186351, + "DEF-skirt.L.005": 0.358614413021921, + "DEF-skirt.L.004": 0.6736723993011002, + "DEF-skirt.011.seg2": 0.02397434757873418, + "DEF-skirt.011.seg3": 0.020762523308016423 + }, + { + "DEF-skirt.L.002": 0.988772566592522, + "DEF-skirt.L.001.seg2": 0.4428292313472455, + "DEF-skirt.L.005.seg3": 0.47097637669824355, + "DEF-skirt.L.005.seg2": 0.12279700596246146 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.L.005", + "DEF-skirt.L.005.seg1", + "DEF-skirt.L.005.seg2", + "DEF-skirt.L.005.seg3" + ], + "tips": [ + [ + -0.0, + 0.01609, + -0.0 + ], + [ + -0.0, + 0.01609, + 0.0 + ], + [ + -0.0, + 0.01609, + -0.0 + ], + [ + -0.00328, + 0.03949, + 0.01665 + ] + ], + "hulls": [ + [ + [ + -0.00485, + -0.03063, + 0.00248 + ], + [ + -0.00254, + 0.0033, + 0.01753 + ], + [ + -0.00239, + 0.00046, + -0.0018 + ], + [ + -0.00253, + 0.00314, + 0.01645 + ], + [ + -0.0024, + 0.00062, + -0.00072 + ], + [ + -0.00485, + -0.03047, + 0.00356 + ] + ], + [], + [ + [ + -0.00236, + 0.00948, + -0.012 + ], + [ + 0.00046, + 0.00643, + 0.00707 + ], + [ + -0.00219, + 0.00911, + -0.01099 + ], + [ + 0.00031, + 0.00659, + 0.006 + ] + ], + [ + [ + -0.00127, + 0.01968, + 0.01112 + ], + [ + -0.00243, + 0.02102, + 0.00345 + ], + [ + -0.00226, + 0.02, + 0.00382 + ], + [ + -0.00131, + 0.01905, + 0.01023 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.L.004.seg3": 0.18071373217817754, + "DEF-skirt.L.003.seg3": 1.1878016405318974, + "DEF-skirt.L.004.seg2": 0.48988694772853547, + "DEF-skirt.R.005.seg3": 0.8433881844420577, + "DEF-skirt.L.seg3": 0.04318668795316161, + "DEF-skirt.L.001.seg2": 0.358614413021921, + "DEF-skirt.L.002": 0.13331009360929014, + "DEF-skirt.L.001.seg1": 0.36357547421065073, + "DEF-skirt.R.003.seg3": 0.1715765989223872, + "DEF-skirt.R.004": 0.030876031930140435, + "DEF-skirt.R.004.seg1": 0.020803836908132034, + "DEF-skirt.L.004": 0.22954852875263643, + "DEF-skirt.L.004.seg1": 1.117666014626619, + "DEF-skirt.R.005": 0.00681448318365796 + }, + { + "DEF-skirt.L.005.seg2": 0.2999459253805148, + "DEF-skirt.L.004.seg3": 0.286943106992394, + "DEF-skirt.L.003.seg3": 0.09606776911003378, + "DEF-skirt.R.005.seg3": 0.289521499895125 + }, + { + "DEF-skirt.L.004.seg3": 0.2371031746007346, + "DEF-skirt.L.003.seg3": 0.07865966284521508, + "DEF-skirt.L.005.seg1": 0.2999459253805148, + "DEF-skirt.R.005.seg3": 0.2372906590447128, + "DEF-skirt.L.002": 0.16870674232060612, + "DEF-skirt.L.001.seg3": 0.12279700596246146, + "DEF-skirt.L.001.seg2": 0.21270272007869018, + "DEF-skirt.L.005.seg3": 0.14366482829699256 + }, + { + "DEF-skirt.L.002": 0.26989297982735216, + "DEF-skirt.L.001.seg3": 0.47097637669824355, + "DEF-skirt.L.001.seg2": 0.0646526762887677, + "DEF-skirt.L.005.seg2": 0.14366482829699256 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.L.004", + "DEF-skirt.L.004.seg1", + "DEF-skirt.L.004.seg2", + "DEF-skirt.L.004.seg3" + ], + "tips": [ + [ + 0.0, + 0.0568, + -0.0 + ], + [ + 0.0, + 0.05681, + -0.0 + ], + [ + 0.0, + 0.05681, + 0.0 + ], + [ + -0.0338, + 0.05438, + 0.08144 + ] + ], + "hulls": [ + [ + [ + -0.15102, + 0.0078, + 0.08207 + ], + [ + 0.00824, + 0.03576, + 0.00178 + ], + [ + -0.05835, + 0.0553, + -0.00941 + ], + [ + -0.09639, + 0.03085, + 0.06009 + ], + [ + -0.12285, + -0.00656, + 0.08433 + ], + [ + -0.12841, + -0.01172, + 0.07969 + ], + [ + -0.1452, + 0.0128, + 0.08653 + ], + [ + -0.05826, + 0.05446, + -0.01011 + ], + [ + -0.15, + 0.00781, + 0.08246 + ], + [ + 0.00825, + 0.03587, + 0.00286 + ] + ], + [ + [ + 0.08615, + 0.03472, + -0.05979 + ], + [ + -0.05431, + 0.05202, + 0.02224 + ], + [ + 0.02518, + 0.0375, + 0.00055 + ], + [ + -0.02527, + 0.02548, + -0.02003 + ], + [ + 0.07674, + 0.02108, + 0.00321 + ], + [ + 0.04784, + 0.00565, + -0.01696 + ], + [ + 0.05354, + 0.035, + -0.0416 + ], + [ + -0.0043, + 0.04893, + 0.0021 + ], + [ + 0.05709, + 0.03934, + -0.00321 + ], + [ + 0.07479, + 0.0475, + -0.02989 + ] + ], + [ + [ + -0.0656, + 0.03387, + 0.0299 + ], + [ + 0.06661, + 0.00056, + -0.00535 + ], + [ + -0.00376, + 0.00758, + -0.01638 + ], + [ + -0.01302, + 0.03632, + 0.01466 + ], + [ + -0.03914, + 0.01854, + 0.04737 + ], + [ + -0.02557, + 0.00374, + 0.01734 + ], + [ + 0.03762, + 0.0123, + -0.00983 + ], + [ + -0.04518, + 0.02831, + 0.01086 + ], + [ + -0.03363, + 0.04644, + 0.02948 + ], + [ + 0.00975, + 0.02427, + -0.00104 + ] + ], + [] + ], + "neighbours": [ + { + "DEF-skirt.L.004.seg1": 4.0678154269769955, + "DEF-skirt.L.005": 0.22954852875263643, + "DEF-skirt.L.001.seg2": 0.6736723993011002, + "DEF-skirt.L.002": 0.808795880142356, + "DEF-skirt.L.001.seg1": 0.8051463519527622, + "DEF-skirt.L.seg3": 3.8285142497834164, + "DEF-skirt.L.003": 0.21646333191305667, + "DEF-skirt.L.seg2": 0.06935733543747569, + "DEF-skirt.L.003.seg3": 5.274030080809885, + "DEF-skirt.R.003.seg3": 0.845388225157766, + "DEF-skirt.L.003.seg2": 0.677365454643255, + "DEF-skirt.seg3": 0.8155590405608446, + "DEF-skirt": 0.19603096959511881, + "DEF-skirt.seg2": 0.14029811314042995, + "DEF-skirt.L.003.seg1": 0.3003293425343978, + "DEF-skirt.R.004": 0.13967539384350525, + "DEF-skirt.seg1": 0.20676807425859 + }, + { + "DEF-skirt.L.004": 4.0678154269769955, + "DEF-skirt.L.005": 1.117666014626619, + "DEF-skirt.R.003.seg3": 1.9171418898471628, + "DEF-skirt.L.004.seg2": 4.7309382362110455, + "DEF-skirt.L.003.seg3": 7.552187895272386, + "DEF-skirt.R.004": 1.5419089252454603, + "DEF-skirt.L.seg3": 0.9764221426855458, + "DEF-skirt.R.004.seg1": 0.07202823700664285, + "DEF-skirt.seg3": 0.029836616482513456 + }, + { + "DEF-skirt.L.004.seg3": 1.095682577471105, + "DEF-skirt.L.003.seg3": 5.510255386232652, + "DEF-skirt.L.005": 0.48988694772853547, + "DEF-skirt.R.005.seg3": 0.4449604793694335, + "DEF-skirt.R.004": 1.9079217533708204, + "DEF-skirt.R.004.seg1": 1.6832252773454213, + "DEF-skirt.R.003.seg3": 5.3545195307750895, + "DEF-skirt.L.004.seg1": 4.7309382362110455, + "DEF-skirt.R.004.seg2": 0.15842716545330904, + "DEF-skirt.R.005.seg1": 0.035164787307590095, + "DEF-skirt.R.005": 0.007233539959747903 + }, + { + "DEF-skirt.L.005.seg2": 0.2371031746007346, + "DEF-skirt.L.003.seg3": 0.5971418419198388, + "DEF-skirt.L.005.seg1": 0.286943106992394, + "DEF-skirt.R.005.seg3": 0.7169556622842608, + "DEF-skirt.L.005": 0.18071373217817754, + "DEF-skirt.L.004.seg2": 1.095682577471105, + "DEF-skirt.R.003.seg3": 1.2748603054718204, + "DEF-skirt.R.004.seg1": 0.5166879515818066, + "DEF-skirt.R.004.seg2": 0.25998774990698886 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.R.002" + ], + "tips": [ + [ + -0.00968, + 0.11247, + 0.00454 + ] + ], + "hulls": [ + [ + [ + -0.00719, + 0.06892, + 0.00329 + ], + [ + -0.0171, + 0.03783, + 0.00801 + ], + [ + 0.00058, + 0.03804, + -0.00026 + ], + [ + -2e-05, + 0.06885, + 1e-05 + ], + [ + -0.00073, + 0.06811, + 0.00037 + ], + [ + -0.00041, + 0.03801, + 0.00021 + ], + [ + -0.01612, + 0.03765, + 0.00756 + ], + [ + -0.00668, + 0.06797, + 0.00311 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.R.001.seg3": 0.4721447238142513, + "DEF-skirt.R.001.seg2": 1.3861041106828074, + "DEF-skirt.R.005": 0.5360177981787058, + "DEF-skirt.R.001.seg1": 1.026548805673111, + "DEF-skirt.010.seg3": 0.27244704748154724, + "DEF-skirt.010.seg2": 0.18258340776049164, + "DEF-skirt.009.seg3": 0.5169325843054446, + "DEF-skirt.010.seg1": 0.051648361776784, + "DEF-skirt.R.003.seg3": 0.012816917290188745, + "DEF-skirt.R.004.seg2": 0.018325292971089646, + "DEF-skirt.R.001": 0.06312256627611201, + "DEF-skirt.010": 0.029037682237823992 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.R.001", + "DEF-skirt.R.001.seg1", + "DEF-skirt.R.001.seg2", + "DEF-skirt.R.001.seg3" + ], + "tips": [ + [ + 0.0, + 0.0643, + -0.0 + ], + [ + -0.0, + 0.0643, + 0.0 + ], + [ + -0.0, + 0.0643, + 0.0 + ], + [ + -0.00053, + 0.0016, + 0.01591 + ] + ], + "hulls": [ + [ + [ + 0.0053, + 0.01949, + 0.11977 + ], + [ + 0.03099, + 0.06327, + 0.02728 + ], + [ + 0.00431, + 0.01991, + 0.11959 + ], + [ + 0.02996, + 0.0634, + 0.02764 + ] + ], + [ + [ + 0.0247, + 0.06188, + 0.01354 + ], + [ + -0.04416, + 0.02727, + -0.03656 + ], + [ + -0.01632, + 0.00129, + -0.03211 + ], + [ + 0.02158, + 0.03459, + 0.03826 + ], + [ + -0.02664, + 0.0297, + -0.04487 + ], + [ + 0.03255, + 0.03188, + 0.02232 + ], + [ + -0.0173, + 0.00116, + -0.03165 + ], + [ + 0.0253, + 0.06173, + 0.01264 + ], + [ + -0.04318, + 0.0274, + -0.03703 + ], + [ + 0.02218, + 0.03444, + 0.03736 + ] + ], + [ + [ + 0.0003, + 0.06429, + 0.00748 + ], + [ + 0.01463, + 0.00023, + 0.02877 + ], + [ + 0.01676, + 0.03439, + 0.00096 + ], + [ + 0.0061, + 0.03696, + 0.01711 + ], + [ + 0.00403, + 0.06421, + 0.00139 + ], + [ + 0.00669, + 0.0368, + 0.01621 + ], + [ + 0.01523, + 7e-05, + 0.02786 + ], + [ + 0.0039, + 0.06337, + 0.00209 + ], + [ + 0.01621, + 0.03435, + 0.0019 + ] + ], + [] + ], + "neighbours": [ + { + "DEF-skirt.010.seg1": 0.027378974892033114, + "DEF-skirt.007.seg3": 0.2677451548737494, + "DEF-skirt.010": 0.31555339612032274, + "DEF-skirt.009.seg2": 0.4018968056498796, + "DEF-skirt.009.seg3": 0.24376088738124668, + "DEF-skirt.R.001.seg1": 1.0607064606861076, + "DEF-skirt.009.seg1": 0.05524117514372051, + "DEF-skirt.R.002": 0.06312256627611201, + "DEF-skirt.R.seg2": 1.5263588944722746, + "DEF-skirt.R.003.seg3": 0.07035853311526641, + "DEF-skirt.R.003.seg2": 0.15603431496875952, + "DEF-skirt.R.seg3": 0.6250004873314858, + "DEF-skirt.R.seg1": 0.07198854155120338 + }, + { + "DEF-skirt.010.seg3": 0.2332891127301584, + "DEF-skirt.R.002": 1.026548805673111, + "DEF-skirt.010.seg2": 0.12614822889344357, + "DEF-skirt.009.seg3": 0.39026871228668636, + "DEF-skirt.R.001.seg2": 0.45374872514639186, + "DEF-skirt.010.seg1": 0.033309799028286846, + "DEF-skirt.R.005": 0.4914130450568921, + "DEF-skirt.R.003.seg3": 0.11280987179495439, + "DEF-skirt.R.001": 1.0607064606861076, + "DEF-skirt.R.004.seg2": 0.14391126107917818, + "DEF-skirt.010": 0.01830603357103766, + "DEF-skirt.R.seg3": 0.15912477238749645, + "DEF-skirt.R.004.seg1": 0.10724688171236707 + }, + { + "DEF-skirt.R.001.seg3": 0.48646409436291727, + "DEF-skirt.R.002": 1.3861041106828074, + "DEF-skirt.R.005": 0.7915718703374228, + "DEF-skirt.R.001.seg1": 0.45374872514639186, + "DEF-skirt.R.004.seg2": 0.04626688987928218 + }, + { + "DEF-skirt.R.002": 0.4721447238142513, + "DEF-skirt.R.001.seg2": 0.48646409436291727, + "DEF-skirt.R.005": 0.2593759751341018 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.R.005", + "DEF-skirt.R.005.seg1", + "DEF-skirt.R.005.seg2", + "DEF-skirt.R.005.seg3" + ], + "tips": [ + [ + 0.0, + 0.01912, + 0.0 + ], + [ + 0.0, + 0.01912, + -0.0 + ], + [ + 0.0, + 0.01912, + -0.0 + ], + [ + 0.08661, + 0.13329, + 0.07735 + ] + ], + "hulls": [ + [ + [ + 0.0341, + 0.01431, + 0.01316 + ], + [ + -0.02492, + -0.01564, + -0.00996 + ], + [ + -0.00759, + 0.00361, + 0.01594 + ], + [ + 0.00187, + -0.00238, + -7e-05 + ], + [ + 0.00134, + -0.00204, + 0.00082 + ], + [ + 0.03357, + 0.01464, + 0.01405 + ], + [ + -0.00707, + 0.00327, + 0.01504 + ], + [ + -0.02438, + -0.01598, + -0.01085 + ] + ], + [ + [ + -0.05443, + 0.00812, + -0.03221 + ], + [ + -0.02008, + 0.00129, + -0.03264 + ], + [ + -0.04983, + 0.01063, + -0.03808 + ], + [ + -0.02056, + 0.00086, + -0.03176 + ], + [ + -0.05337, + 0.00801, + -0.03246 + ], + [ + -0.04941, + 0.01002, + -0.03727 + ] + ], + [], + [ + [ + 0.08062, + 0.02593, + 0.06383 + ], + [ + 0.01748, + 0.09156, + 0.02803 + ], + [ + 0.04592, + 0.07269, + 0.02198 + ], + [ + 0.06121, + 0.04666, + 0.05168 + ], + [ + 0.03615, + 0.07203, + 0.03887 + ], + [ + 0.08118, + 0.02596, + 0.06289 + ], + [ + 0.03671, + 0.07206, + 0.03793 + ], + [ + 0.01837, + 0.09107, + 0.02763 + ], + [ + 0.06066, + 0.04663, + 0.05262 + ], + [ + 0.04551, + 0.07252, + 0.02298 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.R.005.seg1": 1.1971281659610125, + "DEF-skirt.R.001.seg3": 0.2593759751341018, + "DEF-skirt.R.002": 0.5360177981787058, + "DEF-skirt.R.001.seg2": 0.7915718703374228, + "DEF-skirt.R.001.seg1": 0.4914130450568921, + "DEF-skirt.R.003.seg3": 1.091596357605304, + "DEF-skirt.R.004.seg2": 9.754011944602853, + "DEF-skirt.R.seg3": 0.020397376975925585, + "DEF-skirt.R.004.seg1": 6.500489989848925, + "DEF-skirt.L.004.seg2": 0.007233539959747903, + "DEF-skirt.R.004": 0.0037763106041570904, + "DEF-skirt.R.004.seg3": 0.2621371448345676, + "DEF-skirt.L.003.seg3": 0.04733610412909939, + "DEF-skirt.L.005": 0.00681448318365796 + }, + { + "DEF-skirt.R.005": 1.1971281659610125, + "DEF-skirt.R.003.seg3": 0.15873896284533995, + "DEF-skirt.R.004.seg2": 0.05547518726174139, + "DEF-skirt.R.004.seg1": 0.09565318256508437, + "DEF-skirt.L.004.seg2": 0.035164787307590095, + "DEF-skirt.R.004": 0.018476782348616994, + "DEF-skirt.R.005.seg2": 0.05278311122623336 + }, + { + "DEF-skirt.R.005.seg1": 0.05278311122623336 + }, + { + "DEF-skirt.L.005.seg2": 0.2372906590447128, + "DEF-skirt.L.004.seg3": 0.7169556622842608, + "DEF-skirt.L.003.seg3": 0.5377347293486077, + "DEF-skirt.L.005.seg1": 0.289521499895125, + "DEF-skirt.L.005": 0.8433881844420577, + "DEF-skirt.L.004.seg2": 0.4449604793694335, + "DEF-skirt.R.003.seg3": 0.1178247570438451, + "DEF-skirt.R.004": 0.03221048762352208, + "DEF-skirt.R.004.seg1": 0.021702974422598054 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.R.004", + "DEF-skirt.R.004.seg1", + "DEF-skirt.R.004.seg2", + "DEF-skirt.R.004.seg3" + ], + "tips": [ + [ + 0.0, + 0.0491, + -0.0 + ], + [ + -0.0, + 0.0491, + 0.0 + ], + [ + -0.0, + 0.0491, + 0.0 + ], + [ + -0.0, + 0.0491, + 0.0 + ] + ], + "hulls": [ + [ + [ + -0.1167, + 0.01819, + 0.04148 + ], + [ + -0.02518, + 0.01477, + 0.0226 + ], + [ + -0.11245, + 0.01782, + 0.04839 + ], + [ + -0.11668, + 0.01753, + 0.04061 + ], + [ + -0.11302, + 0.01872, + 0.0486 + ], + [ + -0.02577, + 0.01467, + 0.02169 + ] + ], + [ + [ + -0.03391, + 0.04902, + 0.02667 + ], + [ + 0.02743, + 0.01628, + -0.00983 + ], + [ + -0.00994, + 0.03813, + -0.0066 + ], + [ + 0.01438, + 0.02973, + 0.00607 + ], + [ + -0.00761, + 0.03188, + 0.01466 + ], + [ + 0.01318, + 0.02813, + -0.01841 + ], + [ + 0.03012, + 0.01732, + 0.01001 + ], + [ + -0.00982, + 0.03887, + 0.00271 + ], + [ + 0.0137, + 0.03, + -0.00279 + ], + [ + -0.03511, + 0.04812, + 0.01805 + ] + ], + [ + [ + 0.04779, + 0.02322, + -0.02176 + ], + [ + -0.04971, + 0.00448, + 0.02323 + ], + [ + 0.00662, + 0.02205, + 0.01108 + ], + [ + -0.03678, + 0.02978, + -0.00441 + ], + [ + -0.02466, + 0.02702, + 0.02605 + ], + [ + 0.04436, + 0.00749, + 0.00644 + ], + [ + -0.0462, + 0.00208, + -0.00291 + ], + [ + 0.02212, + 0.00747, + -0.00466 + ], + [ + 0.00344, + 0.01565, + -0.0085 + ], + [ + 0.04078, + 0.00627, + -0.01328 + ] + ], + [] + ], + "neighbours": [ + { + "DEF-skirt.L.005": 0.030876031930140435, + "DEF-skirt.L.004.seg2": 1.9079217533708204, + "DEF-skirt.R.005.seg3": 0.03221048762352208, + "DEF-skirt.R.004.seg1": 1.0732347883554358, + "DEF-skirt.R.003.seg3": 4.464269337643888, + "DEF-skirt.L.003.seg3": 1.6662561376102858, + "DEF-skirt.L.004.seg1": 1.5419089252454603, + "DEF-skirt.R.seg2": 0.16426175880538585, + "DEF-skirt.L.004": 0.13967539384350525, + "DEF-skirt.R.003.seg2": 0.016111401937035363, + "DEF-skirt.R.seg3": 0.21310817027062778, + "DEF-skirt.R.005.seg1": 0.018476782348616994, + "DEF-skirt.R.005": 0.0037763106041570904 + }, + { + "DEF-skirt.R.004.seg2": 10.738716948372819, + "DEF-skirt.L.005": 0.020803836908132034, + "DEF-skirt.L.004.seg2": 1.6832252773454213, + "DEF-skirt.R.005.seg3": 0.021702974422598054, + "DEF-skirt.R.004": 1.0732347883554358, + "DEF-skirt.R.seg3": 0.19151314001136188, + "DEF-skirt.R.005": 6.500489989848925, + "DEF-skirt.R.001.seg1": 0.10724688171236707, + "DEF-skirt.R.003.seg3": 3.6078875543646496, + "DEF-skirt.L.003.seg3": 0.2699483450459348, + "DEF-skirt.L.004.seg3": 0.5166879515818066, + "DEF-skirt.R.005.seg1": 0.09565318256508437, + "DEF-skirt.L.004.seg1": 0.07202823700664285 + }, + { + "DEF-skirt.R.004.seg1": 10.738716948372819, + "DEF-skirt.R.005": 9.754011944602853, + "DEF-skirt.R.001.seg2": 0.04626688987928218, + "DEF-skirt.R.002": 0.018325292971089646, + "DEF-skirt.R.001.seg1": 0.14391126107917818, + "DEF-skirt.R.seg3": 0.04455171348530562, + "DEF-skirt.R.003.seg3": 3.272260373183556, + "DEF-skirt.L.004.seg3": 0.25998774990698886, + "DEF-skirt.L.004.seg2": 0.15842716545330904, + "DEF-skirt.R.005.seg1": 0.05547518726174139, + "DEF-skirt.R.004.seg3": 0.5314995138158436 + }, + { + "DEF-skirt.R.005": 0.2621371448345676, + "DEF-skirt.R.004.seg2": 0.5314995138158436 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.010", + "DEF-skirt.010.seg1", + "DEF-skirt.010.seg2", + "DEF-skirt.010.seg3" + ], + "tips": [ + [ + -0.0, + 0.02681, + -0.0 + ], + [ + 0.0, + 0.02681, + 0.0 + ], + [ + 0.0, + 0.02681, + -0.0 + ], + [ + -0.04852, + 0.01964, + 0.12108 + ] + ], + "hulls": [ + [ + [ + 0.08387, + 0.01976, + -0.01391 + ], + [ + -0.06183, + 0.02188, + -0.00739 + ], + [ + -0.03781, + -0.01788, + 0.00557 + ], + [ + 0.05871, + 0.01031, + -0.02099 + ], + [ + -0.05956, + 0.01908, + 0.01173 + ], + [ + 0.08341, + 0.01978, + -0.01491 + ], + [ + -0.05956, + 0.01926, + 0.01065 + ], + [ + 0.05915, + 0.0103, + -0.01999 + ], + [ + -0.06175, + 0.02156, + -0.00635 + ], + [ + -0.03783, + -0.01771, + 0.0045 + ] + ], + [ + [ + 0.02077, + 0.02144, + -0.07553 + ], + [ + 0.02768, + 0.02288, + -0.07198 + ], + [ + 0.02139, + 0.02087, + -0.07483 + ], + [ + 0.02711, + 0.02196, + -0.07183 + ] + ], + [], + [ + [ + -0.02616, + 0.01002, + 0.06035 + ], + [ + -0.01828, + 0.01042, + 0.06088 + ], + [ + -0.02535, + 0.00932, + 0.06056 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.010.seg1": 0.5487628936145512, + "DEF-skirt.009.seg2": 2.1035028646904017, + "DEF-skirt.009.seg3": 1.345437829411113, + "DEF-skirt.009.seg1": 1.1850910995461685, + "DEF-skirt.007.seg3": 0.23355445061974422, + "DEF-skirt.R.001": 0.31555339612032274, + "DEF-skirt.009": 0.23097140030928476, + "DEF-skirt.R.002": 0.029037682237823992, + "DEF-skirt.R.001.seg1": 0.01830603357103766, + "DEF-skirt.005.seg2": 0.11437498180926176, + "DEF-skirt.005.seg1": 0.18882265189366965, + "DEF-skirt.005": 0.010310972552469655 + }, + { + "DEF-skirt.010.seg2": 0.9157422602751464, + "DEF-skirt.009.seg3": 1.5453642087036952, + "DEF-skirt.010": 0.5487628936145512, + "DEF-skirt.009.seg2": 0.04938591538750098, + "DEF-skirt.R.001.seg1": 0.033309799028286846, + "DEF-skirt.R.002": 0.051648361776784, + "DEF-skirt.007.seg3": 0.015989096555482363, + "DEF-skirt.R.001": 0.027378974892033114 + }, + { + "DEF-skirt.R.001.seg1": 0.12614822889344357, + "DEF-skirt.010.seg3": 0.11231192164214132, + "DEF-skirt.R.002": 0.18258340776049164, + "DEF-skirt.009.seg3": 0.7490264894622274, + "DEF-skirt.010.seg1": 0.9157422602751464 + }, + { + "DEF-skirt.R.001.seg1": 0.2332891127301584, + "DEF-skirt.R.002": 0.27244704748154724, + "DEF-skirt.010.seg2": 0.11231192164214132, + "DEF-skirt.009.seg3": 0.7566836571082174 + } + ] + }, + { + "class": "skirt", + "root_parent": "DEF-spine", + "bones": [ + "DEF-skirt.009", + "DEF-skirt.009.seg1", + "DEF-skirt.009.seg2", + "DEF-skirt.009.seg3" + ], + "tips": [ + [ + 0.0, + 0.02818, + 0.0 + ], + [ + 0.0, + 0.02818, + 0.0 + ], + [ + 0.0, + 0.02818, + -0.0 + ], + [ + -0.03619, + 0.04068, + -0.00405 + ] + ], + "hulls": [ + [ + [ + -0.0934, + 0.01308, + -0.05741 + ], + [ + 0.09157, + 0.01046, + 6e-05 + ], + [ + -0.04856, + 0.02623, + 0.00682 + ], + [ + -0.07845, + 0.01877, + -0.02838 + ], + [ + -0.06331, + 0.02572, + -0.00594 + ], + [ + 0.08445, + 0.01441, + 0.00469 + ], + [ + 0.08911, + 0.01898, + -0.0017 + ], + [ + 0.09016, + 0.01656, + 0.00119 + ], + [ + 0.09129, + 0.01152, + 1e-05 + ], + [ + 0.08904, + 0.01844, + -0.00264 + ] + ], + [ + [ + 0.09362, + 0.00863, + -0.08179 + ], + [ + -0.05087, + 0.02478, + 0.04259 + ], + [ + 0.02526, + 0.01004, + -0.02974 + ], + [ + 0.02918, + 0.02075, + -0.07815 + ], + [ + 0.05947, + 0.00301, + -0.06983 + ], + [ + 0.0185, + 0.0247, + -0.00059 + ], + [ + 0.06892, + 0.0245, + -0.06689 + ], + [ + 0.02461, + 0.02325, + -0.04076 + ], + [ + 0.03153, + 0.00983, + -0.06629 + ], + [ + 0.05892, + 0.01463, + -0.081 + ] + ], + [ + [ + -0.07292, + 0.02213, + 0.09532 + ], + [ + 0.06001, + 0.00472, + -0.09372 + ], + [ + -0.01288, + 0.01989, + 0.00375 + ], + [ + 0.0358, + 0.01343, + -0.04136 + ], + [ + -0.04798, + 0.00526, + 0.04757 + ], + [ + 0.01666, + 0.01022, + -0.01058 + ], + [ + 0.02945, + 0.01043, + -0.0912 + ], + [ + -0.03094, + 0.01476, + 0.02644 + ], + [ + 0.02255, + 0.02536, + -0.06376 + ], + [ + 0.04226, + 0.00062, + -0.06501 + ] + ], + [ + [ + -0.0721, + 0.06955, + -0.00063 + ], + [ + 0.021, + 0.01513, + -0.02777 + ], + [ + -0.0281, + 0.01052, + 0.04322 + ], + [ + -0.01592, + 0.00469, + -0.00519 + ], + [ + -0.05269, + 0.04819, + 0.01577 + ], + [ + -0.04035, + 0.01305, + 0.00828 + ], + [ + -0.00951, + 0.02526, + -0.01258 + ], + [ + -0.0199, + 0.01686, + 0.01286 + ], + [ + -0.04182, + 0.01246, + 0.03474 + ], + [ + -0.01674, + 0.02392, + -0.01633 + ] + ] + ], + "neighbours": [ + { + "DEF-skirt.010": 0.23097140030928476, + "DEF-skirt.009.seg1": 2.8972802492875527, + "DEF-skirt.003.seg3": 1.1286794898007495, + "DEF-skirt.seg3": 0.7582600522037454, + "DEF-skirt.005.seg2": 0.3083252302655602, + "DEF-skirt.005.seg1": 1.1164002439418375, + "DEF-skirt.005": 0.7109617764468392, + "DEF-skirt.007.seg3": 9.43662584664051, + "DEF-skirt.seg2": 0.16869629844680606, + "DEF-skirt.seg1": 0.09372445010591768, + "DEF-skirt.R.seg3": 0.08669143553370695 + }, + { + "DEF-skirt.010": 1.1850910995461685, + "DEF-skirt.009.seg2": 2.723864090688208, + "DEF-skirt.009": 2.8972802492875527, + "DEF-skirt.R.001": 0.05524117514372051, + "DEF-skirt.007.seg3": 2.906074152306811, + "DEF-skirt.003.seg3": 0.8940016644600399, + "DEF-skirt.seg3": 0.9144382488613035, + "DEF-skirt.005.seg2": 1.2483108134831402, + "DEF-skirt.005.seg1": 1.364221007427845, + "DEF-skirt.005": 0.3450042763204117, + "DEF-skirt.R.seg3": 0.03686175164852512, + "DEF-skirt.seg2": 0.0714384640111776 + }, + { + "DEF-skirt.010.seg1": 0.04938591538750098, + "DEF-skirt.010": 2.1035028646904017, + "DEF-skirt.009.seg3": 1.8092874106016312, + "DEF-skirt.009.seg1": 2.723864090688208, + "DEF-skirt.007.seg3": 4.5295165581740635, + "DEF-skirt.R.001": 0.4018968056498796, + "DEF-skirt.005.seg2": 0.8744732184988231, + "DEF-skirt.005.seg1": 1.0288586076015256, + "DEF-skirt.003.seg3": 0.18218562382336984, + "DEF-skirt.R.seg3": 0.6798349904287918, + "DEF-skirt.seg3": 0.16737698817810986, + "DEF-skirt.seg2": 0.01027086487236381, + "DEF-skirt.005": 0.05151539510096881 + }, + { + "DEF-skirt.R.001.seg1": 0.39026871228668636, + "DEF-skirt.010.seg3": 0.7566836571082174, + "DEF-skirt.R.002": 0.5169325843054446, + "DEF-skirt.010.seg2": 0.7490264894622274, + "DEF-skirt.010.seg1": 1.5453642087036952, + "DEF-skirt.010": 1.345437829411113, + "DEF-skirt.009.seg2": 1.8092874106016312, + "DEF-skirt.007.seg3": 1.5188658246344597, + "DEF-skirt.R.001": 0.24376088738124668, + "DEF-skirt.R.seg3": 4.171412457713149, + "DEF-skirt.005.seg1": 0.16007123804476198, + "DEF-skirt.005.seg2": 0.08906398621705636 + } + ] + } + ], + "twist": [ + { + "bone": "DEF-upper_arm.L.001", + "parent": "DEF-upper_arm.L", + "child": "DEF-forearm.L" + }, + { + "bone": "DEF-forearm.L.001", + "parent": "DEF-forearm.L", + "child": "DEF-hand.L" + }, + { + "bone": "DEF-upper_arm.R.001", + "parent": "DEF-upper_arm.R", + "child": "DEF-forearm.R" + }, + { + "bone": "DEF-forearm.R.001", + "parent": "DEF-forearm.R", + "child": "DEF-hand.R" + }, + { + "bone": "DEF-thigh.L.001", + "parent": "DEF-thigh.L", + "child": "DEF-shin.L" + }, + { + "bone": "DEF-shin.L.001", + "parent": "DEF-shin.L", + "child": "DEF-foot.L" + }, + { + "bone": "DEF-thigh.R.001", + "parent": "DEF-thigh.R", + "child": "DEF-shin.R" + }, + { + "bone": "DEF-shin.R.001", + "parent": "DEF-shin.R", + "child": "DEF-foot.R" + } + ], + "colliders": [ + { + "bone": "DEF-spine", + "child": "DEF-spine.001", + "from": 0.0, + "radius_head": 0.1842, + "radius_tail": 0.1842, + "radius": 0.1842, + "lid": true + }, + { + "bone": "DEF-thigh.L", + "child": "DEF-shin.L", + "from": 0.1, + "radius_head": 0.1104, + "radius_tail": 0.063, + "radius": 0.063 + }, + { + "bone": "DEF-thigh.R", + "child": "DEF-shin.R", + "from": 0.1, + "radius_head": 0.1107, + "radius_tail": 0.062, + "radius": 0.062 + }, + { + "bone": "DEF-shin.L", + "child": "DEF-foot.L", + "from": 0.1, + "radius_head": 0.0953, + "radius_tail": 0.082, + "radius": 0.082 + }, + { + "bone": "DEF-shin.R", + "child": "DEF-foot.R", + "from": 0.1, + "radius_head": 0.0955, + "radius_tail": 0.0815, + "radius": 0.0815 + } + ], + "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-shin.L", + "DEF-shin.R", + "DEF-shoulder.L", + "DEF-shoulder.R", + "DEF-spine", + "DEF-spine.001", + "DEF-spine.002", + "DEF-spine.003", + "DEF-spine.004", + "DEF-spine.005", + "DEF-spine.006", + "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" + ], + "surfaces": [ + { + "mesh": "Object_10", + "surface": 0, + "material": "EyesInvL", + "class": "body", + "detail": "eyes_ink", + "why": "material names itself an eye card", + "verts": 140, + "textured": false, + "chain_share": {} + }, + { + "mesh": "Object_11", + "surface": 0, + "material": "EyesHL", + "class": "body", + "detail": "eyes_highlight", + "why": "material names itself an eye card", + "verts": 80, + "textured": false, + "chain_share": {} + }, + { + "mesh": "Object_12", + "surface": 0, + "material": "EyesFullBlack", + "class": "body", + "detail": "eyes_ink", + "why": "material names itself an eye card", + "verts": 269, + "textured": false, + "chain_share": {} + }, + { + "mesh": "Object_14", + "surface": 0, + "material": "ClothB", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 1658, + "textured": true, + "chain_share": { + "skirt": 0.698 + } + }, + { + "mesh": "Object_15", + "surface": 0, + "material": "FullBlack", + "class": "linework", + "detail": "outline_hull", + "why": "material is untextured ink or inside-out", + "verts": 2052, + "textured": false, + "chain_share": { + "skirt": 0.698 + } + }, + { + "mesh": "Object_17", + "surface": 0, + "material": "ClothA", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 706, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_18", + "surface": 0, + "material": "FullBlack", + "class": "linework", + "detail": "outline_hull", + "why": "material is untextured ink or inside-out", + "verts": 786, + "textured": false, + "chain_share": {} + }, + { + "mesh": "Object_20", + "surface": 0, + "material": "ClothA", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 124, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_22", + "surface": 0, + "material": "ClothA", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 106, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_24", + "surface": 0, + "material": "ClothCAndW", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 78, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_26", + "surface": 0, + "material": "ClothCAndW", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 312, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_27", + "surface": 0, + "material": "FullBlack", + "class": "linework", + "detail": "outline_hull", + "why": "material is untextured ink or inside-out", + "verts": 312, + "textured": false, + "chain_share": {} + }, + { + "mesh": "Object_29", + "surface": 0, + "material": "ClothCAndW", + "class": "cloth", + "detail": "garment", + "why": "material name says 'garment'", + "verts": 54, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_31", + "surface": 0, + "material": "Hair", + "class": "hair", + "detail": "hair", + "why": "material name says 'hair'", + "verts": 3479, + "textured": true, + "chain_share": { + "hair": 0.51, + "skirt": 0.002 + } + }, + { + "mesh": "Object_32", + "surface": 0, + "material": "material", + "class": "linework", + "detail": "outline_hull", + "why": "material is untextured ink or inside-out", + "verts": 3814, + "textured": false, + "chain_share": { + "hair": 0.474, + "skirt": 0.002 + } + }, + { + "mesh": "Object_7", + "surface": 0, + "material": "Face", + "class": "body", + "detail": "face", + "why": "material name says 'face'", + "verts": 1296, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_8", + "surface": 0, + "material": "Body", + "class": "body", + "detail": "skin", + "why": "material name says 'skin'", + "verts": 1980, + "textured": true, + "chain_share": {} + }, + { + "mesh": "Object_9", + "surface": 0, + "material": "Eyes", + "class": "body", + "detail": "eyes", + "why": "material name says 'eyes'", + "verts": 112, + "textured": true, + "chain_share": {} + } + ] +} \ No newline at end of file diff --git a/assets/characters/weapon_holds.json b/assets/characters/weapon_holds.json new file mode 100644 index 0000000..f483a90 --- /dev/null +++ b/assets/characters/weapon_holds.json @@ -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 + ] + } + } + } +} \ No newline at end of file diff --git a/assets/shaders/toon.gdshader b/assets/shaders/toon.gdshader index 4a8062d..b28c482 100644 --- a/assets/shaders/toon.gdshader +++ b/assets/shaders/toon.gdshader @@ -15,6 +15,11 @@ uniform float triplanar_tile = 2.0; // world units per texture tile uniform float band_edge : hint_range(-1.0, 1.0) = 0.05; // NdotL where light band starts uniform float band_softness : hint_range(0.001, 0.5) = 0.04; uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band +// How dark the mid band sits below full light. Near 1.0 the second step almost +// disappears, which is what textures that already carry painted cel shading +// (imported anime characters) need — a hard step on top of painted shading +// reads as a glossy stripe sweeping across the hair. +uniform float mid_tone : hint_range(0.0, 1.0) = 0.82; uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint // Matte-anime defaults: zero specular (any stepped glint reads as shine // sweeping across hair/cloth when the camera moves), whisper of rim. @@ -69,8 +74,8 @@ void light() { float lit = ndotl * ATTENUATION; float band = smoothstep(band_edge - band_softness, band_edge + band_softness, lit); float mid = smoothstep(mid_band_edge - band_softness, mid_band_edge + band_softness, lit); - // 3 tones: shadow tint -> base band (0.82) -> full light. - float tone = mix(0.82, 1.0, mid); + // 3 tones: shadow tint -> base band (mid_tone) -> full light. + float tone = mix(mid_tone, 1.0, mid); vec3 shade = mix(shadow_color.rgb, vec3(tone), band); DIFFUSE_LIGHT += ALBEDO * LIGHT_COLOR / PI * shade; diff --git a/assets/shaders/toon_outline.gdshader b/assets/shaders/toon_outline.gdshader index b48dda0..b8122e9 100644 --- a/assets/shaders/toon_outline.gdshader +++ b/assets/shaders/toon_outline.gdshader @@ -6,7 +6,7 @@ render_mode cull_front, unshaded; // culled, leaving a colored shell visible only at the silhouette. uniform vec4 outline_color : source_color = vec4(0.06, 0.05, 0.09, 1.0); -uniform float outline_width : hint_range(0.0, 0.1) = 0.02; +uniform float outline_width : hint_range(0.0, 0.1) = 0.006; void vertex() { VERTEX += NORMAL * outline_width; diff --git a/characters/humanoid_model.gd b/characters/humanoid_model.gd index 3fc03ff..6116bd1 100644 --- a/characters/humanoid_model.gd +++ b/characters/humanoid_model.gd @@ -19,6 +19,7 @@ var upper_arm_r_pivot: Node3D var upper_arm_r: MeshInstance3D var lower_arm_r_pivot: Node3D var lower_arm_r: MeshInstance3D +var hand_r_pivot: Node3D var thigh_l_pivot: Node3D var thigh_l: MeshInstance3D @@ -116,7 +117,14 @@ func _ready() -> void: lower_arm_r.position = Vector3(0, -0.175, 0) lower_arm_r.cast_shadow = shadow_setting lower_arm_r_pivot.add_child(lower_arm_r) - + + # Right hand: the wrist joint at the far end of the forearm. The + # third-person weapon hangs here so it travels with the arm instead of + # floating in front of the chest. + hand_r_pivot = Node3D.new() + hand_r_pivot.position = Vector3(0, -0.35, 0) + lower_arm_r_pivot.add_child(hand_r_pivot) + # Legs (Thigh: 0.45 length, Calf: 0.45 length) var t_leg_mesh = BoxMesh.new() t_leg_mesh.size = Vector3(0.15, 0.45, 0.15) @@ -166,7 +174,7 @@ func _ready() -> void: # Cel-shade + ink outline the blocky fallback body so it matches skinned # player models (shadows-only bodies skip it: outlines would cast shadows) if not shadows_only: - LevelMaterials.apply_toon_recursive(root_pivot, 0.012) + LevelMaterials.apply_toon_recursive(root_pivot, 0.004) ## Skin System var current_skin: PlayerSkin @@ -229,7 +237,7 @@ func _apply_color(col: Color) -> void: head.mesh.material = hmat # Re-toon after the material swap (fresh StandardMaterials above) if not shadows_only: - LevelMaterials.apply_toon_recursive(root_pivot, 0.012) + LevelMaterials.apply_toon_recursive(root_pivot, 0.004) func update_state(state: String, speed: float, is_crouching: bool = false) -> void: current_state = state @@ -240,11 +248,14 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo var is_holding_weapon: bool = false func set_weapon(script_path: String) -> void: - # Clear existing weapons from root_pivot - for child in root_pivot.get_children(): - if child.has_meta("is_third_person_weapon"): - child.queue_free() - + # Clear existing weapons (older builds parented them to root_pivot) + for parent in [root_pivot, hand_r_pivot]: + if not parent: + continue + for child in parent.get_children(): + if child.has_meta("is_third_person_weapon"): + child.queue_free() + if script_path == "": is_holding_weapon = false return @@ -269,13 +280,54 @@ func set_weapon(script_path: String) -> void: # Toon shading only — FBX weapon normals tear inverted-hull outlines LevelMaterials.apply_toon_recursive(w, 0.0) - # Force position after the weapon's _build_model() sets it for 1st person - w.position = Vector3(-0.15, 1.0, 0.4) - w.rotation_degrees = Vector3(0, 180, 0) + # Undo the first-person viewmodel placement from _build_model() + _seat_weapon_in_hand(w) ) - - # Attach to root_pivot so it stays steady and points perfectly forward - root_pivot.add_child(w) + + # Riding the hand keeps the gun with the arm; _process re-aims it forward. + (hand_r_pivot if hand_r_pivot else root_pivot).add_child(w) + + +## The muzzle of the gun actually in this character's hand — see +## SkinnedPlayerModel.get_muzzle_node() for why world effects must use it. +func get_muzzle_node() -> Node3D: + var holder := hand_r_pivot if hand_r_pivot else root_pivot + if not holder: + return null + for child in holder.get_children(): + if child.has_meta("is_third_person_weapon"): + if "muzzle_flash" in child and child.muzzle_flash: + return child.muzzle_flash + return child as Node3D + return null + + +## Slide the weapon along its own barrel axis so a plausible grip point — not +## whatever origin the artist left the model at — ends up in the fist, and turn +## it to face the way the body faces (the model is yawed 180° in _ready, so the +## gun's -Z muzzle axis has to point along the model's local +Z). +func _seat_weapon_in_hand(w: Node3D) -> void: + w.rotation_degrees = Vector3(0, 180, 0) + w.position = Vector3.ZERO + var min_t := INF # most negative along the barrel = stock end + var max_t := -INF # most positive = muzzle end + for mi in w.find_children("*", "MeshInstance3D", true, false): + if not mi.mesh: + continue + # mi.get_aabb(), NOT mi.mesh.get_aabb(): the FBX gun parts are skinned and + # the Mesh resource still carries huge bind-pose bounds. + var xf: Transform3D = w.global_transform.affine_inverse() * mi.global_transform + var aabb: AABB = mi.get_aabb() + for i in 8: + var t: float = (xf * aabb.get_endpoint(i)).dot(Vector3(0, 0, -1)) + min_t = minf(min_t, t) + max_t = maxf(max_t, t) + if min_t > max_t: + return + # ~a third back from the muzzle is where a pistol grip sits on every gun in + # the set, which leaves real length of weapon behind the hand. + var grip_at := min_t + (max_t - min_t) * 0.32 + w.position = Vector3(0, 0, -grip_at) func _set_shadows_recursive(node: Node) -> void: if node is GeometryInstance3D: @@ -438,15 +490,18 @@ func _process(delta: float) -> void: # Override right arm (and left) if holding a weapon if is_holding_weapon and current_state != "death": - # Pose the right arm to look like it's holding the weapon handle - t_upper_arm_r_rot.x = -0.6 - t_upper_arm_r_rot.z = 0.15 - t_lower_arm_r_rot.x = -1.0 - - # Pose the left arm to look like it's holding the foregrip - t_upper_arm_l_rot.x = -0.5 - t_upper_arm_l_rot.z = -0.15 - t_lower_arm_l_rot.x = -1.1 + # Right arm holds the grip: elbow tucked at the ribs, forearm level so + # the fist (and the weapon parented to it) sits chest-high just in + # front of the torso rather than out at arm's length. + t_upper_arm_r_rot.x = -0.15 + t_upper_arm_r_rot.z = -0.10 + t_lower_arm_r_rot.x = -1.45 + + # Left arm crosses over to the handguard: up, in, and a little further + # forward than the grip hand. + t_upper_arm_l_rot.x = -0.55 + t_upper_arm_l_rot.z = 0.55 + t_lower_arm_l_rot.x = -1.0 var lerp_speed = 40.0 * delta root_pivot.position = root_pivot.position.lerp(t_root_pos, lerp_speed) @@ -462,6 +517,12 @@ func _process(delta: float) -> void: calf_l_pivot.rotation = _lerp_vec3(calf_l_pivot.rotation, t_calf_l_rot, lerp_speed) calf_r_pivot.rotation = _lerp_vec3(calf_r_pivot.rotation, t_calf_r_rot, lerp_speed) + # The weapon rides the hand for position, but its AIM belongs to the body: + # cancel the arm chain's rotation so the barrel stays pointed where the + # player faces instead of swinging around with the arm animation. + if hand_r_pivot: + hand_r_pivot.global_basis = root_pivot.global_basis + func _lerp_vec3(a: Vector3, b: Vector3, t: float) -> Vector3: return Vector3( lerp_angle(a.x, b.x, t), diff --git a/characters/rig_anchors.gd b/characters/rig_anchors.gd new file mode 100644 index 0000000..275562e --- /dev/null +++ b/characters/rig_anchors.gd @@ -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) diff --git a/characters/rig_anchors.gd.uid b/characters/rig_anchors.gd.uid new file mode 100644 index 0000000..f245cc5 --- /dev/null +++ b/characters/rig_anchors.gd.uid @@ -0,0 +1 @@ +uid://ck8e6odry037 diff --git a/characters/skin_joint_helper.gd b/characters/skin_joint_helper.gd new file mode 100644 index 0000000..1018e40 --- /dev/null +++ b/characters/skin_joint_helper.gd @@ -0,0 +1,258 @@ +extends Object +class_name SkinJointHelper + +## Articulates a joint through a CHAIN of helper bones so the limb cannot pinch. +## +## Linear-blend skinning averages TRANSFORMS, not shapes. Blending two rotations +## that are θ apart shortens anything perpendicular to the bend by cos(θ/2), so a +## vertex sitting between the thigh and the shin is pulled inside the leg the +## moment the knee folds. That is the thigh going thin during a jump. +## +## The collapse depends only on the WIDEST angle any single vertex has to blend +## across, so the fix is to make that angle small. Insert SEGMENTS-1 helper bones +## through the joint, each driven to its fraction of the child's rotation, and +## give every blend-zone vertex to the two ADJACENT bones it falls between. No +## vertex then blends across more than θ/SEGMENTS, and the worst collapse drops +## from cos(θ/2) to cos(θ/2·SEGMENTS): +## +## θ = 120° 1 step (raw) 0.50 2 steps (half-angle) 0.87 +## 4 steps 0.97 6 steps 0.985 +## +## Nothing is scaled and nothing is snapped rigid — the limb is simply +## articulated finely enough that averaging no longer eats it. This is why the +## fix is more joints rather than volume-correction: a bone scale fattens every +## vertex on the bone whether it was collapsing or not (tried; it ballooned parts +## of the thigh to 167% while the worst vertices stayed put). +## +## KNEES ONLY. Hip and ankle helpers were tried twice and measured worse both +## times — the hip swings rather than folds, so it was adding a blend for +## nothing, and it dragged the skirt down with it (0.80 -> 0.72). Re-measure +## before adding a joint here. +## +## install() returns [child_bone, helper_bone, fraction] triples; the caller MUST +## drive them every frame from inside the skeleton's modification pass (see +## SkinnedPlayerModel.ShooterPoseModifier). A helper that is not updated in step +## with its child deforms the limb instead of saving it. + +## The CHILD bone of each joint to subdivide. The parent is whatever the +## skeleton says it is, not a second hardcoded name. +## +## It used to be a [parent, child] pair of ["DEF-thigh.L", "DEF-shin.L"], which +## silently did nothing on a rig with limb twist bones: Taila's shin hangs off +## DEF-thigh.L.001, so the knee vertices are weighted across THAT and the shin, +## and the pass found no vertex holding both named bones. The knee measured 0.76 +## with this "installed" and doing nothing at all. +const JOINT_CHILDREN := ["DEF-shin.L", "DEF-shin.R", "shin.L", "shin.R"] +## Angular steps through each joint. 4 leaves at most a quarter of the bend for +## any one vertex to blend across, which is a ~3% collapse at a hard tuck. +const SEGMENTS := 4 +## A vertex needs at least this much of BOTH bones before it is worth moving. +const MIN_BLEND := 0.02 +## And the joint must hold at least this share of the vertex overall, or it is a +## stray cross-body influence rather than the joint this vertex belongs to. +const DOMINANT_SHARE := 0.5 + + +## Returns an Array of [child_bone_idx, helper_bone_idx, fraction]. +static func install(root: Node, skeleton: Skeleton3D) -> Array: + if skeleton == null: + return [] + var driven: Array = [] + # child bone -> [parent bone, [helper bones, inner first]] + var joint_helpers := {} + for child_name in JOINT_CHILDREN: + var child := skeleton.find_bone(child_name) + if child < 0: + continue + var parent := skeleton.get_bone_parent(child) + if parent < 0: + continue + if skeleton.find_bone("HELPER1-" + child_name) >= 0: + continue # already installed + var helpers: Array = [] + for step in range(1, SEGMENTS): + var hname: String = "HELPER%d-%s" % [step, child_name] + skeleton.add_bone(hname) + var h := skeleton.find_bone(hname) + skeleton.set_bone_parent(h, parent) + # Same rest as the child, so "this fraction of the child's local + # rotation" lands the helper at that fraction of the bend. + skeleton.set_bone_rest(h, skeleton.get_bone_rest(child)) + skeleton.reset_bone_pose(h) + helpers.append(h) + driven.append([child, h, float(step) / float(SEGMENTS)]) + joint_helpers[child] = [parent, helpers] + if driven.is_empty(): + return [] + + var chain_l := _leg_chain(skeleton, ".L") + var chain_r := _leg_chain(skeleton, ".R") + var limb_radius := 0.09 + if chain_l.size() > 0 and chain_r.size() > 0: + limb_radius = maxf(absf(chain_l[0].x - chain_r[0].x) * 0.5, 0.02) + for mi in root.find_children("*", "MeshInstance3D", true, false): + if mi.mesh == null or mi.skin == null: + continue + if mi.mesh.get_blend_shape_count() > 0: + continue # rebuilding would drop the blend shapes + _reweight(mi, skeleton, joint_helpers, chain_l, chain_r, limb_radius) + return driven + + +## Rest-pose joints down one leg, as a polyline for limb-membership tests. +static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array: + var out := PackedVector3Array() + for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]: + var i := skeleton.find_bone(stem + suffix) + if i >= 0: + out.append(skeleton.get_bone_global_rest(i).origin) + return out + + +static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float: + var best := INF + for i in range(chain.size() - 1): + var a: Vector3 = chain[i] + var ab: Vector3 = chain[i + 1] - a + var l2: float = ab.length_squared() + var t: float = 0.0 if l2 < 0.000001 else clampf((p - a).dot(ab) / l2, 0.0, 1.0) + best = minf(best, p.distance_to(a + ab * t)) + return best + + +## Drive the helpers. MUST run inside the skeleton's modification pass. +static func update(skeleton: Skeleton3D, driven: Array) -> void: + for d in driven: + skeleton.set_bone_pose_rotation(d[1], Quaternion.IDENTITY.slerp( + skeleton.get_bone_pose_rotation(d[0]), d[2])) + + +static func _reweight(mi: MeshInstance3D, skeleton: Skeleton3D, + joint_helpers: Dictionary, chain_l: PackedVector3Array, + chain_r: PackedVector3Array, limb_radius: float) -> void: + 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 = skeleton.find_bone(skin.get_bind_name(b)) + bone_of[b] = bi + var bind_of_bone := {} + for b in skin.get_bind_count(): + bind_of_bone[bone_of[b]] = b + + # Per joint: the ordered bind chain parent -> helpers -> child. Helpers share + # the child's bind pose because their global rests are identical. + var jobs: Array = [] + for child in joint_helpers: + var parent: int = joint_helpers[child][0] + var helpers: Array = joint_helpers[child][1] + if not bind_of_bone.has(child) or not bind_of_bone.has(parent): + continue + var child_bind: int = bind_of_bone[child] + var chain: Array = [bind_of_bone[parent]] + for h in helpers: + skin.add_named_bind(skeleton.get_bone_name(h), + skin.get_bind_pose(child_bind)) + chain.append(skin.get_bind_count() - 1) + chain.append(child_bind) + jobs.append(chain) + if jobs.is_empty(): + return + + var surfaces: Array = [] + var touched := 0 + for s in range(mi.mesh.get_surface_count()): + var arrays: Array = mi.mesh.surface_get_arrays(s) + touched += _reweight_surface(arrays, jobs, chain_l, chain_r, limb_radius) + surfaces.append({ + "arrays": arrays, + "material": mi.mesh.surface_get_material(s), + "name": mi.mesh.surface_get_name(s), + }) + if touched == 0: + return + var rebuilt := ArrayMesh.new() + for i in surfaces.size(): + var e: Dictionary = surfaces[i] + rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, e["arrays"]) + rebuilt.surface_set_material(i, e["material"]) + if e["name"] != "": + rebuilt.surface_set_name(i, e["name"]) + mi.mesh = rebuilt + + +static func _reweight_surface(arrays: Array, jobs: Array, + chain_l: PackedVector3Array, chain_r: PackedVector3Array, + limb_radius: float) -> int: + 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(): + return 0 + var per: int = bones.size() / verts.size() + var touched := 0 + + for v in verts.size(): + # Cloth is not a limb. The gate is generous — the outside of a knee sits + # further from the bone axis than the limb radius and DOES need helping — + # but without it the skirt and the outer boot flare get bound to a knee + # helper and swing with the shin. + if minf(_dist_to_chain(verts[v], chain_l), + _dist_to_chain(verts[v], chain_r)) > limb_radius * 1.6: + continue + # Pick the joint this vertex most belongs to — the chain holding the most + # of its weight — NOT merely the first chain that matches. + # + # Taking the first match bound right-thigh vertices that still carried a + # little residual left-leg weight to the LEFT knee's helpers, which then + # dragged them across the body: measured worst 0.46, and no amount of + # extra subdivision touched it because the vertex was on the wrong + # joint entirely. + var best_chain: Array = [] + var best_kp := -1 + var best_kc := -1 + var best_total := 0.0 + for chain in jobs: + var parent_bind: int = chain[0] + var child_bind: int = chain[chain.size() - 1] + var k_parent := -1 + var k_child := -1 + for k in per: + var b: int = bones[v * per + k] + if b == parent_bind: + k_parent = k + elif b == child_bind: + k_child = k + if k_parent < 0 or k_child < 0: + continue + var w_parent: float = weights[v * per + k_parent] + var w_child: float = weights[v * per + k_child] + if minf(w_parent, w_child) < MIN_BLEND: + continue + var total: float = w_parent + w_child + if total > best_total: + best_total = total + best_chain = chain + best_kp = k_parent + best_kc = k_child + # The joint must genuinely own this vertex. Below this it is a stray + # cross-body influence, and re-binding it would pull the mesh across. + if best_chain.is_empty() or best_total < DOMINANT_SHARE: + continue + # How far through the joint the vertex sits, then hand it to the two + # ADJACENT bones either side of that point. Its widest blend is now one + # segment instead of the whole joint. + var w_c: float = weights[v * per + best_kc] + var pos: float = (w_c / best_total) * float(SEGMENTS) + var lo: int = clampi(int(floor(pos)), 0, SEGMENTS - 1) + var frac: float = clampf(pos - float(lo), 0.0, 1.0) + bones[v * per + best_kp] = best_chain[lo] + weights[v * per + best_kp] = best_total * (1.0 - frac) + bones[v * per + best_kc] = best_chain[lo + 1] + weights[v * per + best_kc] = best_total * frac + touched += 1 + if touched > 0: + arrays[Mesh.ARRAY_BONES] = bones + arrays[Mesh.ARRAY_WEIGHTS] = weights + return touched diff --git a/characters/skin_joint_helper.gd.uid b/characters/skin_joint_helper.gd.uid new file mode 100644 index 0000000..046fc72 --- /dev/null +++ b/characters/skin_joint_helper.gd.uid @@ -0,0 +1 @@ +uid://dkypjuyv81kg7 diff --git a/characters/skin_leg_repair.gd b/characters/skin_leg_repair.gd new file mode 100644 index 0000000..886b1e2 --- /dev/null +++ b/characters/skin_leg_repair.gd @@ -0,0 +1,375 @@ +extends Object +class_name SkinLegRepair + +## Keeps the HUMANOID rigid while letting the CLOTHES flow. +## +## That split is the whole design rule here, and it decides what every pass below +## is allowed to touch: +## +## * LIMB vertices — anything hugging a leg's own bone chain: skin, stockings, +## boots — are cleaned up hard. They belong to one leg, they follow it, and +## they hold their shape. Deforming here is a bug. +## * DRAPE vertices — the skirt and anything else hanging clear of both leg +## chains — are LEFT ALONE, so they keep swinging with the body. +## +## DO NOT stiffen the skirt onto the hips. It was tried (to stop it flattening at +## a wide stride) and reverted: it makes the clothes read as a rigid shell, which +## is worse than the flattening it fixed. Cloth is supposed to move. +## +## Current split, from debug/limb_deform_check.gd: the body holds 0.98 of its +## cross-section at the knee, while the skirt is free at 0.88 — rigid limbs, +## moving cloth. +## +## +## Stops below-the-knee geometry being dragged by BOTH legs at once. +## +## Taila's boots are skinned with weights that bleed across the centre line: +## 262 vertices in the boot/cuff surface (plus 208 in the model's outline shell +## and 176 in the body) carry weight from the left AND right leg, the worst at a +## dead-even 49/51 split. A vertex pulled equally by both feet sits halfway +## between them and stays there while the legs separate, stretching every +## triangle around it. That is the "ankle cuffs are linked" stretching, and the +## elongated boot that reads as the legs being squashed. +## +## Everything else about the rig is fine, which is why this took so long to +## find. Measured through the full runtime stack during a run: no bone's pose +## basis deviates from a pure rotation by more than 0.00001, no bone's length +## drifts from its rest offset by more than 0.0000 m, and no below-knee vertex +## is influenced by any non-leg bone. The skeleton is correct; the weights are +## not. Freezing the AnimationTree at the rest pose renders the boots perfectly, +## which is what proves it is a skinning problem rather than a pose one. +## +## Three steps: +## +## 1. BELOW THE KNEE (height taken from the skeleton's own rest pose, so this +## scales to any character): snap each vertex to the leg that already +## dominates it and renormalise, so nothing is pulled in two directions. +## 2. ABOVE THE KNEE, only for vertices that are part of a LEG rather than +## draping cloth: same snap, to the nearer leg. 122 vertices on Taila's +## thigh mesh carry up to 35% of the opposite leg — that is the squashing +## that survived the below-knee pass and showed up when running and +## jumping split the legs. Limb membership is decided by distance to the +## leg's own bone chain, NOT by height or by surface name: a thigh vertex +## hugs its bone, while a skirt vertex hangs well clear of both and is +## left blended, which is what lets a skirt drape across both legs. +## 3. Cap how much the TORSO owns a leg vertex. The top of the thigh is +## weighted between the hips and the thigh; at a wide stride, linear-blend +## skinning averages the near-static hips against a thigh swung 60 degrees +## out, and the top of the leg flattens into a wedge. That is the hip +## collapse visible from the side while running and jumping, and it is the +## worst number the measurement reports (cross-section 0.85 at the hip +## against 0.97-0.99 at the knee). Leg vertices keep at most MAX_TORSO of +## hips/spine influence, and the excess goes to the leg bone that already +## dominates them, so the thigh follows its own bone. +## 4. Drop any triangle still spanning the two legs below the knee. Those are +## the midline band between the ankles, which has no correct pose either +## way. +## +## Measure with debug/limb_deform_check.gd. + +const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"] +## Bones that belong to the torso, not the leg. +const TORSO_BONE_HINTS := ["hips", "spine", "pelvis"] +## The most torso influence a leg vertex may keep. Some is wanted — it is what +## rounds the hip off — but past this the thigh stops following its own bone. +const MAX_TORSO := 0.15 +## Ignore influences below this — they are rounding, not real weighting. +const EPSILON := 0.005 + + +## Returns [vertices_snapped, triangles_removed] so callers can log the result. +static func repair(root: Node, skeleton: Skeleton3D) -> Array: + var knee := _knee_height(skeleton) + if is_nan(knee): + return [0, 0] + var chain_l := _leg_chain(skeleton, ".L") + var chain_r := _leg_chain(skeleton, ".R") + if chain_l.is_empty() or chain_r.is_empty(): + return [0, 0] + # "Part of a leg" means within roughly half the gap between the two legs of + # that leg's bone chain — self-scaling to the character's proportions. + var limb_radius: float = maxf(absf(chain_l[0].x - chain_r[0].x) * 0.5, 0.02) + + var snapped_total := 0 + var removed_total := 0 + for mi in root.find_children("*", "MeshInstance3D", true, false): + if mi.mesh == null or mi.skin == null: + continue + # Rebuilding a mesh drops blend shapes, so a skin that uses them (a face + # rig) is left alone rather than silently losing its expressions. + if mi.mesh.get_blend_shape_count() > 0: + continue + var r := _repair_mesh(mi, skeleton, knee, chain_l, chain_r, limb_radius) + snapped_total += r[0] + removed_total += r[1] + return [snapped_total, removed_total] + + +## Rest-pose joint positions down one leg, used as a polyline to measure how +## close a vertex sits to that limb. +static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array: + var out := PackedVector3Array() + for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]: + var i := skeleton.find_bone(stem + suffix) + if i < 0: + i = skeleton.find_bone(stem.trim_prefix("DEF-") + suffix) + if i >= 0: + out.append(skeleton.get_bone_global_rest(i).origin) + return out + + +## Distance from a point to a polyline. +static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float: + var best := INF + for i in range(chain.size() - 1): + var a: Vector3 = chain[i] + var b: Vector3 = chain[i + 1] + var ab: Vector3 = b - a + var len2: float = ab.length_squared() + var t: float = 0.0 if len2 < 0.000001 else clampf((p - a).dot(ab) / len2, 0.0, 1.0) + best = minf(best, p.distance_to(a + ab * t)) + return best + + +static func _knee_height(skeleton: Skeleton3D) -> float: + if skeleton == null: + return NAN + for n in ["DEF-shin.L", "shin.L", "DEF-shin.R", "shin.R"]: + var i := skeleton.find_bone(n) + if i >= 0: + return skeleton.get_bone_global_rest(i).origin.y + return NAN + + +static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float, + chain_l: PackedVector3Array, chain_r: PackedVector3Array, + limb_radius: float) -> Array: + var side := _side_map(mi.skin, skeleton) + var torso_bone := _torso_map(mi.skin, skeleton) + var surfaces: Array = [] + var snapped := 0 + var removed := 0 + for s in range(mi.mesh.get_surface_count()): + var arrays: Array = mi.mesh.surface_get_arrays(s) + var r := _repair_surface(arrays, side, torso_bone, knee, chain_l, chain_r, limb_radius) + snapped += r[0] + removed += r[1] + surfaces.append({ + "arrays": arrays, + "material": mi.mesh.surface_get_material(s), + "name": mi.mesh.surface_get_name(s), + }) + if snapped == 0 and removed == 0: + return [0, 0] + + var rebuilt := ArrayMesh.new() + for i in surfaces.size(): + var entry: Dictionary = surfaces[i] + rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, entry["arrays"]) + rebuilt.surface_set_material(i, entry["material"]) + if entry["name"] != "": + rebuilt.surface_set_name(i, entry["name"]) + mi.mesh = rebuilt + return [snapped, removed] + + +## Is each bind a torso bone? Keyed by SKIN BIND index, like _side_map. +static func _torso_map(skin: Skin, skeleton: Skeleton3D) -> Array: + var out: Array = [] + out.resize(skin.get_bind_count()) + for b in skin.get_bind_count(): + var n := skin.get_bind_name(b) + if n == "": + var bone := skin.get_bind_bone(b) + n = skeleton.get_bone_name(bone) if bone >= 0 else "" + out[b] = false + for hint in TORSO_BONE_HINTS: + if n.findn(hint) != -1: + out[b] = true + break + return out + + +## -1 left leg, +1 right leg, 0 anything else — keyed by SKIN BIND index, which +## is what ARRAY_BONES stores (not the skeleton's bone index). +static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array: + var out := PackedInt32Array() + out.resize(skin.get_bind_count()) + for b in skin.get_bind_count(): + var n := skin.get_bind_name(b) + if n == "": + var bone := skin.get_bind_bone(b) + n = skeleton.get_bone_name(bone) if bone >= 0 else "" + var is_leg := false + for hint in LEG_BONE_HINTS: + if n.findn(hint) != -1: + is_leg = true + break + if not is_leg: + out[b] = 0 + elif n.ends_with(".L"): + out[b] = -1 + elif n.ends_with(".R"): + out[b] = 1 + else: + out[b] = 0 + return out + + +static func _repair_surface(arrays: Array, side: PackedInt32Array, + torso_bone: Array, knee: float, + chain_l: PackedVector3Array, chain_r: PackedVector3Array, + limb_radius: float) -> Array: + 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(): + return [0, 0] + var per: int = bones.size() / verts.size() + + # Step 1 — one leg per vertex. + var vside := PackedInt32Array() + vside.resize(verts.size()) + var snapped := 0 + for v in verts.size(): + var wl := 0.0 + var wr := 0.0 + for k in per: + var w: float = weights[v * per + k] + if w <= EPSILON: + continue + match side[bones[v * per + k]]: + -1: wl += w + 1: wr += w + if wl <= 0.0 and wr <= 0.0: + vside[v] = 0 + continue + var keep: int = -1 if wl >= wr else 1 + vside[v] = keep + if minf(wl, wr) <= EPSILON: + continue # already single-legged + if verts[v].y > knee: + # Above the knee, only repair vertices that belong to a LEG. Cloth + # that drapes across both legs sits clear of either bone chain and + # is left blended so it can keep draping. + var dl: float = _dist_to_chain(verts[v], chain_l) + var dr: float = _dist_to_chain(verts[v], chain_r) + if minf(dl, dr) > limb_radius: + continue # hugs neither chain — cloth, leave it alone + # Side comes from WEIGHT, not from which chain is nearer. An earlier + # version also demanded one chain be 1.25x closer than the other, + # which skipped everything near the centre line — and that is exactly + # where the damage was: inner-thigh vertices kept 21% of the OPPOSITE + # leg and were torn apart when the legs split (measured 0.46). A limb + # vertex belongs to whichever leg actually drives it. + # Drop the losing leg's influence and renormalise what remains. + var total := 0.0 + for k in per: + var b: int = bones[v * per + k] + if side[b] != 0 and side[b] != keep: + weights[v * per + k] = 0.0 + total += weights[v * per + k] + if total > 0.0: + for k in per: + weights[v * per + k] /= total + snapped += 1 + + # Step 2 — stop the torso holding the top of the leg back. Only vertices that + # actually belong to a limb are touched, so the skirt keeps swinging from + # the hips as it should. + for v in verts.size(): + if vside[v] == 0: + continue + var chain: PackedVector3Array = chain_l if vside[v] == -1 else chain_r + if _dist_to_chain(verts[v], chain) > limb_radius: + continue + var torso := 0.0 + var dom_k := -1 + var dom_w := 0.0 + for k in per: + var b: int = bones[v * per + k] + var w: float = weights[v * per + k] + if torso_bone[b]: + torso += w + elif side[b] != 0 and w > dom_w: + dom_w = w + dom_k = k + if torso <= MAX_TORSO or dom_k < 0: + continue + # Scale the torso influence down to the cap and hand the rest to the + # leg bone this vertex already follows. + var keep_scale: float = MAX_TORSO / torso + for k in per: + if torso_bone[bones[v * per + k]]: + weights[v * per + k] *= keep_scale + weights[v * per + dom_k] += torso - MAX_TORSO + var sum := 0.0 + for k in per: + sum += weights[v * per + k] + if sum > 0.0: + for k in per: + weights[v * per + k] /= sum + snapped += 1 + + # Step 3 — belt and braces: NO vertex that sits on a limb may carry any + # weight from the opposite leg, at any height. The staged rules above each + # have their own guards and between them they were still letting inner-thigh + # vertices through with ~20% of the far leg, which tears them apart when the + # legs split (measured 0.46 — the worst collapse left on the model). This is + # unconditional and runs last so nothing can reintroduce it. + for v in verts.size(): + var near_l: float = _dist_to_chain(verts[v], chain_l) + var near_r: float = _dist_to_chain(verts[v], chain_r) + # Generous radius: the limb radius is measured from the bone AXIS, so a + # vertex on the front or back of a thigh clears it easily, and those were + # exactly the ones slipping through with opposite-leg weight. The skirt + # hangs far enough out to stay outside even this. + if minf(near_l, near_r) > limb_radius * 1.6: + continue # cloth + var own: int = -1 if near_l < near_r else 1 + # Prefer the leg that actually drives it; fall back to the nearer chain. + var wl2 := 0.0 + var wr2 := 0.0 + for k in per: + match side[bones[v * per + k]]: + -1: wl2 += weights[v * per + k] + 1: wr2 += weights[v * per + k] + if maxf(wl2, wr2) > 0.0: + own = -1 if wl2 >= wr2 else 1 + if minf(wl2, wr2) <= 0.0: + continue # already single-legged + var tot := 0.0 + for k in per: + var b2: int = bones[v * per + k] + if side[b2] != 0 and side[b2] != own: + weights[v * per + k] = 0.0 + tot += weights[v * per + k] + if tot > 0.0: + for k in per: + weights[v * per + k] /= tot + vside[v] = own + snapped += 1 + + # Step 4 — drop triangles that still span the legs below the knee. + var removed := 0 + if not idx.is_empty(): + var keep_idx := PackedInt32Array() + for t in range(0, idx.size(), 3): + var a: int = idx[t] + var b: int = idx[t + 1] + var c: int = idx[t + 2] + var below: bool = (verts[a].y + verts[b].y + verts[c].y) / 3.0 < knee + var has_l: bool = vside[a] == -1 or vside[b] == -1 or vside[c] == -1 + var has_r: bool = vside[a] == 1 or vside[b] == 1 or vside[c] == 1 + if below and has_l and has_r: + removed += 1 + continue + keep_idx.append(a) + keep_idx.append(b) + keep_idx.append(c) + if removed > 0: + arrays[Mesh.ARRAY_INDEX] = keep_idx + if snapped > 0: + arrays[Mesh.ARRAY_WEIGHTS] = weights + return [snapped, removed] diff --git a/characters/skin_leg_repair.gd.uid b/characters/skin_leg_repair.gd.uid new file mode 100644 index 0000000..a3a47cf --- /dev/null +++ b/characters/skin_leg_repair.gd.uid @@ -0,0 +1 @@ +uid://db6vqsk1sku85 diff --git a/characters/skin_surfaces.gd b/characters/skin_surfaces.gd new file mode 100644 index 0000000..a9aefae --- /dev/null +++ b/characters/skin_surfaces.gd @@ -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 `.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", "")] diff --git a/characters/skin_surfaces.gd.uid b/characters/skin_surfaces.gd.uid new file mode 100644 index 0000000..b5696e3 --- /dev/null +++ b/characters/skin_surfaces.gd.uid @@ -0,0 +1 @@ +uid://diqctdcqixjqg diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index 8393f54..df527f4 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -56,12 +56,18 @@ const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch", "CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple", "Dance", "PistolIdle"] -const BLEND_TIME := 0.15 -## Per-clip blend overrides: snappy moves cut fast, locomotion cross-fades. +const BLEND_TIME := 0.32 +## Per-clip blend overrides. Reaction moves still need to read as instant, but +## nothing cuts hard any more — every clip cross-fades. Locomotion gets the +## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed +## drifts across their thresholds, and that is where hard cuts were most +## visible. const BLEND_TIMES := { - "Dash": 0.06, "Jump": 0.08, "Hit": 0.05, "Land": 0.08, - "Slide": 0.1, "Death": 0.1, "Throw": 0.06, "PistolReload": 0.12, - "Idle": 0.25, "PistolIdle": 0.25, "Walk": 0.2, "Run": 0.2, "Sprint": 0.2, + "Dash": 0.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16, + "Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24, + "Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40, + "CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28, "WallRun": 0.30, + "WallCling": 0.28, "Grapple": 0.28, } ## Named gameplay actions -> (clip, lock seconds). Networked via the @@ -80,7 +86,29 @@ var loaded: bool = false var _resolved_clips: Dictionary = {} # canonical name -> actual clip name var _current_clip: String = "" var _weapon_attachment: BoneAttachment3D +## [child_bone, helper_bone] pairs driven every frame by the pose modifier. +var _joint_helpers: Array = [] +## Contents of .rig.json — resolved bone roles, cloth chains, twist pairs +## and leg colliders, written by tools/retarget.py. Empty for a model that was +## rebound onto the library skeleton instead of keeping its own rig. +var _rig_info: Dictionary = {} +## What each surface of this model IS — body, cloth, hair, accessory. Read from +## the same sidecar; drives the per-class cel look and answers `surfaces_of()`. +var _surfaces: SkinSurfaces = null +var _spring_mod: SpringBones var is_holding_weapon: bool = false +## Which skin this is, so per-character hold tuning can be looked up. Set by +## whoever spawns the model; falls back to the GLB's basename. +var skin_id: String = "" +## Live hold overrides. Written by debug/rig_lab.gd while tuning and by +## set_weapon() from the saved table otherwise. +var hold_tune: Dictionary = {} +## Live anchor overrides — where the grip sits in the palm, and how the gun +## rolls in the fingers. Same two sources as hold_tune. See RigAnchors. +var anchors: Dictionary = {} +## The weapon's local transform in the hand as `_measure_weapon` left it, before +## the wrist counter-rotation. See `ShooterPoseModifier.wrist_comp_r`. +var _weapon_seat: Transform3D = Transform3D.IDENTITY # Animation blending: locomotion plays full-body through a Transition node; # gameplay one-shots (reload/throw/shoot/hit) play through an @@ -93,9 +121,17 @@ var _upper_lock: float = 0.0 # seconds the one-shot owns the ARMS var _upper_total: float = 0.0 # its full duration, for progress 0..1 var _upper_action: String = "" # which ACTIONS entry is playing ## Bone-name fragments that belong to the upper-body one-shot layer. +## +## NECK AND HEAD ARE DELIBERATELY EXCLUDED. The shared library's action clips +## were authored for a character looking at their own hands: PistolReload alone +## dives the head 33° and bends the whole neck chain 67° (measured). At that +## depth Taila's head/hair weights pinch and the skull visibly changes shape — +## which is what "reloading squishes the headshape" was. Nothing is actually +## scaled; no bone in any clip deviates from the rest pose by more than a +## millimetre. Leaving neck+head on the locomotion layer keeps the character +## looking downrange through a reload, which is also what a shooter wants. const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb", - "f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003", - "neck", "head"] + "f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003"] # Grapple: world-space anchor the hook is attached to (drives the procedural # zip pose — body aligned to the line, free arm reaching for the point). @@ -114,8 +150,32 @@ var _cur_fwd: float = 0.0 var _cur_ads: float = 0.0 var _cur_slide: float = 0.0 var _cur_wall: float = 0.0 +## Which way the legs are actually travelling, in radians about the character's +## own up axis, and whether the locomotion cycle is running backwards to achieve +## it. See _update_travel. +var _cur_travel: float = 0.0 +var _travel_reverse: bool = false var _owner_visible: bool = false +## Horizontal speed from the last update_state, so the lean can scale with how +## fast the character is really moving. +var _speed: float = 0.0 +var _loco_tier: int = 0 +var _tier_age: float = 0.0 const POSE_SMOOTH := 10.0 +## Separate, slower rate for the whole-body lean. Roughly a 0.36 s time constant, +## so the posture arrives with the clip crossfade instead of a tenth of a second +## ahead of it. +const LEAN_SMOOTH := 4.5 +## Ground locomotion tiers, slowest first, and how far below the promoting +## threshold the speed must fall before dropping back a tier. +const LOCO_TIERS := ["Idle", "Walk", "Run", "Sprint"] +const LOCO_HYSTERESIS := 0.78 +## A locomotion tier is held at least this long before another change is +## allowed. Hard acceleration genuinely passes through walking pace in about a +## fifth of a second, so without this Walk got 0.19 s — less than half of its own +## 0.40 s crossfade — and was cut off mid-blend by Run. Multi-tier jumps still +## happen in one step, so this delays nothing that was not already a blur. +const MIN_TIER_DWELL := 0.22 func _ready() -> void: @@ -132,6 +192,11 @@ func load_model(path: String) -> void: _resolved_clips.clear() _current_clip = "" _weapon_attachment = null + # Cleared, not left standing: a model with no skeleton never reaches the + # branch that reloads these, and would otherwise be described by the LAST + # character's sidecar. + _rig_info = {} + _surfaces = null var scene := GLBLoader.load(path) if not scene: @@ -148,12 +213,57 @@ func load_model(path: String) -> void: push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path) else: _ensure_meshes_bound(scene) + _rig_info = _load_rig_info(path) + # `weights_authored` is MEASURED at build time, not inferred from which + # pipeline branch ran: a model that arrives unrigged still gets a + # sidecar, and its nearest-bone weights still need the repair below. + if not _rig_info.get("weights_authored", false): + # Weights were solved, not painted — every vertex went to its + # nearest four bones with no idea which limb it belongs to. + # SkinLegRepair exists solely to undo that, and it is destructive + # (it snaps weights and deletes triangles), so a model that kept its + # ARTIST weights must never be put through it. + var fixed := SkinLegRepair.repair(scene, skeleton) + if fixed[0] > 0 or fixed[1] > 0: + print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles" + % [path.get_file(), fixed[0], fixed[1]]) + # Joint subdivision runs for EVERY model, however it was rigged. It is + # not a weight repair: linear-blend skinning collapses any joint by + # cos(angle/2) no matter how good the weights are, and dropping it from + # the authored-weight path measured 0.77 at the knee against 0.99 with + # it. See SkinJointHelper. + _joint_helpers = SkinJointHelper.install(scene, skeleton) _pose_mod = ShooterPoseModifier.new() + _pose_mod.joint_helpers = _joint_helpers + _pose_mod.roles = _rig_info.get("roles", {}) + _pose_mod.fingers = _rig_info.get("fingers", {}) _pose_mod.name = "ShooterPose" skeleton.add_child(_pose_mod) + # Cloth and hair last, so the springs react to the FINAL body pose — + # animation plus the shooter lean/slide layer. + if not _rig_info.is_empty(): + _spring_mod = SpringBones.new() + _spring_mod.name = "SpringBones" + skeleton.add_child(_spring_mod) + var driven := _spring_mod.setup(skeleton, _rig_info, + _cloth_hulls(scene)) + if driven == 0: + _spring_mod.queue_free() + _spring_mod = null + else: + print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs" + % [path.get_file(), driven]) - # Cel-shaded look: toon shading over the imported textures + ink outline. + # Cel-shaded look: toon shading over the imported textures... LevelMaterials.apply_toon_recursive(scene) + # ...then the character-only pass, which is where the surface table earns + # its keep: it says which surfaces are hair, cloth, body or an accessory, so + # each can take its own outline weight and banding instead of all of them + # taking numbers calibrated on skin. It also identifies the model's own + # line-work by name and weight rather than by "is it nearly black", which is + # what used to render a flat-coloured model as a black silhouette. + _surfaces = SkinSurfaces.from_rig_info(_rig_info) + LevelMaterials.apply_character_look(scene, _surfaces) if animation_player: _index_animations() _setup_anim_tree(scene) @@ -170,6 +280,145 @@ func load_model(path: String) -> void: _play_clip("Idle") +## Collision hulls for the cloth solver, taken from the MESH rather than from +## the sidecar: bone name -> the points that bone drives, in its own rest space. +## +## The sidecar carries ten farthest-point samples per cloth bone, which is a good +## description of a panel's OUTLINE and a poor one of a panel. Farthest-point +## sampling lands on corners, edges and the hem; a thigh comes up through the +## MIDDLE of a panel, between every sample, and the solver reported each frame's +## contacts fully resolved while 158 vertices sat 95 mm inside a leg. +## +## The runtime has the actual mesh, so it does not have to guess. Every vertex a +## cloth bone dominates is binned into a ~16 mm grid and one representative per +## cell is kept, which covers a panel evenly for a bounded number of points — +## unlike keeping every vertex, which would be thousands of collision tests per +## frame for no extra accuracy at the scale a limb is shaped. +const HULL_CELL := 0.020 +const HULL_MAX := 14 + +func _cloth_hulls(scene: Node) -> Dictionary: + var cloth := {} + for c in _rig_info.get("chains", []): + for n in c.get("bones", []): + var bi := skeleton.find_bone(String(n)) + if bi >= 0: + cloth[bi] = true + if cloth.is_empty(): + return {} + + # bone -> cell key -> the vertex nearest that cell's centre. + var cells := {} + for mi in scene.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 = skeleton.find_bone(skin.get_bind_name(b)) + bone_of[b] = bi + for s in 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(): + # A vertex belongs to whichever bone holds the largest share of + # it — that is the bone whose motion actually decides where it + # ends up, and so the bone that has to keep it out of a leg. + var best := 0.0 + var bind := -1 + for k in per: + var w: float = weights[v * per + k] + if w > best: + best = w + bind = bones[v * per + k] + if bind < 0 or best < 0.5: + continue + var bi: int = bone_of.get(bind, -1) + if not cloth.has(bi): + continue + # The bind pose maps a vertex straight into its bone's rest + # space, which is exactly the frame the solver poses hulls in. + var local: Vector3 = skin.get_bind_pose(bind) * verts[v] + var key := "%d_%d_%d" % [ + int(round(local.x / HULL_CELL)), + int(round(local.y / HULL_CELL)), + int(round(local.z / HULL_CELL))] + if not cells.has(bi): + cells[bi] = {} + if not cells[bi].has(key): + cells[bi][key] = local + + var out := {} + var total := 0 + for bi in cells: + var pts: Array = cells[bi].values() + if pts.size() > HULL_MAX: + # Keep the OUTERMOST cells. What clips is the part of a panel + # furthest from the bone it hangs on, and the grid has already made + # sure those are spread over the whole sheet rather than clustered. + pts.sort_custom(func(a, b): return a.length_squared() > b.length_squared()) + pts = pts.slice(0, HULL_MAX) + var packed := PackedVector3Array() + for p in pts: + packed.append(p) + out[skeleton.get_bone_name(bi)] = packed + total += packed.size() + print("SkinnedPlayerModel: cloth hulls from mesh — %d bones, %d points" + % [out.size(), total]) + return out + + +## Read the rig sidecar that tools/retarget.py writes next to the GLB. +## +## Its presence is also the signal that this model kept its OWN skeleton and +## authored weights, which is what decides whether the load-time weight repair +## below is needed at all. +func _load_rig_info(model_path: String) -> Dictionary: + var side := model_path.get_basename() + ".rig.json" + if not FileAccess.file_exists(side): + return {} + var text := FileAccess.get_file_as_string(side) + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("SkinnedPlayerModel: could not parse '%s'" % side) + return {} + return parsed + + +## Every [mesh, surface index] of this model belonging to one surface class — +## SkinSurfaces.BODY, CLOTH, HAIR or ACCESSORY. +## +## The point of separating a character into a body, garments and hair is that +## the game can then treat them differently, and it can only do that if it can +## ask which is which. This is that question. It is used by the rig lab to let +## an artist isolate a class, and it is what a damage flash on skin only, or a +## hidden hat, or a per-class LOD would be built on. +func surfaces_of(surface_class: String) -> Array: + var out: Array = [] + if _surfaces == null: + return out + for mi in find_children("*", "MeshInstance3D", true, false): + if mi.mesh == null: + continue + for s in mi.mesh.get_surface_count(): + var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D + if _surfaces.resolve(mi.name, s, src)[0] == surface_class: + out.append([mi, s]) + return out + + +## The surface table, for tools that want to show or edit it. May be null. +func surface_table() -> SkinSurfaces: + return _surfaces + + ## Make sure every skinned MeshInstance3D is actually driven by the skeleton. ## A correctly-exported GLB binds automatically, but if one imports with a skin ## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind @@ -232,8 +481,10 @@ func _setup_anim_tree(scene: Node) -> void: bt.connect_node("loco_scale", 0, "loco") var upper := AnimationNodeOneShot.new() - upper.fadein_time = 0.08 - upper.fadeout_time = 0.15 + # The upper-body one-shot pops in and out over the locomotion clip, so its + # own fades matter as much as the locomotion cross-fade. + upper.fadein_time = 0.14 + upper.fadeout_time = 0.22 upper.filter_enabled = true _upper_anim = AnimationNodeAnimation.new() bt.add_node("upper_clip", _upper_anim, Vector2(0, 240)) @@ -349,6 +600,7 @@ func add_gun_recoil(strength: float = 1.0) -> void: ## Same contract as HumanoidModel.update_state(). Called by the movement ## controller each frame with either local or network-synced state. func update_state(state: String, speed: float, is_crouching: bool = false) -> void: + _speed = speed if not loaded or not animation_player: return @@ -374,12 +626,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo clip = "Dance" elif is_crouching: clip = "CrouchWalk" if speed > 0.5 else "Crouch" - elif speed > run_anim_reference_speed * 1.35: - clip = "Sprint" - elif speed > walk_anim_reference_speed * 1.2: - clip = "Run" - elif speed > 0.5: - clip = "Walk" + else: + clip = _loco_clip(speed) # Armed idle uses the plain Idle clip — the rifle-hold pose layer # owns the arms, so the odd arms-crossed PistolIdle base reads worse. "air": @@ -410,11 +658,22 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo # Scale locomotion playback so feet keep up with actual movement speed. if _anim_tree: var s := 1.0 + var is_loco := false match clip: "Walk", "CrouchWalk": s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6) + is_loco = true "Run", "Sprint", "WallRun": s = clampf(speed / run_anim_reference_speed, 0.7, 1.8) + is_loco = true + # Backpedalling: run the cycle BACKWARDS rather than moon-walking with + # the forward clip. The shared library has no authored reverse run, and + # a reversed stride reads correctly for a backpedal. The hip yaw in + # _update_travel picks the regime and points the legs to match, so the + # two must agree — reading a different threshold here used to leave a + # band where the feet ran one way and pointed the other. + if is_loco and _travel_reverse: + s = -s _anim_tree.set("parameters/loco_scale/scale", s) @@ -441,19 +700,41 @@ func set_grapple_target(point_world: Vector3) -> void: _grapple_point_world = point_world +## How far away each cloth detail level starts, in metres. See SpringBones.lod — +## the solver is expensive enough that only the character being looked at can +## afford the full thing. +const CLOTH_LOD_RANGES := [6.0, 14.0, 28.0] +var _lod_timer: float = 0.0 + + func _process(delta: float) -> void: + _update_cloth_lod(delta) if not _pose_mod: return + _hold_weapon_still() var t := 1.0 - exp(-POSE_SMOOTH * delta) - _cur_strafe = lerpf(_cur_strafe, _target_strafe, t) - _cur_fwd = lerpf(_cur_fwd, _target_fwd, t) + # The body lean gets its own, much slower rate, and is scaled by how fast the + # character is ACTUALLY moving rather than by which key is held. + # + # The controller passes a normalised input direction, so `fwd` jumps 0 -> 1 + # the instant W is pressed. At the shared rate that planted the full forward + # lean in about a tenth of a second while the Idle->Run crossfade was still + # 0.4 s from finishing — the body snapped into a run posture ahead of the run + # cycle. Tying it to speed means the lean now grows as the character + # accelerates, and lands with the clip. + var lean_t := 1.0 - exp(-LEAN_SMOOTH * delta) + var drive: float = clampf(_speed / maxf(run_anim_reference_speed, 0.01), 0.0, 1.0) + _cur_strafe = lerpf(_cur_strafe, _target_strafe * drive, lean_t) + _cur_fwd = lerpf(_cur_fwd, _target_fwd * drive, lean_t) _cur_ads = lerpf(_cur_ads, _target_ads, t) var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0 _cur_slide = lerpf(_cur_slide, slide_target, t) var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0 - _cur_wall = lerpf(_cur_wall, wall_target, t) + _cur_wall = lerpf(_cur_wall, wall_target, lean_t) + _update_travel(delta, drive) _pose_mod.strafe = _cur_strafe _pose_mod.fwd = _cur_fwd + _pose_mod.travel_yaw = _cur_travel _pose_mod.ads = _cur_ads _pose_mod.slide = _cur_slide _pose_mod.wall = _cur_wall @@ -523,6 +804,100 @@ func _process(delta: float) -> void: _pose_mod.reload_phase = rl_target +## Which way the LEGS should point, and whether the stride runs backwards. +## +## The shared clip library has one forward locomotion cycle and no strafe or +## backpedal clips, so a character sidestepping used to run forwards on the spot +## while sliding sideways — nothing in the animation said which way they were +## going, and a body lean was carrying the whole burden of telling the player. +## +## Turning the HIPS toward the travel direction is what actually says it, and it +## costs no new animation: the legs are children of the hips, so the whole stride +## turns with them, while the spine counter-rotates so the chest and the gun stay +## on the aim. It is the same split every third-person shooter uses, and the same +## one Hoyoverse's locked-on locomotion uses. +## +## Beyond about a right angle the hips cannot follow, so the cycle plays in +## REVERSE and the legs point the other way instead — a real backpedal rather +## than a moonwalk. Which regime is in force is hysteretic, and the yaw is eased +## rather than snapped, so switching between them reads as a pivot on the spot, +## which is what a person actually does there. +## The most the hips may turn away from where the character is facing. +## +## A right angle would point the legs exactly along a sidestep, but a person +## sidestepping does not stand with their hips square to their path — they open +## maybe half that and let the feet cross. Past this the silhouette stops reading +## as a shooter holding an aim and starts reading as someone who has turned round. +const MAX_TRAVEL_YAW := 0.95 +const TRAVEL_SMOOTH := 9.0 +const TRAVEL_REVERSE_IN := -0.35 +const TRAVEL_REVERSE_OUT := -0.12 + +func _update_travel(delta: float, drive: float) -> void: + var st: float = _target_strafe + var fw: float = _target_fwd + if _travel_reverse: + if fw > TRAVEL_REVERSE_OUT: + _travel_reverse = false + elif fw < TRAVEL_REVERSE_IN: + _travel_reverse = true + var want := 0.0 + if absf(st) > 0.01 or absf(fw) > 0.01: + # Skeleton space: the character faces +Z and character-right is -X, so a + # travel direction of (strafe right, forward) is (-strafe, 0, fwd). A yaw + # of `want` about +Y points the legs along it — or along the opposite of + # it when the stride is running backwards. + want = atan2(st, -fw) if _travel_reverse else atan2(-st, fw) + want = clampf(want, -MAX_TRAVEL_YAW, MAX_TRAVEL_YAW) * drive + # Shortest way round, so a pivot never takes the long route. + var d := wrapf(want - _cur_travel, -PI, PI) + _cur_travel += d * (1.0 - exp(-TRAVEL_SMOOTH * delta)) + + +## Pick the cloth solver's detail level from how far the camera is. +## +## Re-checked a few times a second rather than every frame: the answer changes +## slowly, and the distance query is not free either. +func _update_cloth_lod(delta: float) -> void: + if _spring_mod == null: + return + _lod_timer -= delta + if _lod_timer > 0.0: + return + _lod_timer = 0.25 + var cam := get_viewport().get_camera_3d() if is_inside_tree() else null + if cam == null: + return + var d := cam.global_position.distance_to(global_position) + var want := CLOTH_LOD_RANGES.size() + for i in CLOTH_LOD_RANGES.size(): + if d < CLOTH_LOD_RANGES[i]: + want = i + break + _spring_mod.lod = want + + +## Locomotion clip for a ground speed, with HYSTERESIS. +## +## The thresholds used to be a bare elif chain, so a character accelerating from +## a standstill crossed all three in under a second and each crossfade cut off +## the one before it — and any speed hovering on a boundary flickered between +## two clips forever. Dropping back down needs the speed to fall well under the +## threshold that promoted it, so a tier, once entered, is committed to. +func _loco_clip(speed: float) -> String: + _tier_age += get_process_delta_time() + var up := [0.5, walk_anim_reference_speed * 1.2, run_anim_reference_speed * 1.35] + var want := _loco_tier + while want < LOCO_TIERS.size() - 1 and speed > up[want]: + want += 1 + while want > 0 and speed < up[want - 1] * LOCO_HYSTERESIS: + want -= 1 + if want != _loco_tier and _tier_age >= MIN_TIER_DWELL: + _loco_tier = want + _tier_age = 0.0 + return LOCO_TIERS[_loco_tier] + + func _play_clip(canonical: String, restart: bool = false) -> void: if not _anim_tree or not _resolved_clips.has(canonical): return @@ -534,6 +909,96 @@ func _play_clip(canonical: String, restart: bool = false) -> void: _current_clip = clip_name +## Current smoothed forward lean, 0..1. For debug/transition_check.gd. +func get_lean_debug() -> float: + return _cur_fwd + + +## Every canonical clip this character resolved to something real. +## +## Canonical rather than raw, because the raw names differ per character — one +## rig's "CrouchIdle" is another's "Crouch_Idle_Loop" — and the canonical name is +## what the game asks for. For debug/rig_lab.gd's clip scrubber. +func clip_names_debug() -> Array: + return _resolved_clips.keys() + + +## Play one clip outright, ignoring the locomotion state machine. Lab only. +func play_clip_debug(canonical: String) -> void: + _play_clip(canonical, true) + + +## Push a new hold tuning table in and re-seat the weapon with it. +## +## For debug/rig_lab.gd: the knobs that live on the pose layer take effect on +## the next frame, but weapon SIZE and the grip offset are baked into the +## attachment when the weapon is seated, so those need the weapon re-measured. +func set_hold_tuning(t: Dictionary) -> void: + hold_tune = t + if _pose_mod: + _pose_mod.tune = t + _reseat_weapon() + + +## Push a new anchor table in and re-seat the weapon on it. +## +## Separate from set_hold_tuning because the two are separate questions with +## separate scopes — a hold is per character AND weapon, an anchor is per +## character — and the lab edits them on different screens. +func set_anchors(a: Dictionary) -> void: + anchors = a + _reseat_weapon() + + +## Keep the weapon where the solver put it while the wrist turns under it. +## +## The two are welded by construction — the gun is a child of a BoneAttachment3D +## on the trigger hand — so a wrist rotation swings the barrel off the aim line +## and takes every control that could correct it along for the ride. The pose +## layer works out the exact counter-rotation in the hand's own local frame; +## this applies it. +## +## Deliberately in `_process` rather than inside the modifier pass. The gun's +## mount is not something the skeleton owns, and the value being compensated +## only changes when a slider moves or the ADS blend travels, so being one frame +## behind is a rotation of a fraction of a degree that nothing can see. Reaching +## into the modifier to touch a scene node would be worse. +func _hold_weapon_still() -> void: + if _weapon_attachment == null or _weapon_attachment.get_child_count() == 0: + return + var w := _weapon_attachment.get_child(0) as Node3D + if w == null: + return + var comp: Quaternion = _pose_mod.wrist_comp_r + w.transform = Transform3D(Basis(comp), Vector3.ZERO) * _weapon_seat + + +## Re-apply the grip anchor and re-measure, after either table changed. +## +## `_measure_weapon` reads the weapon's transform to work out where its grip and +## muzzle are, so the anchor has to be back in place BEFORE it runs — measuring +## from identity and then offsetting would move the gun without moving the +## points the hands are being solved onto. +func _reseat_weapon() -> void: + if _weapon_attachment == null or _weapon_attachment.get_child_count() == 0: + return + var w := _weapon_attachment.get_child(0) as Node3D + if w: + w.transform = RigAnchors.grip_transform(anchors) + _measure_weapon(w) + + +## Whether the locomotion cycle is running backwards, which points the stride +## the opposite way to the hips. For debug/travel_dir_check.gd. +func stride_reversed_debug() -> bool: + return _travel_reverse + + +## Clip currently playing. For debug/transition_check.gd. +func current_clip_debug() -> String: + return _current_clip + + ## Vertical velocity of the body this model is attached to (0 if detached). func _vertical_speed() -> float: var p := get_parent() @@ -553,6 +1018,17 @@ func set_weapon(script_path: String) -> void: is_holding_weapon = script_path != "" if script_path == "" or not skeleton: return + # Per-character, per-weapon hold overrides, if any have been tuned. Empty is + # the normal case and means "use what the code derives". + var sid := skin_id if skin_id != "" else model_path.get_file().get_basename() + if hold_tune.is_empty(): + hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid, + script_path.get_file().get_basename()) + # Anchors are per character, not per weapon — where a grip sits in a palm is + # a fact about the hand — so unlike hold_tune they are not re-read per gun + # unless the lab has pushed a live set in. + if anchors.is_empty(): + anchors = RigAnchors.resolve(RigAnchors.load_all(), sid) var script = load(script_path) if not script: @@ -569,21 +1045,37 @@ func set_weapon(script_path: String) -> void: # (weapon swap while toggled), else the new weapon comes up invisible. if (shadows_only or first_person_mode) and not _owner_visible: _set_shadows_recursive(w) - # Undo the first-person viewmodel placement from the weapon's _ready: - # lie along the hand's grip, scaled down to character proportions. - w.position = Vector3(-0.02, 0.07, 0.0) - w.rotation_degrees = Vector3(0, 90, -90) - w.scale = Vector3(1.0, 1.0, 1.0) - # Tell the pose layer the gun's axes in hand-bone space so it can - # aim the wrist to point the muzzle exactly where the player looks. + # Seat the weapon in the hand with NO hand-relative rotation. + # + # There used to be a fixed `rotation_degrees = (0, 90, -90)` here, which + # is the offset that happens to be right for the library's own DEF-hand.R + # and is wrong for every rig whose hand bone has a different roll. A bone + # attachment is expressed in the BONE's axes, and no two rigs agree on + # those, so a constant here mounts the gun differently on every character + # — which is what "the hand mount points are totally wrong on all models" + # was. + # + # It does not need to be right, because the pose layer aims the gun by + # rotating the WRIST until the weapon's forward axis lies on the aim line + # (see _apply_rifle_hold). Handing it the identity means "the gun's + # forward is the hand bone's -Z", which is true by construction on any + # rig, and the wrist then absorbs whatever that bone's roll happens to be. + # The grip is placed at the bone's origin below, so the gun sits IN the + # hand rather than at a fixed offset from a differently-oriented bone. + # + # ...with one adjustment on top: the character's own grip ANCHOR. A hand + # bone's origin is the wrist, not the palm, and how far down the palm a + # grip should sit is a fact about that character's hand — how big it is, + # how the fingers were modelled — which cannot be derived. It defaults to + # identity, so a character nobody has tuned behaves exactly as before. + w.transform = RigAnchors.grip_transform(anchors) if _pose_mod: - var b: Basis = w.transform.basis.orthonormalized() - _pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1) - _pose_mod.gun_up_hand = b * Vector3(0, 1, 0) + _pose_mod.gun_fwd_hand = Vector3(0, 0, -1) + _pose_mod.gun_up_hand = Vector3(0, 1, 0) _measure_weapon(w) ) - var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"]) + var hand_idx := _role_bone("hand.R", ["RightHand", "Hand_R", "hand.R"]) if hand_idx >= 0: _weapon_attachment = BoneAttachment3D.new() _weapon_attachment.name = "WeaponAttachment" @@ -599,47 +1091,145 @@ func set_weapon(script_path: String) -> void: add_child(w) -## Measure the held weapon along its own barrel axis so the pose layer knows -## where the real foregrip and stock butt are, instead of guessing. Distances -## are from the GRIP (the weapon node's origin, which sits in the hand), in -## metres of character space. +## The muzzle of the gun actually in this character's hand. +## +## Anything the WORLD sees — tracers, muzzle flash, the shot's audio position — +## has to originate here whenever the character model is what the viewer is +## looking at. The first-person viewmodel is parented to the camera, so ITS +## muzzle sits inside the player's head; using it in third person put the flash +## next to the character's shoulder. +func get_muzzle_node() -> Node3D: + if not _weapon_attachment or _weapon_attachment.get_child_count() == 0: + return null + var w := _weapon_attachment.get_child(0) + if "muzzle_flash" in w and w.muzzle_flash: + return w.muzzle_flash + return w as Node3D + + +## Seat the weapon in the hand and tell the pose layer where the support hand +## and stock are, using the weapon's OWN authored markers. +## +## This used to derive everything from mesh AABBs, which silently produced +## nonsense: the FBX guns report bind-pose bounds tens of metres across (the M4 +## measured 24 m long), so the grip offset threw the gun 7.5 m in front of the +## character. Nothing about a mesh's bounding box is trustworthy here. +## +## The reliable data is already authored: WeaponManager places the first-person +## viewmodel's hands at GRIP_LOCAL and SUPPORT_LOCAL in weapon space, and every +## weapon sets muzzle_flash.position at its barrel tip. Third person simply +## reaches for the same points the viewmodel does. +## How far a weapon may be scaled down before it reads as a toy. +const MIN_WEAPON_SCALE := 0.55 +## Forward offset of the stock pocket from the shoulder joint — see POCKET_HIP. +const POCKET_FORWARD := 0.06 + + +## Scale the weapon so this character can actually reach its handguard. +## +## Not against a fixed reference size: against the arm that has to hold it. The +## set is modelled at real-world scale — an M4 is 0.84 m butt to muzzle — and +## these characters are stylised, with arms around 0.47 m against an adult 0.52. +## +## The binding constraint is the SUPPORT arm. Its hand has to reach a point +## (stock + fore) in front of the shoulder pocket, from a shoulder half the +## shoulder-width off the weapon's axis. Solve that triangle for the largest gun +## whose handguard still lands inside the arm's reach, and the support hand stops +## being dragged back down the barrel. +## +## Measured on Taila before this: a support offset authored at 0.35 m collapsed +## to 0.083 m, which puts the support fist on top of the trigger fist — a +## two-handed pistol grip, not a rifle. That is what "the hands are not on the +## rails" was. +func _weapon_scale(span: float) -> float: + if not _pose_mod or not skeleton or span <= 0.01: + return 1.0 + var reach: float = _pose_mod._arm_reach(skeleton, "DEF-upper_arm.L", + "DEF-forearm.L", "DEF-hand.L") * 0.94 + if reach <= 0.01: + return 1.0 + var ua_l: int = _pose_mod._idx.get("DEF-upper_arm.L", -1) + var ua_r: int = _pose_mod._idx.get("DEF-upper_arm.R", -1) + var lateral := 0.14 + if ua_l >= 0 and ua_r >= 0: + # The weapon rides near the right shoulder, so the support arm crosses + # about half the shoulder width to get to it. + lateral = skeleton.get_bone_global_rest(ua_l).origin.distance_to( + skeleton.get_bone_global_rest(ua_r).origin) * 0.5 + var forward := sqrt(maxf(reach * reach - lateral * lateral, 0.01)) + return clampf((forward - POCKET_FORWARD) / span, MIN_WEAPON_SCALE, 1.0) + + func _measure_weapon(w: Node3D) -> void: - var local_fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis - var min_t := INF # most negative = stock end - var max_t := -INF # most positive = muzzle end - for mi in w.find_children("*", "MeshInstance3D", true, false): - if not mi.mesh: - continue - var xf: Transform3D = w.global_transform.affine_inverse() * mi.global_transform - var aabb: AABB = mi.mesh.get_aabb() - for i in 8: - var t: float = (xf * aabb.get_endpoint(i)).dot(local_fwd) - min_t = minf(min_t, t) - max_t = maxf(max_t, t) - if min_t > max_t: - return - var s: float = absf(w.scale.z) - var total := max_t - min_t - if total < 0.0001: - return - # Weapon models put their origin wherever the artist left it — for the M4 - # that is barely 10 cm behind the muzzle end, so hanging the hand there - # and then parking the stock in the shoulder shoved the hand INTO the - # shoulder and the arm folded up behind the head. Re-seat the weapon so - # the hand sits at a realistic pistol-grip point (~a third back from the - # muzzle), which puts real length of gun behind the hand to reach the - # shoulder with. - var grip_at := min_t + total * 0.32 - w.position -= _pose_mod.gun_fwd_hand * (grip_at * s) - var back := (grip_at - min_t) * s # butt of the stock, behind the grip - var front := (max_t - grip_at) * s # muzzle, ahead of the grip - _pose_mod.gun_stock = clampf(back, 0.10, 0.40) - # Support hand rides partway out the handguard, never past the muzzle. - _pose_mod.gun_fore = clampf(front * 0.55, 0.14, 0.45) + var grip: Vector3 = WeaponGrips.GRIP + var support: Vector3 = WeaponGrips.SUPPORT + var fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis + + # Barrel length, grip to muzzle. Every weapon marks its own barrel tip. + var muzzle_dist: float = WeaponGrips.DEFAULT_MUZZLE_DIST + if "muzzle_flash" in w and w.muzzle_flash: + muzzle_dist = maxf(absf((w.muzzle_flash.position - grip).dot(fwd)), 0.1) + + # Support hand: where the viewmodel's off hand rides — but NEVER past the + # muzzle. That offset is one constant for all weapons, so on a short gun + # (the MP7's barrel is only 0.30 m) it used to hang the support hand out in + # front of the barrel with nothing to hold, which is why some weapons did + # not look supported by the arms. + var fore: float = absf((support - grip).dot(fwd)) + var fore_u: float = clampf(minf(fore, muzzle_dist * 0.8), 0.12, 0.45) + var stock_u: float = clampf(muzzle_dist * 0.5, 0.10, 0.40) + + # Size the gun to the arm that has to hold it, then seat the grip in the fist. + var k := _weapon_scale(stock_u + fore_u) + # A tuned size wins over the derived one. The derivation keeps the handguard + # inside the support arm's reach, which is a floor on believability, not a + # statement about how big the gun should LOOK. + if hold_tune.get("weapon_scale", 0.0) > 0.01: + k = float(hold_tune["weapon_scale"]) + w.scale = Vector3.ONE * k + w.position -= w.transform.basis * grip + + _pose_mod.gun_fore = fore_u * k + if hold_tune.get("gun_fore", 0.0) > 0.0001: + _pose_mod.gun_fore = float(hold_tune["gun_fore"]) + + # Stock: not authored anywhere, so derive it from the barrel. Half the + # grip-to-muzzle distance behind the grip lands the butt in the shoulder + # pocket for every gun in the set. + _pose_mod.gun_stock = stock_u * k + if hold_tune.get("gun_stock", 0.0) > 0.0001: + _pose_mod.gun_stock = float(hold_tune["gun_stock"]) + _pose_mod.tune = hold_tune + # Where the weapon sits in the hand once everything derived and tuned has + # been applied. Kept because `_process` re-derives the mount every frame from + # it plus the wrist counter-rotation, and recomputing the seat instead would + # re-run this whole measurement sixty times a second. + _weapon_seat = w.transform # ── Helpers ─────────────────────────────────────────────────────────────────── +## A bone by its resolved ROLE, falling back to name matching. +## +## tools/rig_map.py resolves every rig to roles and writes them to the sidecar so +## that nothing downstream has to guess a bone name — but a hardcoded lookup here +## meant four characters could not hold a gun. Their hands resolve perfectly as +## "Right wrist" and "J_Bip_R_Hand"; none of them matches a spelling this file +## knew, so set_weapon fell through to parenting the weapon to the model root at +## a fixed chest offset, where it is not attached to the character at all. +## +## The fallback stays for a model with no sidecar. It must never be the first +## thing tried. +func _role_bone(role: String, fallbacks: Array) -> int: + var roles: Dictionary = _rig_info.get("roles", {}) + var actual := String(roles.get(role, "")) + if actual != "" and skeleton: + var b := skeleton.find_bone(actual) + if b >= 0: + return b + return _find_bone(fallbacks) + + func _find_bone(name_parts: Array) -> int: if not skeleton: return -1 @@ -682,6 +1272,9 @@ class ShooterPoseModifier extends SkeletonModifier3D: var ads: float = 0.0 # 0 hip .. 1 aiming var slide: float = 0.0 # 0 .. 1 slide blend var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean) + # Yaw of the LOWER body about the character's up axis, in radians — which way + # the legs are actually travelling. See SkinnedPlayerModel._update_travel. + var travel_yaw: float = 0.0 var aim_pitch: float = 0.0 # radians, up positive — upper body follows aim var recoil: float = 0.0 # decaying shot kick var state: String = "idle" @@ -704,10 +1297,20 @@ class ShooterPoseModifier extends SkeletonModifier3D: var gun_stock: float = 0.20 # 0..1 through a reload — drives the support hand to the mag well and back. var reload_phase: float = 0.0 + # [child_bone, helper_bone] pairs; see SkinJointHelper. + var joint_helpers: Array = [] # Tuning (radians). Positive pitch leans forward; positive roll leans right. - const LEAN_ROLL := 0.30 - const LEAN_PITCH := 0.18 + # The lean is the ONLY thing that tells a viewer which way this character is + # travelling — the library has one forward locomotion cycle and no strafe + # clips — so it has to be legible, not subtle. + ## How much of the hip yaw the spine takes back, so the chest, the head and + ## the gun stay pointed where the player is aiming. Not all of it: a real + ## torso does follow the hips a little, and countering the whole thing makes + ## the waist look broken. + const TRAVEL_COUNTER := 0.82 + const LEAN_ROLL := 0.42 + const LEAN_PITCH := 0.30 const SLIDE_BACK := 0.75 # torso lean-back during slide const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward const SLIDE_LEG_FWD := 0.95 # thighs swing forward so feet lead the slide @@ -720,6 +1323,75 @@ class ShooterPoseModifier extends SkeletonModifier3D: var _idx: Dictionary = {} var _resolved := false + ## Role -> actual bone name for THIS rig, from .rig.json. Empty when + ## the model was rebound onto the library skeleton, where the names below + ## already match. + var roles: Dictionary = {} + ## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar. + var fingers: Dictionary = {} + ## Per-character hold overrides — see characters/weapon_hold_tuning.gd and + ## debug/rig_lab.gd. A dictionary rather than a field per knob so a new + ## knob needs no plumbing: add it here, read it with _t/_tv, and the lab + ## picks it up from the same table. + var tune: Dictionary = {} + ## Where the hold put the weapon this frame, in skeleton space. The lab draws + ## these so the points being tuned are visible rather than inferred. + var dbg_grip: Vector3 = Vector3.ZERO + var dbg_fore: Vector3 = Vector3.ZERO + var dbg_stock: Vector3 = Vector3.ZERO + ## The gun's frame this frame — across, up, along the barrel. The lab needs + ## it to turn a mouse drag on an anchor marker back into the axes its knob is + ## expressed in; without it, dragging left would mean something different at + ## every pitch of the weapon. + var dbg_gun_basis: Basis = Basis.IDENTITY + ## What the weapon mount must be rotated by, in the trigger hand's own local + ## frame, to undo `wrist_r` — so the hand turns and the GUN does not. + ## + ## The weapon is parented to a BoneAttachment3D on that hand, so without this + ## the two are welded: rotating the wrist swings the barrel off the aim line, + ## and there is no second control that could bring it back, because every + ## control that moves the gun is expressed relative to the same hand. The + ## wrist knob was therefore unusable for the one thing it exists for. + ## + ## Identity when `wrist_r` is untuned, so a character nobody has touched + ## mounts its weapon exactly as before. + var wrist_comp_r: Quaternion = Quaternion.IDENTITY + + func _t(key: String, fallback: float) -> float: + return float(tune.get(key, fallback)) + + func _tv(key: String, fallback: Vector3) -> Vector3: + var v = tune.get(key) + # A zero-length vector means "not set" — see WeaponHoldTuning. It is how + # the elbow poles keep their hip/ADS blend unless overridden. + return v if (v is Vector3 and v.length() > 0.0001) else fallback + + ## A per-pose scalar knob, blended by `ads` the same way the hold itself is. + ## + ## Stored as `_hip` and `_ads` — the convention `pocket_hip` and + ## `pocket_ads` already used, now that every knob which ought to differ + ## between the two holds can. + func _tp(stem: String, d_hip: float, d_ads: float) -> float: + return lerpf(_t(stem + "_hip", d_hip), _t(stem + "_ads", d_ads), ads) + + func _tvp(stem: String, d_hip: Vector3, d_ads: Vector3) -> Vector3: + return _tv(stem + "_hip", d_hip).lerp(_tv(stem + "_ads", d_ads), ads) + + ## The wrist offset for one hand, as a rotation in the GUN's frame. + ## + ## Pitch about the weapon's across-axis, yaw about its up, roll about the + ## barrel — so the three sliders mean the same thing whether the muzzle is + ## down at low ready or level down the sights. Identity when untuned, which + ## is exactly what the hold did before there was anything but a roll. + func _wrist(stem: String, side: Vector3, up: Vector3, fwd: Vector3) -> Quaternion: + var w := _tvp(stem, Vector3.ZERO, Vector3.ZERO) + if w == Vector3.ZERO: + return Quaternion.IDENTITY + return Quaternion(side, w.x) * Quaternion(up, w.y) * Quaternion(fwd, w.z) + var _fing: Dictionary = {} # same, resolved to bone indices + var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame + ## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose. + var _hand_frame: Dictionary = {} func _resolve() -> void: var skel := get_skeleton() @@ -727,10 +1399,97 @@ class ShooterPoseModifier extends SkeletonModifier3D: "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R", "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L", "DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"] + # The names above are the LIBRARY skeleton's. A model that kept its own + # rig names things differently and three of them simply do not exist on + # it — 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 below silently did nothing. + var alias := {} + if not roles.is_empty(): + var neck: String = roles.get("neck", "") + var head: String = roles.get("head", "") + var torso: Array = [] + for n in roles.get("spine", []): + if n != neck and n != head: + torso.append(n) + for i in mini(torso.size(), SPINE.size() - 1): + alias[SPINE[i + 1]] = torso[i] for n in names: - _idx[n] = skel.find_bone(n) + # Canonical names are the role keys with the DEF- prefix, so the + # limbs, hips, neck and head all map straight through. + var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n)) + var b := skel.find_bone(actual) + if b < 0: + b = skel.find_bone(n) + _idx[n] = b + _resolve_hands(skel) _resolved = true + ## Finger bones, and the axis a finger closes about, per hand. + ## + ## The axis is derived from the hand's OWN ANATOMY in the rest pose, because + ## no two rigs agree on finger bone orientation and a constant would close + ## one character's hand and splay another's: + ## + ## 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 — which is + ## true of a hand, not of a rig convention. + ## curl along x palm, so turning about it swings the fingers into the + ## palm rather than sideways or backwards. + func _resolve_hands(skel: Skeleton3D) -> void: + for key in fingers: + var list := PackedInt32Array() + for n in fingers[key]: + var b := skel.find_bone(String(n)) + if b >= 0: + list.append(b) + if not list.is_empty(): + _fing[key] = list + for side in ["L", "R"]: + var hand: int = _idx.get("DEF-hand." + side, -1) + var mid: PackedInt32Array = _fing.get("middle." + side, PackedInt32Array()) + if hand < 0 or mid.is_empty(): + continue + var wrist := skel.get_bone_global_rest(hand).origin + var knuckle := skel.get_bone_global_rest(mid[0]).origin + var along := knuckle - wrist + if along.length() < 0.0001: + continue + along = along.normalized() + var palm := Vector3.ZERO + var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array()) + if not thumb.is_empty(): + palm = skel.get_bone_global_rest(thumb[thumb.size() - 1]).origin - knuckle + else: + # No thumb on this rig: fall back to the knuckle line, which + # gives a plane but not a side, so the sign may be wrong. + var ix: PackedInt32Array = _fing.get("index." + side, PackedInt32Array()) + var pk: PackedInt32Array = _fing.get("pinky." + side, PackedInt32Array()) + if ix.is_empty() or pk.is_empty(): + continue + palm = (skel.get_bone_global_rest(pk[0]).origin + - skel.get_bone_global_rest(ix[0]).origin).cross(along) + palm -= along * palm.dot(along) + if palm.length() < 0.0001: + continue + palm = palm.normalized() + var curl := along.cross(palm).normalized() + _curl[side] = curl + # The whole hand as an ANATOMICAL FRAME, not just the curl axis. + # + # Orienting a hand onto something it is gripping is a frame-to-frame + # problem: the fingers have to wrap AROUND the object, so the curl + # axis must lie along the object's axis, and the palm has to face it. + # Both are answered at once by mapping this frame onto the target's. + # Building the rotation from a shortest arc plus a constant twist — + # which is what was here — leaves the roll about the barrel + # unspecified, so the support hand landed upside down. + _hand_frame[side] = Basis(along, palm, curl) + if OS.has_environment("HAND_DEBUG"): + print("HANDS fingers=%d resolved=%d curl=%s" % [ + fingers.size(), _fing.size(), _curl]) + func _process_modification() -> void: var skel := get_skeleton() if not skel: @@ -755,10 +1514,17 @@ class ShooterPoseModifier extends SkeletonModifier3D: _apply_grapple(skel) if _hold_r > 0.01 or _hold_l > 0.01: _apply_rifle_hold(skel) + _close_hands(skel) if recoil > 0.01: _apply_recoil(skel) recoil = lerpf(recoil, 0.0, 0.25) + # Joints LAST, and inside the modification pass: each helper has to track + # whatever final rotation its child bone ended up with, or it deforms the + # limb instead of saving it. + if not joint_helpers.is_empty(): + SkinJointHelper.update(skel, joint_helpers) + # Upper body follows the camera pitch: distributed over spine/neck/head # so looking up/down reads on the whole silhouette, not just the head. @@ -783,16 +1549,42 @@ class ShooterPoseModifier extends SkeletonModifier3D: for n in ["DEF-spine.002", "DEF-spine.003"]: _add_space(skel, _idx.get(n, -1), back) - # Distribute a skeleton-space lean across the spine bones. + # Turn the lower body to face the way the character is travelling, and lean + # along that direction rather than along the facing. + # + # The legs hang off the hips, so yawing the hips turns the whole stride — the + # one thing that makes a sidestep look like a sidestep when the clip library + # has only a forward run. The spine takes most of it back so the chest and the + # gun stay on the aim. + # + # The LEAN has to move into the travel frame with it. Leaning "forward" along + # the character's facing while the legs run off to one side leans them + # sideways relative to their own stride, which is exactly the sensation of a + # character being dragged rather than running. func _apply_lean(skel: Skeleton3D) -> void: - var pitch := fwd * LEAN_PITCH * (1.0 - slide) - var roll := strafe * LEAN_ROLL * (1.0 - slide) - if absf(pitch) < 0.001 and absf(roll) < 0.001: + var yaw := travel_yaw * (1.0 - slide) + # How hard the character is driving, regardless of which way. Signed + # `fwd` is wrong now that the legs turn: a sidestep has fwd near zero and + # should still lean into its own stride. + var effort := clampf(Vector2(strafe, fwd).length(), 0.0, 1.0) + var pitch := effort * LEAN_PITCH * (1.0 - slide) + if absf(pitch) < 0.001 and absf(yaw) < 0.001: return - var q := Quaternion(Vector3(1, 0, 0), pitch) * Quaternion(Vector3(0, 0, 1), roll) - var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size()) - for n in SPINE: - _add_space(skel, _idx.get(n, -1), per) + var turn := Quaternion(Vector3(0, 1, 0), yaw) + # Pitch about the axis ACROSS the direction of travel, not across the + # facing — see the comment above the function. + var lean := Quaternion((turn * Vector3(1, 0, 0)).normalized(), pitch) + # The hips carry the yaw, and a little of the lean. + _add_space(skel, _idx.get("DEF-hips", -1), + turn * Quaternion.IDENTITY.slerp(lean, 0.25)) + # The torso unwinds the yaw and takes the rest of the lean. + var torso: Array = SPINE.slice(1) + var n := maxf(torso.size(), 1) + var back := Quaternion(Vector3(0, 1, 0), -yaw * TRAVEL_COUNTER) + var per_yaw := Quaternion.IDENTITY.slerp(back, 1.0 / n) + var per_lean := Quaternion.IDENTITY.slerp(lean, 0.75 / n) + for b in torso: + _add_space(skel, _idx.get(b, -1), per_yaw * per_lean) # Grapple zip: the whole body pivots to fly along the line to the anchor, # legs trail behind, and the FREE (left) hand reaches up the rope — the @@ -886,11 +1678,16 @@ class ShooterPoseModifier extends SkeletonModifier3D: # the handguard, instead of both arms waving at art-directed angles near it. # Skeleton space: character faces +Z, up +Y, character-right -X. - const GUN_PITCH_HIP := 0.38 # muzzle tilts down this much at low-ready + # Muzzle tilt at low-ready. Kept shallow: at the old 0.38 rad (22 degrees) + # the gun read as dangling from the hands rather than being carried. + const GUN_PITCH_HIP := 0.16 # Where the butt of the stock sits, relative to the right shoulder joint. # The pocket is on the FRONT of the shoulder, slightly inboard of the joint. + # Low-ready used to drop the butt 0.20 m to the ribs, which pulled the whole + # weapon off the shoulder and out of the arms — it never looked held. It now + # stays in the pocket and only relaxes slightly out of ADS. const POCKET_ADS := Vector3(0.05, 0.01, 0.07) # in the shoulder pocket - const POCKET_HIP := Vector3(0.02, -0.20, 0.05) # tucked down at the ribs + const POCKET_HIP := Vector3(0.03, -0.07, 0.06) # still shouldered, relaxed # Elbow bend hints (skeleton space). At the hip the firing elbow rides # down by the ribs; shouldered it flares OUT and level (the classic # "chicken wing"), which is what keeps the tight fold from folding the @@ -900,7 +1697,6 @@ class ShooterPoseModifier extends SkeletonModifier3D: const POLE_L_HIP := Vector3(0.45, -0.90, -0.10) const POLE_L_ADS := Vector3(0.30, -0.95, -0.05) const R_HAND_TWIST := 0.0 - const L_HAND_TWIST := 0.5 func _apply_rifle_hold(skel: Skeleton3D) -> void: var ua_r: int = _idx.get("DEF-upper_arm.R", -1) @@ -913,7 +1709,10 @@ class ShooterPoseModifier extends SkeletonModifier3D: # 1. The gun's line: pitched down at low-ready, on the camera line at # ADS, kicked up by recoil. - var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe + # + # `pitch` is a low-ready knob only, and deliberately: down the sights + # the muzzle follows the CAMERA, so there is nothing there to tune. + var gun_pitch := lerpf(_t("pitch_hip", GUN_PITCH_HIP), -aim_pitch, ads) - kick + breathe var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \ * Vector3(0, 0, 1)).normalized() # Gun's "up" — perpendicular to the barrel in the vertical plane, so the @@ -926,9 +1725,16 @@ class ShooterPoseModifier extends SkeletonModifier3D: # 2. Anchor the stock at the shoulder, then walk out along the barrel. var shoulder := skel.get_bone_global_pose(ua_r).origin - var pocket: Vector3 = POCKET_HIP.lerp(POCKET_ADS, ads) + var pocket: Vector3 = _tvp("pocket", POCKET_HIP, POCKET_ADS) var stock_pos := shoulder + pocket - var grip_pos := stock_pos + aim_dir * gun_stock + # The gun's own frame: across, up, along the barrel. The hand anchors are + # nudged in THIS rather than in skeleton space so a sideways offset stays + # sideways relative to the weapon as it pitches from low ready to ADS, + # instead of sliding around the gun as it tips. + var gun_basis := Basis(side, gun_up, aim_dir) + dbg_gun_basis = gun_basis + var grip_pos := stock_pos + aim_dir * gun_stock \ + + gun_basis * _tv("grip_shift", Vector3.ZERO) # The support hand rides as far out the handguard as it can actually # REACH. Without this a long rifle puts the foregrip past the left # arm's limit and the IK yanks the whole arm out straight. @@ -937,13 +1743,25 @@ class ShooterPoseModifier extends SkeletonModifier3D: var l_sh := skel.get_bone_global_pose(ua_l).origin var reach_l := _arm_reach(skel, "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L") * 0.94 - for _i in 5: + # Slide the support hand back down the handguard until the arm can + # reach it — but NEVER past this floor. Without one it collapsed to + # 0.083 m on Taila, which puts the support fist on top of the trigger + # fist: that reads as a two-handed pistol grip, not a rifle. A support + # arm that is nearly straight looks far better than no handguard hold. + var floor_fore: float = gun_fore * 0.55 + for _i in 6: if grip_pos.distance_to(l_sh) > reach_l: break # even the grip is out of reach; nothing to slide to if (grip_pos + aim_dir * fore_dist).distance_to(l_sh) <= reach_l: break - fore_dist *= 0.75 - var fore_pos := grip_pos + aim_dir * fore_dist + fore_dist = maxf(fore_dist * 0.8, floor_fore) + if fore_dist <= floor_fore: + break + var fore_pos := grip_pos + aim_dir * fore_dist \ + + gun_basis * _tv("fore_shift", Vector3.ZERO) + dbg_grip = grip_pos + dbg_fore = fore_pos + dbg_stock = stock_pos # 3. Support hand goes to the mag well during a reload (under the # receiver — the correct side), otherwise to the handguard. @@ -962,8 +1780,8 @@ class ShooterPoseModifier extends SkeletonModifier3D: l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20) # 4. Solve both arms onto those points. - var pole_r: Vector3 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized() - var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, ads).normalized() + var pole_r: Vector3 = _tvp("pole_r", POLE_R_HIP, POLE_R_ADS).normalized() + var pole_l: Vector3 = _tvp("pole_l", POLE_L_HIP, POLE_L_ADS).normalized() var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R", grip_pos, pole_r, _hold_r) var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L", @@ -971,8 +1789,9 @@ class ShooterPoseModifier extends SkeletonModifier3D: if OS.has_environment("GUN_POSE_DEBUG"): var hr: int = _idx.get("DEF-hand.R", -1) var hl: int = _idx.get("DEF-hand.L", -1) - print("HOLD ads=%.2f holdR=%.2f holdL=%.2f rl=%.2f fore=%.3f stock=%.3f foredist=%.3f" % [ - ads, _hold_r, _hold_l, reload_phase, gun_fore, gun_stock, fore_dist], + print("HOLD st=%s tgtL=%.2f ads=%.2f holdR=%.2f holdL=%.2f rl=%.2f fore=%.3f stock=%.3f foredist=%.3f" % [ + state, hold_l_target, ads, _hold_r, _hold_l, reload_phase, + gun_fore, gun_stock, fore_dist], " sh=", shoulder, " grip=", grip_pos, " fore=", fore_pos, " handR=", skel.get_bone_global_pose(hr).origin if hr >= 0 else "-", " handL=", skel.get_bone_global_pose(hl).origin if hl >= 0 else "-", @@ -992,21 +1811,110 @@ class ShooterPoseModifier extends SkeletonModifier3D: if up_flat.length_squared() > 0.0001: var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir) arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc - _set_global_rot(skel, hand, g_fa_r, arc, _hold_r) + # The artist's wrist, on top of the solved one. Three axes in the + # gun's frame rather than the single twist this used to take — + # the barrel has to lie on the aim line, which fixes two of the + # hand's three freedoms, but nothing fixes how far the wrist is + # cocked or broken, and those were unreachable. + # + # The gun hangs off THIS bone, so rotating it carries the gun + # along and the barrel comes off the aim line — which made the + # knob useless for its actual purpose, since there was then no + # way to align the hand to a gun that had moved with it. Both + # outcomes are computed, the hand takes the rotated one, and the + # difference between them is published as the counter-rotation + # the weapon mount needs to stay exactly where the solver put it. + # See `wrist_comp_r`. + var cur := skel.get_bone_pose_rotation(hand) + var local_free := (g_fa_r.inverse() * arc).normalized() + var wrist_q := _wrist("wrist_r", side, gun_up, aim_dir) + var local_wrist := (g_fa_r.inverse() * (wrist_q * arc)).normalized() + var applied := cur.slerp(local_wrist, _hold_r) + wrist_comp_r = applied.inverse() * cur.slerp(local_free, _hold_r) + skel.set_bone_pose_rotation(hand, applied) - # 6. Support hand: palm wraps the handguard, following its forearm. - if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY and ua_l >= 0: + # 6. Support hand: WRAP the handguard. + # + # Built as a frame, not as an arc plus a twist. A hand gripping a + # cylinder has its fingers curling AROUND that cylinder, which fixes two + # things at once and leaves nothing free: + # + # curl axis must lie along the BARREL, or the fingers close across + # the handguard instead of around it + # palm must face the barrel — up, for a hand supporting from + # underneath + # + # The old version aligned the hand's forearm line to the barrel with a + # shortest arc and then added a constant 0.5 rad twist. A shortest arc + # says nothing about roll, so the roll came entirely from that constant, + # and a constant is only ever right for the one rig it was tuned on — the + # support hand came out upside down. + if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY and ua_l >= 0 \ + and _hand_frame.has("L"): var hand_l: int = _idx.get("DEF-hand.L", -1) - var fa_l_idx: int = _idx.get("DEF-forearm.L", -1) - if hand_l >= 0 and fa_l_idx >= 0: - var fa_o := skel.get_bone_global_rest(fa_l_idx).origin - var hand_o := skel.get_bone_global_rest(hand_l).origin - var fa_rest_dir := (hand_o - fa_o).normalized() - var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion() - # Point the palm along the barrel so the fingers close over it. - var g_hand := Quaternion(aim_dir, L_HAND_TWIST) \ - * Quaternion(fa_rest_dir, aim_dir) * hand_rest_q - _set_global_rot(skel, hand_l, g_fa_l, g_hand, _hold_l) + if hand_l >= 0: + # -aim_dir so the hand comes at the handguard from the body side + # rather than reaching over it backwards. + var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up, + -aim_dir) + # Then the artist's wrist. Rolling about the barrel is the one + # axis a hand wrapping a cylinder is genuinely free in, and it + # used to be the only one offered — which left no way to cock the + # wrist forward or break it inward, and those are most of what + # separates a convincing support hand from a mannequin's. + # Rotating the whole frame is equivalent to the old roll for the + # roll component, since -aim_dir is unchanged by a rotation + # about aim_dir. + want = Basis(_wrist("wrist_l", side, gun_up, aim_dir)) * want + var rest: Basis = _hand_frame["L"] + var g_hand := (want * rest.inverse()).get_rotation_quaternion() \ + * skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion() + _set_global_rot(skel, hand_l, g_fa_l, g_hand.normalized(), _hold_l) + + # How far each segment of a finger closes, knuckle -> tip, in radians. + # + # Two different grips. The SUPPORT hand wraps a handguard, so all four + # fingers close hard and evenly. The TRIGGER hand wraps a pistol grip with + # three fingers while the index lies along the trigger, nearly straight — + # curling it with the rest is the single thing that most makes a game + # character look like they are squeezing a bar of soap rather than holding a + # rifle. + const CURL_WRAP := [0.85, 1.15, 0.85] + const CURL_TRIGGER := [0.42, 0.55, 0.35] + # The thumb opposes rather than curls, so it closes less and it is the one + # digit whose middle joint barely bends. + const CURL_THUMB := [0.45, 0.30, 0.25] + const DIGITS := ["index", "middle", "ring", "pinky"] + + ## Close both hands around whatever the IK put them on. + ## + ## Applied AFTER the hold, so the fingers follow wherever the wrists ended up. + ## Weighted by the per-arm hold blend, so a hand the clip has taken back opens + ## again instead of staying clamped shut. + func _close_hands(skel: Skeleton3D) -> void: + _close_hand(skel, "R", _hold_r) + _close_hand(skel, "L", _hold_l) + + func _close_hand(skel: Skeleton3D, side: String, w: float) -> void: + if w <= 0.01 or not _curl.has(side): + return + var axis: Vector3 = _curl[side] + for digit in DIGITS: + var bones: PackedInt32Array = _fing.get(digit + "." + side, + PackedInt32Array()) + if bones.is_empty(): + continue + # The right index rides the trigger; everything else wraps. + var trigger: bool = side == "R" and digit == "index" + var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP + var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0) + for i in bones.size(): + var a: float = amount[i] if i < amount.size() else amount[-1] + _add_space(skel, bones[i], Quaternion(axis, a * scale * w)) + var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array()) + for i in thumb.size(): + var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1] + _add_space(skel, thumb[i], Quaternion(axis, a * _t("curl_thumb", 1.0) * w)) ## Straight-arm length of an arm chain, from the rest pose. func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String, diff --git a/characters/spring_bones.gd b/characters/spring_bones.gd new file mode 100644 index 0000000..3b0fa75 --- /dev/null +++ b/characters/spring_bones.gd @@ -0,0 +1,1238 @@ +extends SkeletonModifier3D +class_name SpringBones + +## Secondary motion for cloth, hair and accessories — a position-based cloth +## solver over the rig's own cosmetic bones. +## +## The animation clips drive the BODY and nothing else — tools/retarget.py +## deliberately exports no tracks at all for skirt, hair or accessory bones (see +## its `export_optimize_animation_keep_anim_armature=False`). This is what moves +## them, and it is the half of the pipeline that makes clothes read as clothes. +## +## WHY A PARTICLE SOLVER RATHER THAN A SPRING PER BONE +## +## The previous version integrated one damped spring per bone and then tried to +## repair the result with four more passes stacked behind it: resolve the +## collision against the spring's target, resolve it again against the answer, +## relax the cross-panel links and rebuild every pose from the corrected tips, +## then walk a separate cyclic-descent "lift" up each chain. Each stage wrote +## bone poses the next stage read back and partly undid, and the lift wrote poses +## that were never fed back into the spring state at all — so the spring spent +## the next frame pulling against a pose it did not know about. That is a +## feedback loop, and it is what the skirt "glitching out when it is moved" +## actually was. It also could not stop the thigh coming through, because every +## one of those stages could only ROTATE a bone about its own head, and the +## contacts that matter sit near that head. +## +## This solves the whole garment the way cloth is actually solved — the same +## shape as Magica Cloth 2's BoneCloth, which is what the Hoyoverse-style +## character pipelines use: +## +## 1. Every JOINT of every chain is a particle. Bone i spans particle i to +## particle i+1, so a bone's head is a particle too and can move. +## 2. Predict: integrate each particle with inertia, gravity and wind, in the +## chain anchor's frame so simply travelling costs nothing. +## 3. Relax ALL the constraints together, several Gauss-Seidel iterations: +## bone length, a cone limit against the animated pose, a backstop on how +## far the garment may stray, the horizontal links that hold neighbouring +## panels together, and the leg/body colliders. +## 4. Convert the settled particles into bone rotations ONCE, at the end. +## +## Because collision is a constraint inside that loop rather than a repair pass +## behind it, nothing undoes it and it converges with everything else. And +## because a bone's HEAD is a particle, a contact with no rotational leverage is +## resolved by the whole panel moving — which is what a real skirt does when a +## thigh comes up under it, and what three separate bolt-on stages (a bodily +## chain push, an ancestor lift, a drape weight) were each approximating. +## +## Bone lengths, hull samples and the collider radii are MEASURED from the +## model's own geometry at build time and read from .rig.json — a glTF +## skeleton carries no bone tails at all, so there is nothing in the skeleton +## itself that says which way a panel hangs or how thick a thigh is. + +## Per class: w = stiffness pulling back toward the animated pose (rad/s), zeta = +## damping ratio (1.0 is critical, lower overshoots), gravity in m/s^2, wind = +## how far the class streams in the airflow, as a fraction of its own length. +## +## Hair is stiffer and lighter than cloth so it settles quickly instead of +## wobbling; a skirt is slacker and heavier so it lags and swings. +## +## Gravity is small because the AUTHORED rest pose already has the garment +## hanging; this only biases the droop while the body accelerates. It is not +## free — a constant force offsets the resting particle by g/w^2, so a large +## value pulls the hem below where it was modelled, straight into the thigh it +## then has to be pushed back out of. +## +## `stray` is the backstop — see MAX_STRAY. It is a per-class number because the +## two classes want opposite things from it. Hair has nothing to get out of the +## way of, so a tight leash is pure benefit and is what stops a long strand +## flailing. A garment has to be able to lie along a thigh that has swung +## horizontal in a slide, which is most of a panel's own length of travel, so the +## same leash on cloth is the clipping. +## `hinge` / `bend` are the swing limits — see MAX_SWING_ROOT. They are per class +## because the two ends of a chain mean different things to different garments. A +## SKIRT hangs from a waistband and hinges there freely: a knee coming up to hip +## height puts a thigh horizontally through where the front panel hangs, and the +## panel has to ride up onto it, which is most of a right angle. HAIR grows out +## of a scalp — a strand that hinges 70 degrees at the root has come off the +## head. Same constraint, opposite settings, and one shared number was wrong for +## both. +const TUNING := { + "hair": {"w": 15.0, "zeta": 0.45, "gravity": 1.6, "wind": 0.30, "stray": 0.45, + "hinge": 26.0, "bend": 52.0}, + "skirt": {"w": 12.0, "zeta": 0.48, "gravity": 2.0, "wind": 0.18, "stray": 1.25, + "hinge": 78.0, "bend": 55.0}, + "cloth": {"w": 13.0, "zeta": 0.55, "gravity": 1.9, "wind": 0.18, "stray": 1.10, + "hinge": 62.0, "bend": 52.0}, + "cape": {"w": 10.0, "zeta": 0.50, "gravity": 2.4, "wind": 0.35, "stray": 1.10, + "hinge": 55.0, "bend": 55.0}, + "coat": {"w": 12.0, "zeta": 0.52, "gravity": 2.1, "wind": 0.22, "stray": 1.10, + "hinge": 62.0, "bend": 52.0}, + "scarf": {"w": 13.0, "zeta": 0.55, "gravity": 1.8, "wind": 0.35, "stray": 0.70, + "hinge": 40.0, "bend": 58.0}, + "ribbon": {"w": 15.0, "zeta": 0.52, "gravity": 1.6, "wind": 0.35, "stray": 0.70, + "hinge": 35.0, "bend": 58.0}, + "tail": {"w": 14.0, "zeta": 0.55, "gravity": 1.5, "wind": 0.20, "stray": 0.60, + "hinge": 30.0, "bend": 50.0}, + "sleeve": {"w": 15.0, "zeta": 0.60, "gravity": 1.6, "wind": 0.20, "stray": 0.60, + "hinge": 30.0, "bend": 45.0}, + "breast": {"w": 21.0, "zeta": 0.65, "gravity": 1.0, "wind": 0.0, "stray": 0.30, + "hinge": 14.0, "bend": 14.0}, + "bust": {"w": 21.0, "zeta": 0.65, "gravity": 1.0, "wind": 0.0, "stray": 0.30, + "hinge": 14.0, "bend": 14.0}, +} +const DEFAULT_TUNING := {"w": 14.0, "zeta": 0.55, "gravity": 1.8, "wind": 0.2, + "stray": 0.55, "hinge": 40.0, "bend": 52.0} + +## The most a bone may turn away from the direction the animated pose gives it — +## GRADED along the chain, tighter where it is anchored and looser at the free +## end. Angles still compound down a chain, so a strand curves well past these +## overall, and how far the garment may travel in total is bounded separately by +## MAX_STRAY. +## +## The ROOT limit is not the 20 degrees Magica Cloth 2's skirt guide suggests, +## and the difference is geometry rather than taste. Their figure is for a chain +## whose first bone is a large share of the skirt; the first segment of one of +## Taila's panels is 49 mm of a 288 mm panel, so 20 degrees there moves the panel +## below it by SEVENTEEN MILLIMETRES. Measured over a movement sweep, the solver +## saw 104 mm of thigh inside the front of the skirt, pushed on it every frame +## and every iteration, and left 95 mm of it — not because it was blind or +## mistuned, but because the joint it had to turn was allowed 17 mm of travel. +## +## The two limits also mean different things. Between segments this is BENDING +## stiffness — how sharply a sheet of cloth may crease — and it belongs tight. +## At the root it is a HINGE at the waistband, and a skirt swinging clear of a +## knee coming up under it really does hinge 40-odd degrees there. +## +## Collision is applied after this within each iteration and may exceed it — the +## same precedence Magica uses ("collision detection has a higher priority than +## this limit"). +const MAX_SWING_ROOT := deg_to_rad(42.0) +const MAX_SWING_TIP := deg_to_rad(58.0) + +## How far a particle may end up from where the animation alone would have put +## it, as a fraction of the WHOLE chain's length. +## +## The cone limit above is measured against the PARENT, so a four-bone chain can +## legitimately curl to 4x its per-joint limit and the far end of a long strand +## gets thrown a long way by errors that are individually reasonable. This bounds +## the total. It is a hard positional clamp and it is the single thing that turns +## "hair spazzing about" into hair — Magica calls it Max Distance and it is the +## first parameter their setup guide reaches for. +## +## Measured against the whole garment, NOT against the chain hanging above each +## joint. Scaling it per joint sounds right and throttles exactly the wrong +## place: the second segment of one of Taila's skirt panels has only 98 mm of +## chain above it, which allowed it 44 mm of travel while the thigh under it +## needed it to move 100 mm — so the leg went through the front of the skirt and +## every collider measurement said the solver had fired. The garment is one piece +## of cloth and the whole of it has the same room to move. +## +## Overridden per class in TUNING; this is only the fallback for a class with no +## entry of its own. +const MAX_STRAY := 0.55 + +## Solver iterations. Gauss-Seidel over length, cone, backstop, collision and the +## cross-panel links. Four is enough for the chains here (four bones deep); the +## extra two buy the collision convergence that stops a thigh showing through on +## the frames where a leg sweeps hard. +const ITERATIONS := 3 +## Collision-and-length-only passes after the main loop. See the solve. +const SETTLE_PASSES := 1 + +## Detail level, set by the owner from how much the viewer can actually see — +## 0 is the full solve, 3 is the cheapest. +## +## This is not optional polish. The solver relaxes 127 joints against five +## capsules several times a frame, in GDScript, and collision is three quarters +## of that: a full-quality character costs about 2.6 ms, which is a sixth of a +## 60 fps frame for ONE of them. A shooter has several on screen and only the +## nearest is being looked at closely enough for a hem to matter. +## +## Each level drops a pass or the collision, in the order that costs the least +## visually — the shape constraints hold the silhouette on their own, so a +## distant character still has a skirt that swings; it just stops being carefully +## kept off a thigh nobody can resolve at that range. +const LOD_ITERS := [3, 2, 2, 1] +const LOD_SETTLE := [1, 1, 0, 0] +const LOD_COLLIDE := [true, true, true, false] +var lod: int = 0: set = _set_lod +var _iters: int = ITERATIONS +var _settles: int = SETTLE_PASSES +var _collide: bool = true + +func _set_lod(v: int) -> void: + lod = clampi(v, 0, LOD_ITERS.size() - 1) + _iters = LOD_ITERS[lod] + _settles = LOD_SETTLE[lod] + _collide = LOD_COLLIDE[lod] +## Inverse mass of a chain's ROOT joint, relative to the joints below it. +## +## Not zero. A contact against the top of a thigh has almost no lever on the bone +## that owns it, so no rotation can clear it — which is what defeated every +## earlier round of tuning. Letting the root carry a little of the correction +## lets the whole panel ride up over the leg instead, rigidly, so the waistband +## seam cannot be stretched open by it. Small, and hard-clamped by ROOT_STRAY, so +## the garment can never detach from the body. +const ROOT_INV_MASS := 0.25 +const ROOT_STRAY := 0.045 +## A frame delta longer than this is a hitch or a load spike. Integrating it +## launches every chain across the map, so it is clamped instead. +const MAX_STEP := 1.0 / 30.0 +## Origin jump (metres in one frame) that means a teleport — respawn, or the +## model being reparented — rather than movement. Chains snap instead of whip. +const TELEPORT := 1.5 +## Speed at which the wind term reaches full strength. +const WIND_SPEED := 9.0 +## WHY THERE IS NO "DRAPE" TERM HERE ANY MORE. +## +## There used to be one: each cloth bone measured how much of it rested on each +## leg, and took a share of that leg's motion before the solver ran, so a thigh +## arrived with the skirt already moving out of its way. Hoyoverse-style rigs do +## carry a partial constraint from the leg onto the upper skirt bones, and the +## idea is sound — but it was here to paper over a solver that could not push a +## panel off a thigh at all, and it cost far more than it bought once that was +## fixed. Measured over the movement sweep in debug/cloth_clip_check.gd, with it +## against without: +## +## run 101 -> 92 mm fall 82 -> 49 mm +## air 96 -> 88 mm dash 136 -> 95 mm +## slide 97 -> 96 mm idle after a dash 103 -> 20 mm +## +## It was worse in every state but a walk, and that last row is the one that +## mattered: WITH the drape the garment never came home after a hard move, it +## just sat displaced. It also cost an order of magnitude in stability — 0.48 +## deg/frame of skirt movement at a dead-still idle against 0.05 without, because +## the target it aimed at sat inside the leg the collision was pushing out of, so +## the two ran against each other forever. That is what the skirt "glitching out" +## was, and it is gone with the term that caused it. +## +## The mesh-neighbour data it used is still read: the horizontal links below need +## it, and that is what it was always most useful for. +## HORIZONTAL links between neighbouring cloth chains. +## +## A skirt's bones are linked vertically by the parent-child hierarchy and not at +## all sideways, so every panel solves as if the ones sewn to it did not exist. +## Magica Cloth 2's BoneCloth skirt guide calls connecting them "the most +## important work when expressing a skirt", and notes that without it "the +## accuracy of collision detection will be significantly reduced" — which is +## exactly what was happening here: each panel individually satisfied its +## constraints and the garment as a whole came apart. +## +## Relaxed inside the same iteration loop as everything else, so a panel pushed +## off a thigh takes the ones sewn to it with it. +const LINK_STIFF := 0.45 +## The same link resisting COMPRESSION. Much weaker: see the solve loop. +const LINK_SQUASH := 0.12 +## Most links per bone, strongest first. A skirt vertex is shared with a handful +## of neighbours; linking every one of them just stiffens the garment into a +## bell. +const LINK_MAX := 4 +## Classes a leg may carry. Cloth that hangs over the hips — not hair. +## Ceiling on how fast a particle may travel relative to its anchor, as a +## multiple of the bone length it hangs from. Bounds the tip speed to something +## proportional to the bone instead of a flat number that means nothing to a +## 20 mm hair segment and everything to a 140 mm skirt hem. +const MAX_RATE := 14.0 +## Ceiling on how fast a chain's anchor is believed to be travelling, and how +## quickly that estimate may change. Backstops against a bad frame delta. +const MAX_CARRY := 20.0 +const CARRY_SMOOTH := 0.15 +const MAX_SUBSTEPS := 4 +## Overlap a contact is allowed to keep before the solver acts on it. +## +## The rest-clearance table is measured in the AUTHORED rest pose, and a +## character never stands in it — the idle clip alone moves the legs enough that +## cloth hanging against a thigh grazes its capsule every frame. Without slop +## those grazes are real contacts and the idle buzzes. Six millimetres is well +## under anything the eye can find on a hem and it makes the idle silent. +const CONTACT_SLOP := 0.006 +## Fractions along a bone tested against the colliders when the sidecar carries +## no hull for it. A skirt panel is a sheet, so testing only its tip lets the +## middle of the panel clip. +const SAMPLES := [0.35, 0.7, 1.0] +## How much of a collision correction the bone's HEAD takes when the contact sits +## at the head end. 1.0 would let a panel shear off its own waistband. +const COLLIDE_HEAD_SHARE := 0.85 + +var _chains: Array = [] +var _colliders: Array = [] +## [particle a, particle b, rest distance] for every horizontal link. Indices are +## into the flat particle arrays below. +var _links: Array = [] +## Joint particles: one per bone plus a final tip, per chain. Kept in the chain +## ANCHOR's frame, so simply travelling through the world cancels exactly and the +## solver only ever deals with the motion that should actually move cloth. +var _q: PackedVector3Array = PackedVector3Array() +var _v: PackedVector3Array = PackedVector3Array() +## Scratch, world space, rebuilt every frame. Kept as members so the per-frame +## solve does not reallocate. +var _pw: PackedVector3Array = PackedVector3Array() # working positions +var _ref: PackedVector3Array = PackedVector3Array() # animated reference +var _rdir: PackedVector3Array = PackedVector3Array() # reference bone dirs +var _rbasis: Array = [] # animated bone bases +var _imass: PackedFloat32Array = PackedFloat32Array() +## The collider capsules in world space, rebuilt ONCE per frame. +## +## They were being rebuilt inside the innermost loop — for every bone, against +## every collider, on every relaxation pass — which is two skeleton queries and +## two transform multiplies about five thousand times a frame for a result that +## cannot change while the solve is running. That alone was most of the 10.9 ms +## per character this used to cost. +var _cap_a: PackedVector3Array = PackedVector3Array() +var _cap_b: PackedVector3Array = PackedVector3Array() +var _cap_rh: PackedFloat32Array = PackedFloat32Array() +var _cap_rt: PackedFloat32Array = PackedFloat32Array() +## Scratch for one bone's posed hull points, reused instead of reallocated. +var _pts: PackedVector3Array = PackedVector3Array() +## Where each chain's anchor was last frame, to measure how fast it is carrying +## the cloth through the world. +var _anchor_prev: PackedVector3Array = PackedVector3Array() +var _carry_prev: PackedVector3Array = PackedVector3Array() +var _time: float = 0.0 +var _settled: bool = false +## Force a fixed timestep instead of the engine clock. 0 = normal. Set by +## debug/cloth_settle_check.gd so settling can be measured at a REAL frame rate: +## headless runs uncapped, where the clamped sub-millisecond delta makes every +## chain look motionless whatever the tuning. +var fixed_delta: float = 0.0 +## Diagnostics for the debug scripts — how many collider pushes fired, and how +## deep the worst contact on each bone got. +var _hits: int = 0 +var _hit_frames: int = 0 +var _hit_depth: Dictionary = {} +var _applied: Dictionary = {} +var _left: Dictionary = {} +var debug_bone: int = -1 +var debug_swing: float = 0.0 +var debug_len: float = 0.0 +var debug_origin: Vector3 = Vector3.ZERO +var _calls: int = 0 +var _frames_seen: Dictionary = {} +var _dt_min: float = 999.0 +var _dt_max: float = 0.0 + + +## How many times the solver ran per rendered frame, and the delta spread. +func debug_rate() -> String: + var n: int = maxi(_frames_seen.size(), 1) + var out := "calls/frame %.2f dt %.5f..%.5f" % [float(_calls) / n, _dt_min, _dt_max] + _calls = 0 + _frames_seen.clear() + _dt_min = 999.0 + _dt_max = 0.0 + return out + + +## bone index -> [total correction applied (mm), deepest overlap seen (mm)]. +func debug_effort_report() -> Dictionary: + var out := {} + for b in _applied: + out[b] = [_applied[b] * 1000.0, _left.get(b, 0.0) * 1000.0] + _applied.clear() + _left.clear() + return out + + +## bone index -> worst penetration (metres) seen since the last call. +func debug_hit_report() -> Dictionary: + var d := _hit_depth.duplicate() + _hit_depth.clear() + return d + + +## Average collider pushes per frame since the last call. A chain that is +## settling should report ~0; a steady non-zero count means the solver and the +## colliders are fighting, which no amount of damping will settle. +func debug_collisions_per_frame() -> float: + var v := float(_hits) / maxf(_hit_frames, 1.0) + _hits = 0 + _hit_frames = 0 + return v + + +## Build from the sidecar written by tools/retarget.py. Returns how many bones +## are being driven, so the caller can log or disable itself when there are none. +## +## `hull_override` is bone name -> the points, in that bone's own rest space, +## that the collision should test. It comes from the MESH ITSELF at load time +## (see SkinnedPlayerModel._cloth_hulls) and it replaces the ten farthest-point +## samples the sidecar carries. Farthest-point sampling spans a panel's corners +## and hem and leaves its MIDDLE unsampled, which is precisely where a thigh +## comes up through a skirt: measured over a movement sweep the solver reported +## every contact resolved while 158 vertices were 95 mm inside a leg, because +## not one of them was a point it was looking at. +func setup(skel: Skeleton3D, info: Dictionary, hull_override: Dictionary = {}) -> int: + _chains.clear() + _links.clear() + _colliders.clear() + if skel == null or info.is_empty(): + return 0 + + # Colliders first: each chain's per-bone clearance is measured against them. + for c in info.get("colliders", []): + var ca := skel.find_bone(String(c.get("bone", ""))) + var cb := skel.find_bone(String(c.get("child", ""))) + if ca >= 0 and cb >= 0: + var tail := float(c.get("radius_tail", c.get("radius", 0.1))) + _colliders.append({ + "a": ca, "b": cb, + # How far down the bone the capsule starts. The top of a thigh is + # hip, not limb — see tools/retarget.py::_leg_colliders. + "from": float(c.get("from", 0.0)), + "lid": bool(c.get("lid", false)), + "rh": float(c.get("radius_head", tail)), + "rt": tail, + }) + + var total := 0 + for entry in info.get("chains", []): + var parent_name: String = entry.get("root_parent", "") + var parent := skel.find_bone(parent_name) if parent_name != "" else -1 + if parent < 0: + continue + var bones := PackedInt32Array() + var tips := PackedVector3Array() + var hulls: Array = [] + var names: Array = entry.get("bones", []) + var raw_tips: Array = entry.get("tips", []) + var raw_hulls: Array = entry.get("hulls", []) + for i in names.size(): + var idx := skel.find_bone(String(names[i])) + if idx < 0: + continue + var tip := Vector3.ZERO + if i < raw_tips.size(): + var t: Array = raw_tips[i] + if t.size() == 3: + tip = Vector3(t[0], t[1], t[2]) + if tip.length() < 0.001: + continue # no measurable extent — nothing to swing + # Chains must stay a strict parent->child run: the particle chain + # below assumes bone i+1 hangs off bone i, and a gap would put a + # distance constraint across a joint that is not there. + if bones.size() > 0 and skel.get_bone_parent(idx) != bones[bones.size() - 1]: + break + bones.append(idx) + tips.append(tip) + var hull: PackedVector3Array = hull_override.get( + skel.get_bone_name(idx), PackedVector3Array()) + if hull.is_empty() and i < raw_hulls.size(): + for h in raw_hulls[i]: + if h.size() == 3: + hull.append(Vector3(h[0], h[1], h[2])) + hulls.append(hull) + if bones.is_empty(): + continue + var cls := String(entry.get("class", "")) + var tune: Dictionary = TUNING.get(cls, DEFAULT_TUNING) + # Rest LINK geometry: particle i sits at bone i's head, particle n at the + # last bone's tip. So the link below bone i reaches to the next bone's + # head, which is NOT the same vector as that bone's mesh tip — the last + # segment of a panel runs on past its own child joint to the hem. + var seg := PackedFloat32Array() + var segdir := PackedVector3Array() + var span := 0.0 + for i in bones.size(): + var v: Vector3 = tips[i] if i == bones.size() - 1 \ + else skel.get_bone_rest(bones[i + 1]).origin + if v.length() < 0.0005: + v = tips[i] + seg.append(v.length()) + segdir.append(v.normalized()) + span += v.length() + _chains.append({ + "parent": parent, + "bones": bones, + "tips": tips, + "seg": seg, + "segdir": segdir, + "span": span, + "hulls": hulls, + "class": cls, + # Everything collides now, including hair. It used to be garments + # only, because a collision push happened AFTER the integrator and so + # was deaf to every spring parameter — long back hair reaching past + # the hips got shoved out of a thigh and hauled back every frame at + # stride frequency, which is the blur. Inside the relaxation there is + # no such fight, and hair that does not collide simply passes through + # the body: measured 373 mm of leg inside the hair over a movement + # sweep, against 183 mm for the skirt that did collide. + "radii": _rest_clearances(skel, bones, tips, hulls), + "names": names, + "neighbours": entry.get("neighbours", []), + "w": float(tune["w"]), + "zeta": float(tune["zeta"]), + "gravity": float(tune["gravity"]), + "wind": float(tune.get("wind", 0.0)), + "stray": float(tune.get("stray", MAX_STRAY)), + "hinge": deg_to_rad(float(tune.get("hinge", + rad_to_deg(MAX_SWING_ROOT)))), + "bend": deg_to_rad(float(tune.get("bend", rad_to_deg(MAX_SWING_TIP)))), + }) + total += bones.size() + + # Flat particle layout: n+1 particles per chain. + var k := 0 + for ci in _chains.size(): + _chains[ci]["base"] = k + k += (_chains[ci]["bones"] as PackedInt32Array).size() + 1 + _build_links(skel) + + print("SpringBones: %d chains, %d particles, %d collision hull points" % [ + _chains.size(), k, _hull_points()]) + + _q.resize(k) + _v.resize(k) + _pw.resize(k) + _ref.resize(k) + _rdir.resize(k) + _imass.resize(k) + _rbasis.resize(k) + _cap_a.resize(_colliders.size()) + _cap_b.resize(_colliders.size()) + _cap_rh.resize(_colliders.size()) + _cap_rt.resize(_colliders.size()) + for c in _colliders.size(): + _cap_rh[c] = float(_colliders[c]["rh"]) + _cap_rt[c] = float(_colliders[c]["rt"]) + _anchor_prev.resize(_chains.size()) + _carry_prev.resize(_chains.size()) + # Inverse mass: heavier (stiffer) near the anchor, lighter toward the free + # end, so a correction prefers to move the hem rather than the waistband. + for ci in _chains.size(): + var base: int = _chains[ci]["base"] + var n: int = (_chains[ci]["bones"] as PackedInt32Array).size() + for i in n + 1: + var along := 0.0 if n < 1 else float(i) / float(n) + _imass[base + i] = ROOT_INV_MASS if i == 0 else lerpf(0.55, 1.0, along) + _settled = false + return total + + +## Total collision sample points across every chain — the solver's real cost. +func _hull_points() -> int: + var n := 0 + for c in _chains: + for h: PackedVector3Array in c["hulls"]: + n += h.size() if h.size() > 0 else SAMPLES.size() + return n + + +## The two ends of a collider capsule, with its `from` offset applied — so every +## place that tests against a limb agrees on where that limb starts. +func _capsule(skel: Skeleton3D, to_world: Transform3D, col: Dictionary) -> Array: + var a: Vector3 = to_world * skel.get_bone_global_pose(col["a"]).origin + var b: Vector3 = to_world * skel.get_bone_global_pose(col["b"]).origin + return [a.lerp(b, float(col["from"])), b] + + +## Same, in the skeleton's REST pose. +func _capsule_rest(skel: Skeleton3D, col: Dictionary) -> Array: + var a := skel.get_bone_global_rest(col["a"]).origin + var b := skel.get_bone_global_rest(col["b"]).origin + return [a.lerp(b, float(col["from"])), b] + + +## Per (bone, collider) capsule radius, capped so the REST pose never collides. +## +## The authored rest pose is by definition not clipping — the artist modelled the +## skirt over these legs. But a capsule sized from the thigh's own vertices is +## fat enough (0.11 m at Taila's hip) to swallow the skirt bones that hang right +## against it, and those bones then get shoved out and pulled straight back in +## every single frame: a limit cycle that never decays. That is cloth and hair +## "never settling", and it also leaves the collider saturated and useless +## against the clipping it exists to prevent. +## +## Capping to just inside the rest clearance makes the rest pose a valid state, +## so idle is quiet and a push only ever means the leg has genuinely swung into +## the cloth. +## +## The cap is PER POINT, not per bone. Scaling a whole bone's radius by its worst +## point switches collision off entirely for every skirt panel whose top +## naturally hangs against the thigh — which is all the ones that matter. +## +## And it is measured against the capsule radius WHERE THE POINT RESTS, not +## against the widest radius anywhere on the limb. A leg tapers 2:1 from hip to +## knee, so comparing every point to the hip radius declares the whole skirt +## already inside the capsule and hands all of it the reduced allowance meant for +## the waistband alone. +func _rest_clearances(skel: Skeleton3D, bones: PackedInt32Array, + tips: PackedVector3Array, hulls: Array) -> Array: + var out: Array = [] + for i in bones.size(): + var rest := skel.get_bone_global_rest(bones[i]) + var origin := rest.origin + var tip: Vector3 = rest * tips[i] + var pts := _sample_points(rest, origin, tip, hulls[i]) + var n := _colliders.size() * pts.size() + var caps := PackedFloat32Array() + caps.resize(n) + # How far this bone's geometry reaches from its own head, for the + # broad-phase skip in _collide_bone. + var span := 0.0 + for p: Vector3 in pts: + span = maxf(span, p.distance_to(origin)) + for c in _colliders.size(): + var col: Dictionary = _colliders[c] + var ends := _capsule_rest(skel, col) + var a: Vector3 = ends[0] + var b: Vector3 = ends[1] + var ab := b - a + var d2 := ab.length_squared() + for j in pts.size(): + var t: float = 0.0 if d2 < 0.000001 \ + else clampf((pts[j] - a).dot(ab) / d2, 0.0, 1.0) + var d: float = (pts[j] - (a + ab * t)).length() + # A limb tapers 2:1 hip to knee, so measure against the radius + # WHERE THE POINT IS. + var local: float = lerpf(float(col["rh"]), float(col["rt"]), t) + caps[c * pts.size() + j] = maxf(local, d * 0.9) if d >= local else d * 0.9 + out.append({"cap": caps, "span": span}) + return out + + +## Distance links between bones of DIFFERENT chains that share mesh vertices. +## +## The garment's own topology decides what is sewn to what — `neighbours` in the +## sidecar is the weight each pair of cloth bones shares over the same vertices, +## which is the artist's answer and not a guess from names or rest distance. +## +## Only cross-chain pairs: within a chain the particle links already hold the +## bones together, and re-stating that here only fights them. +func _build_links(skel: Skeleton3D) -> void: + _links.clear() + # bone -> [chain, index]; the particle at that bone's TIP is base+index+1. + var where := {} + for ci in _chains.size(): + var bones: PackedInt32Array = _chains[ci]["bones"] + for i in bones.size(): + where[bones[i]] = [ci, i] + + var seen := {} + for ci in _chains.size(): + var chain: Dictionary = _chains[ci] + if String(chain["class"]) == "hair": + continue # hair strands hang free; linking them stiffens them into rope + var bones: PackedInt32Array = chain["bones"] + var names: Array = chain["names"] + var nbrs: Array = chain["neighbours"] + for i in bones.size(): + var at: int = names.find(skel.get_bone_name(bones[i])) + if at < 0 or at >= nbrs.size(): + continue + var nb: Dictionary = nbrs[at] + # Strongest few only — see LINK_MAX. + var ranked: Array = nb.keys() + ranked.sort_custom(func(a, b): return float(nb[a]) > float(nb[b])) + var made := 0 + for other_name in ranked: + if made >= LINK_MAX: + break + var oi := skel.find_bone(String(other_name)) + if oi < 0 or not where.has(oi) or where[oi][0] == ci: + continue + var pa: int = int(chain["base"]) + i + 1 + var ob: Array = where[oi] + var pb: int = int(_chains[ob[0]]["base"]) + int(ob[1]) + 1 + var key := "%d_%d" % [mini(pa, pb), maxi(pa, pb)] + if seen.has(key): + continue + seen[key] = true + made += 1 + # Rest separation of the two TIPS, which is what the links hold. + var oc: Dictionary = _chains[ob[0]] + var wa: Vector3 = skel.get_bone_global_rest(bones[i]) \ + * (chain["tips"] as PackedVector3Array)[i] + var wb: Vector3 = skel.get_bone_global_rest(oi) \ + * (oc["tips"] as PackedVector3Array)[ob[1]] + _links.append([pa, pb, wa.distance_to(wb)]) + print("SpringBones: %d horizontal links between cloth chains" % _links.size()) + + +static func _sample_points(xform: Transform3D, origin: Vector3, tip: Vector3, + hull: PackedVector3Array) -> Array: + var out: Array = [] + if hull.size() > 0: + for h in hull: + out.append(xform * h) + return out + for s in SAMPLES: + out.append(origin.lerp(tip, s)) + return out + + +static func _seg_point_distance(p: Vector3, a: Vector3, b: Vector3) -> float: + var ab := b - a + var d2 := ab.length_squared() + var t := 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0) + return p.distance_to(a + ab * t) + + +## The ENGINE's frame delta, never a wall clock. +## +## `Time.get_ticks_usec()` deltas measured 0.002 s to 0.033 s inside a single +## second — a 16x spread. That alone is survivable, but the anchor velocity +## divides a position change by it, and that position change came from the +## animation advancing by the ENGINE's delta. When the two disagree the computed +## velocity is wrong by that ratio, so the wind and the damping reference are +## wrong by 16x frame to frame. +func _delta() -> float: + if fixed_delta > 0.0: + return fixed_delta + var dt := get_physics_process_delta_time() if Engine.is_in_physics_frame() \ + else get_process_delta_time() + return clampf(dt, 1.0 / 480.0, MAX_STEP) + + +# ── The solve ───────────────────────────────────────────────────────────────── + +func _process_modification() -> void: + var skel := get_skeleton() + if skel == null or _chains.is_empty(): + return + var dt := _delta() + _calls += 1 + _frames_seen[Engine.get_process_frames()] = true + _dt_min = minf(_dt_min, dt) + _dt_max = maxf(_dt_max, dt) + _time += dt + _hit_frames += 1 + var to_world := skel.global_transform + for c in _colliders.size(): + var ends := _capsule(skel, to_world, _colliders[c]) + _cap_a[c] = ends[0] + _cap_b[c] = ends[1] + + # 1. Where the animation alone would put every joint this frame, including + # the share of each leg's swing the cloth resting on it is carried by. + var anchors: Array = [] + for ci in _chains.size(): + anchors.append(_build_reference(skel, to_world, ci)) + + # 2. Integrate. The state lives in the anchor's frame, so travelling at a + # steady speed excites nothing and only real acceleration, rotation, wind + # and gravity move the cloth. + for ci in _chains.size(): + _predict(skel, ci, anchors[ci], dt) + + # 3. Relax everything together. + # + # LINKS FIRST, chains second, so the last thing to touch any particle in an + # iteration is its collision. Order is not cosmetic here. With the chains + # first, every iteration ended by pulling neighbouring panels back toward + # their rest separation — straight back into the leg that had just been + # cleared. Measured on a slide: 93 mm of thigh inside a front panel went in, + # 95 mm came out, and with the links switched off entirely the same frame + # solved down to 27 mm. Collision outranks the garment's own shape, which is + # also the precedence Magica Cloth 2 documents. + for _it in _iters: + for L in _links: + var a: int = L[0] + var b: int = L[1] + var d: Vector3 = _pw[b] - _pw[a] + var cur := d.length() + if cur < 0.00001: + continue + var wa: float = _imass[a] + var wb: float = _imass[b] + var sum := wa + wb + if sum <= 0.0001: + continue + # Cloth is stiff in TENSION and nearly free in COMPRESSION — a + # garment gathers and folds happily but does not tear. Pulling + # equally hard on both is what made these links fight the legs: a + # panel riding up over a thigh opens away from the ones sewn beside + # it, and a symmetric link reads that as the garment coming apart. + var rest: float = float(L[2]) + var k: float = LINK_STIFF if cur > rest else LINK_SQUASH + var move: Vector3 = d * ((cur - rest) / cur * k / sum) + _pw[a] += move * wa + _pw[b] -= move * wb + for ci in _chains.size(): + _solve_chain(skel, to_world, ci, true) + # A short tail of COLLISION AND BONE LENGTH ONLY. + # + # The cone limit and the backstop are shape constraints — they say what the + # garment should look like — and re-imposing them after each collision means + # the two argue instead of converging: quadrupling the main iteration count + # moved a 92 mm overlap to 76 mm and stalled, which is a standing fight + # rather than slow relaxation. These last passes let the collision finish, + # holding only the constraint that cannot be given up (a bone has a length). + # Shape has already been decided above and a few millimetres of overlap is + # all that is left to clear, so nothing visible is given away by it. + for _t in _settles: + for ci in _chains.size(): + _solve_chain(skel, to_world, ci, false) + + # What the relaxation actually converged to, measured but not corrected. Read + # alongside the same measurement taken off the finished skeleton, this is the + # only way to tell "the constraints could not agree" from "they agreed and + # the answer was lost on the way into the pose". + if _want_residual: + for ci in _chains.size(): + _measure_residual(skel, to_world, ci) + + # 4. Settled particles -> bone poses, and back into the anchor's frame. + for ci in _chains.size(): + _write_poses(skel, anchors[ci], ci) + _settled = true + + +## Deepest remaining overlap per bone, from the settled particles. Diagnostic +## only — nothing is corrected here. +func _measure_residual(skel: Skeleton3D, to_world: Transform3D, ci: int) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var seg: PackedFloat32Array = chain["seg"] + var hulls: Array = chain["hulls"] + var radii: Array = chain["radii"] + var base: int = chain["base"] + for i in bones.size(): + if i >= radii.size(): + continue + var caps: PackedFloat32Array = radii[i].get("cap", PackedFloat32Array()) + if caps.is_empty(): + continue + var a := base + i + var d: Vector3 = _pw[a + 1] - _pw[a] + if d.length() < 0.00001: + continue + var u := d.normalized() + var posed := Transform3D(Basis(Quaternion(_rdir[a], u)) * (_rbasis[a] as Basis), _pw[a]) + var pts := _sample_points(posed, _pw[a], _pw[a] + u * seg[i], hulls[i]) + var worst := 0.0 + for c in _colliders.size(): + var col: Dictionary = _colliders[c] + var ends := _capsule(skel, to_world, col) + var ca: Vector3 = ends[0] + var ab: Vector3 = ends[1] - ca + var d2 := ab.length_squared() + for j in pts.size(): + var p: Vector3 = pts[j] + var t: float = 0.0 if d2 < 0.000001 \ + else clampf((p - ca).dot(ab) / d2, 0.0, 1.0) + var kk := c * pts.size() + j + var allow: float = caps[kk] if kk < caps.size() else 1.0 + var r: float = minf(lerpf(float(col["rh"]), float(col["rt"]), t), allow) + worst = maxf(worst, (r - CONTACT_SLOP) - p.distance_to(ca + ab * t)) + if worst > 0.0: + _resid[bones[i]] = maxf(_resid.get(bones[i], 0.0), worst) + + +## Turn on the residual measurement above. Off by default — it is a second full +## collision sweep and the game does not need it. +var _want_residual: bool = false +var _resid: Dictionary = {} + +func debug_residual_report() -> Dictionary: + _want_residual = true + var d := _resid.duplicate() + _resid.clear() + return d + + +## The chain's animated pose for this frame: every joint where rigidly following +## the body would put it: every joint where rigidly following the animation +## would leave it. +## +## Returns the anchor transform, and fills _ref / _rdir / _rbasis for the chain. +func _build_reference(skel: Skeleton3D, to_world: Transform3D, ci: int) -> Transform3D: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var segdir: PackedVector3Array = chain["segdir"] + var seg: PackedFloat32Array = chain["seg"] + var base: int = chain["base"] + var anchor: Transform3D = to_world * skel.get_bone_global_pose(chain["parent"]) + var pw := anchor + for i in bones.size(): + # Built from the REST offset, not the current pose, or last frame's + # result would compound into a permanent drift. + var rigid: Transform3D = pw * skel.get_bone_rest(bones[i]) + _ref[base + i] = rigid.origin + _rbasis[base + i] = rigid.basis + _rdir[base + i] = (rigid.basis * segdir[i]).normalized() + pw = Transform3D(rigid.basis, rigid.origin + _rdir[base + i] * seg[i]) + _ref[base + bones.size()] = pw.origin + return anchor + + +## Integrate one chain's particles and seed the working positions. +func _predict(skel: Skeleton3D, ci: int, anchor: Transform3D, dt: float) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var base: int = chain["base"] + var n: int = bones.size() + var w: float = chain["w"] + var pull: float = w * w + var damp: float = 2.0 * float(chain["zeta"]) * w + var gravity := Vector3.DOWN * float(chain["gravity"]) + var seg: PackedFloat32Array = chain["seg"] + + # How fast the chain's anchor is travelling through the world — the reference + # the wind is measured against. Low-passed: a raw per-call difference + # alternates violently when the solver is stepped more than once for the same + # animation frame, and the wind force then flips sign every other step. + var carry := (anchor.origin - _anchor_prev[ci]) / dt if _settled else Vector3.ZERO + carry = _carry_prev[ci].lerp(carry.limit_length(MAX_CARRY), CARRY_SMOOTH) + _carry_prev[ci] = carry + _anchor_prev[ci] = anchor.origin + + var inv_anchor := anchor.affine_inverse() + var reset := not _settled + if not reset: + # A teleport — respawn, or the model being reparented. Snap, do not whip. + var got: Vector3 = anchor * _q[base + n] + if got.distance_to(_ref[base + n]) > TELEPORT: + reset = true + + # Cloth streams backwards through the air it is moving into. A STEADY wind + # gives a steady deflection, which is hair that blows back once and then + # hangs there rigid — travelling at a constant speed excites nothing else now + # that the solver works in the anchor's frame — so the gust varies. Two + # incommensurate rates so the pattern does not read as a loop, and a + # per-chain phase so strands do not move in lockstep. + var wind_dir := Vector3.ZERO + var wind_mag := 0.0 + if carry.length() > 0.05: + var ph: float = float(ci) * 1.7 + var gust := minf(carry.length() / WIND_SPEED, 1.0) * float(chain["wind"]) + var back := -carry.normalized() + var side := back.cross(Vector3.UP) + side = side.normalized() if side.length() > 0.001 else Vector3.ZERO + var t := 1.0 + 0.45 * sin(_time * 6.3 + ph) + 0.25 * sin(_time * 10.7 + ph * 1.6) + wind_dir = back * t + side * (0.35 * sin(_time * 8.1 + ph)) + wind_mag = pull * gust + + # Substep so the pull can never overshoot, however stiff it is or however + # long the frame was. A single explicit step is only stable while w*dt stays + # small; past that it gains energy every frame and the chain flies off. + var sub := clampi(int(ceil(dt * w / 0.4)), 1, MAX_SUBSTEPS) + var h := dt / float(sub) + if reset: + _q[base] = inv_anchor * _ref[base] + _v[base] = Vector3.ZERO + # The root joint is not pinned outright — see ROOT_INV_MASS. It keeps a + # fraction of last frame's bodily shift and relaxes back toward the body, so + # a panel that had to ride up over a thigh comes home once the leg has gone + # by instead of stepping back the instant the contact clears. + _pw[base] = _ref[base] \ + + ((anchor * _q[base]) - _ref[base]).limit_length(ROOT_STRAY) * 0.55 + for i in range(1, n + 1): + var k := base + i + if reset: + _q[k] = inv_anchor * _ref[k] + _v[k] = Vector3.ZERO + var p: Vector3 = anchor * _q[k] + var vel: Vector3 = anchor.basis * _v[k] + var length: float = seg[i - 1] + for _s in sub: + var acc := (_ref[k] - p) * pull + gravity + wind_dir * (wind_mag * length) + vel += acc * h + vel *= exp(-damp * h) + vel = vel.limit_length(length * MAX_RATE) + p += vel * h + _pw[k] = p + _v[k] = anchor.basis.inverse() * vel + + +## One Gauss-Seidel sweep over a chain: bone length, cone limit, backstop, +## colliders. Runs top-down so each bone sees where its parent has just gone. +func _solve_chain(skel: Skeleton3D, to_world: Transform3D, ci: int, + shape: bool) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var seg: PackedFloat32Array = chain["seg"] + var hulls: Array = chain["hulls"] + var radii: Array = chain["radii"] + var base: int = chain["base"] + var n: int = bones.size() + var far: float = float(chain["span"]) * float(chain["stray"]) + var hinge: float = chain["hinge"] + var bend: float = chain["bend"] + # How far the bone ABOVE has swung out of the animated pose. Each bone's cone + # limit is measured from its own reference carried through this, so the chain + # curls instead of every joint being pinned to the animation independently. + var carry_swing := Quaternion.IDENTITY + for i in n: + var a := base + i + var b := base + i + 1 + var length: float = seg[i] + + # 1. Bone length. Symmetric, so a correction below propagates upward — + # which is the whole reason the joints are particles. + var d: Vector3 = _pw[b] - _pw[a] + var cur := d.length() + if cur > 0.00001: + var wa: float = _imass[a] + var wb: float = _imass[b] + var sum := wa + wb + if sum > 0.0001: + var fix: Vector3 = d * ((cur - length) / cur / sum) + _pw[a] += fix * wa + _pw[b] -= fix * wb + else: + _pw[b] = _pw[a] + _rdir[a] * length + + # 2. Cone limit, measured from where this bone would point if it FOLLOWED + # ITS PARENT — not from where the animation alone put it. + # + # This is the difference between a chain that can curl and one that + # cannot. Measuring every joint against the animated pose pins the + # whole strand within one cone of it: the root may swing its 42 + # degrees, and then its child, which has physically been carried along + # by that swing, is told it is already 42 degrees out of place and + # must come back. Nothing compounds, so a four-bone panel is no more + # mobile than a one-bone one, and a skirt that has to lie along a + # thigh in a slide simply cannot get there — measured, the solver + # reported the contact every frame and left 99 mm of leg inside the + # hem. + # + # Carrying the parent's actual swing onto the child's reference makes + # the limit mean what it should: how far this bone may bend RELATIVE + # TO THE ONE ABOVE IT, which is bending stiffness, which is a property + # of cloth. Total travel stays bounded by the backstop below. + var ref_dir: Vector3 = (carry_swing * _rdir[a]).normalized() + if shape: + var dir: Vector3 = _pw[b] - _pw[a] + if dir.length() > 0.00001: + dir = dir.normalized() + var along := 0.0 if n < 2 else float(i) / float(n - 1) + var limit: float = lerpf(hinge, bend, minf(along * 2.0, 1.0)) + var ang := ref_dir.angle_to(dir) + if ang > limit: + dir = ref_dir.slerp(dir, limit / ang).normalized() + _pw[b] = _pw[a] + dir * length + + # 3. Backstop: however the cone limits compound, the joint stays + # within a fraction of the whole garment of where the animation + # put it. See MAX_STRAY. + var stray: Vector3 = _pw[b] - _ref[b] + if stray.length() > far: + _pw[b] = _ref[b] + stray.normalized() * far + + # 4. Colliders, against the REAL GEOMETRY this bone drives. + if _collide and i < radii.size(): + _collide_bone(skel, to_world, bones[i], a, b, length, + hulls[i], radii[i]) + + # The root's own leash back to the body, re-applied as soon as the bone + # hanging off it has had its say — the bones below solve against wherever + # the root ended up, so letting it drift and only clamping at the end + # would have them chasing a position the garment is not allowed to hold. + if i == 0: + var off0: Vector3 = _pw[base] - _ref[base] + if off0.length() > ROOT_STRAY: + _pw[base] = _ref[base] + off0.normalized() * ROOT_STRAY + + # What this bone finally settled on, for its child's cone limit. + var got: Vector3 = _pw[b] - _pw[a] + carry_swing = Quaternion(_rdir[a], got.normalized()) \ + if got.length() > 0.00001 else Quaternion.IDENTITY + + +## Push one bone's mesh hull out of every collider by moving its two joints. +## +## The bone stick is not what clips — a skirt panel is a wide sheet, and measured +## over a movement sweep the bones sat ~1 mm clear of the legs while the thigh +## was 85 mm inside the skirt MESH. So the test is against the hull samples from +## the sidecar, which are the actual vertices this bone drives. +## +## The correction is shared between the bone's head and its tip by where the +## contact sits along the bone, which makes it a rigid motion of the panel rather +## than a hinge. A contact at the head end therefore lifts the WHOLE panel — the +## case no amount of rotation could ever reach, because rotating about a point +## moves that point not at all. +func _collide_bone(skel: Skeleton3D, to_world: Transform3D, bone: int, + a: int, b: int, length: float, hull: PackedVector3Array, + rec: Dictionary) -> void: + var caps: PackedFloat32Array = rec.get("cap", PackedFloat32Array()) + if caps.is_empty() or _colliders.is_empty(): + return + var dir: Vector3 = _pw[b] - _pw[a] + if dir.length() < 0.00001: + return + var wa: float = _imass[a] + var wb: float = _imass[b] + if wa + wb <= 0.0001: + return + var span: float = maxf(float(rec.get("span", length)), length) + # Per-BONE broad phase, before the hull is posed at all. Most cloth bones are + # nowhere near a limb on most frames — Taila's hair is 48 of the 127 — and + # posing a couple of dozen hull points for them, nine passes a frame, was + # pure waste. + var near := false + for c in _colliders.size(): + if _seg_point_distance(_pw[a], _cap_a[c], _cap_b[c]) <= span + maxf(_cap_rh[c], _cap_rt[c]): + near = true + break + if not near: + return + var n := _fill_points(a, b, length, hull) + if n == 0: + return + + for c in _colliders.size(): + var ca: Vector3 = _cap_a[c] + var cb: Vector3 = _cap_b[c] + var ab := cb - ca + var d2 := ab.length_squared() + var rh: float = _cap_rh[c] + var rt: float = _cap_rt[c] + # Broad phase. Nothing this bone drives can reach further than `span` + # from its own head, so a limb further away than that plus its own radius + # cannot be touching it. Skips most of the (bone, collider) pairs. + if _seg_point_distance(_pw[a], ca, cb) > span + maxf(rh, rt): + continue + # ONE correction per limb, for the DEEPEST point on the bone. + # + # Not one per point. The hull is a couple of dozen samples spread over a + # panel, and a thigh inside it violates most of them at once — applying + # every violation in turn adds up to many times the one overlap that + # actually exists and flings the panel off the character. The deepest + # point is the one that has to clear; the rest are the same contact seen + # from nearby, and the next iteration re-measures whatever is left. + var deepest := 0.0 + var push := Vector3.ZERO + var at := Vector3.ZERO + for j in n: + var p: Vector3 = _pts[j] + var t: float = 0.0 if d2 < 0.000001 else clampf((p - ca).dot(ab) / d2, 0.0, 1.0) + var kk := c * n + j + var allow: float = caps[kk] if kk < caps.size() else 1.0 + var r: float = minf(lerpf(rh, rt, t), allow) + var away := p - (ca + ab * t) + var dist := away.length() + if dist < 0.0001: + continue + var pen := (r - CONTACT_SLOP) - dist + if pen > deepest: + deepest = pen + push = (away / dist) * pen + at = p + if deepest <= 0.0: + continue + _hits += 1 + _hit_depth[bone] = maxf(_hit_depth.get(bone, 0.0), deepest) + _left[bone] = maxf(_left.get(bone, 0.0), deepest) + _applied[bone] = _applied.get(bone, 0.0) + deepest + # Where along the bone that point hangs decides which end carries the + # correction. A contact at the HEAD end therefore lifts the whole panel + # — the case no rotation can ever reach, because rotating about a point + # moves that point not at all. + var u := (_pw[b] - _pw[a]).normalized() + var s: float = clampf((at - _pw[a]).dot(u) / maxf(length, 0.0001), 0.0, 1.0) + var share_a := (1.0 - s) * COLLIDE_HEAD_SHARE + var share_b := s + (1.0 - s) * (1.0 - COLLIDE_HEAD_SHARE) + # Normalise so THE CONTACT POINT moves by the full overlap — not so the + # two endpoint moves add up to it. + # + # The point rides at fraction `s` along the bone, so it travels + # (1-s)*head + s*tip. Splitting the push into two shares that sum to it + # therefore delivers only about half of it to the place that is actually + # inside the leg, and a contact in the middle of a bone converged at + # roughly half the rate it should: measured, quadrupling the iteration + # count moved a 92 mm overlap to 76 mm and no further. + var eff: float = (1.0 - s) * share_a * wa + s * share_b * wb + if eff <= 0.0001: + continue + _pw[a] += push * (share_a * wa / eff) + _pw[b] += push * (share_b * wb / eff) + # Re-read, so the next limb sees where the bone has just gone. + if _fill_points(a, b, length, hull) == 0: + return + + +## Pose one bone's collision hull into the scratch buffer. Returns how many +## points were written. Kept out of the collider loop and off the heap: this runs +## for every cloth bone on every relaxation pass, and building a fresh Array of +## Vector3 each time was a large share of the solver's whole cost. +func _fill_points(a: int, b: int, length: float, + hull: PackedVector3Array) -> int: + var dir: Vector3 = _pw[b] - _pw[a] + if dir.length() < 0.00001: + return 0 + var u := dir.normalized() + # The bone's basis under the correction it has taken so far: the animated + # basis, swung onto the direction the particles settled on. + var basis: Basis = Basis(Quaternion(_rdir[a], u)) * (_rbasis[a] as Basis) + var head: Vector3 = _pw[a] + if hull.size() > 0: + if _pts.size() < hull.size(): + _pts.resize(hull.size()) + for i in hull.size(): + _pts[i] = head + basis * hull[i] + return hull.size() + if _pts.size() < SAMPLES.size(): + _pts.resize(SAMPLES.size()) + for i in SAMPLES.size(): + _pts[i] = head + u * (length * float(SAMPLES[i])) + return SAMPLES.size() + + +## Turn the settled particles into bone rotations, and store back the joint +## positions the skeleton ACTUALLY got. +## +## The feedback matters as much as the write. A joint's head is fixed by its +## parent's rotation, so the pose the skeleton ends up in is never quite the pose +## the constraints asked for. Integrating from what the solver WANTED rather than +## from what was rendered leaves a standing gap between the two, and that gap is +## a force nothing in the model agreed to — it is what made the old multi-pass +## version oscillate, because its final ancestor-lift pass wrote poses that were +## never fed back into the spring state at all. +func _write_poses(skel: Skeleton3D, anchor: Transform3D, ci: int) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var segdir: PackedVector3Array = chain["segdir"] + var seg: PackedFloat32Array = chain["seg"] + var base: int = chain["base"] + var n: int = bones.size() + var inv_anchor := anchor.affine_inverse() + # Whatever bodily shift the colliders asked of the chain root. Every segment + # below it inherits this through the hierarchy, so the panel travels as one + # piece and the mesh it drives cannot be stretched by it — which is the one + # way translating cloth is safe where a weight gradient is not. + var shift: Vector3 = (_pw[base] - _ref[base]).limit_length(ROOT_STRAY) + + var pw := anchor + for i in n: + var bone: int = bones[i] + var rigid: Transform3D = pw * skel.get_bone_rest(bone) + if i == 0: + rigid.origin += shift + # This joint's head is now final; record where it really landed. + _q[base + i] = inv_anchor * rigid.origin + # The bone's own rest line, and where the particles want it to point. + var rest_dir: Vector3 = (rigid.basis * segdir[i]).normalized() + var want: Vector3 = _pw[base + i + 1] - rigid.origin + var world := rigid + if want.length() > 0.00001 and rest_dir.length_squared() > 0.5: + world.basis = Basis(Quaternion(rest_dir, want.normalized())) * rigid.basis + var local := pw.affine_inverse() * world + skel.set_bone_pose_rotation(bone, local.basis.get_rotation_quaternion()) + if i == 0: + skel.set_bone_pose_position(bone, local.origin) + pw = world + # The free tip, from the last bone's achieved orientation. + var tip_dir: Vector3 = (pw.basis * segdir[n - 1]).normalized() + _q[base + n] = inv_anchor * (pw.origin + tip_dir * seg[n - 1]) diff --git a/characters/spring_bones.gd.uid b/characters/spring_bones.gd.uid new file mode 100644 index 0000000..ffab37e --- /dev/null +++ b/characters/spring_bones.gd.uid @@ -0,0 +1 @@ +uid://deskxxpcexwyt diff --git a/characters/tuning_store.gd b/characters/tuning_store.gd new file mode 100644 index 0000000..dc376ed --- /dev/null +++ b/characters/tuning_store.gd @@ -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.._all this character, every subject +## skins.. 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 "" + + +## 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 diff --git a/characters/tuning_store.gd.uid b/characters/tuning_store.gd.uid new file mode 100644 index 0000000..286e78c --- /dev/null +++ b/characters/tuning_store.gd.uid @@ -0,0 +1 @@ +uid://cq378n7o0qnoc diff --git a/characters/weapon_hold_tuning.gd b/characters/weapon_hold_tuning.gd new file mode 100644 index 0000000..25fb7f8 --- /dev/null +++ b/characters/weapon_hold_tuning.gd @@ -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.._all this character, every weapon +## skins.. 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 `_`, 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) diff --git a/characters/weapon_hold_tuning.gd.uid b/characters/weapon_hold_tuning.gd.uid new file mode 100644 index 0000000..0bf586e --- /dev/null +++ b/characters/weapon_hold_tuning.gd.uid @@ -0,0 +1 @@ +uid://cxbghp14y3i7v diff --git a/debug/anchor_drag_check.gd b/debug/anchor_drag_check.gd new file mode 100644 index 0000000..d142882 --- /dev/null +++ b/debug/anchor_drag_check.gd @@ -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) diff --git a/debug/anchor_drag_check.gd.uid b/debug/anchor_drag_check.gd.uid new file mode 100644 index 0000000..24fc878 --- /dev/null +++ b/debug/anchor_drag_check.gd.uid @@ -0,0 +1 @@ +uid://bw4ojr7c451pq diff --git a/debug/anchor_shift_check.gd b/debug/anchor_shift_check.gd new file mode 100644 index 0000000..514cb78 --- /dev/null +++ b/debug/anchor_shift_check.gd @@ -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 diff --git a/debug/anchor_shift_check.gd.uid b/debug/anchor_shift_check.gd.uid new file mode 100644 index 0000000..82e21d2 --- /dev/null +++ b/debug/anchor_shift_check.gd.uid @@ -0,0 +1 @@ +uid://d3m2g70t1tkho diff --git a/debug/character_look_capture.gd b/debug/character_look_capture.gd new file mode 100644 index 0000000..797e5d1 --- /dev/null +++ b/debug/character_look_capture.gd @@ -0,0 +1,90 @@ +extends SceneTree + +## Dev tool: a skin's RAW import beside the same model with the game's character +## treatment, under identical lighting — the reference shot for tuning +## LevelMaterials.apply_character_look() against the model's source render. +## godot --path . --windowed --resolution 1280x720 \ +## -s res://debug/character_look_capture.gd -- [res-path-to-glb] +## Saves look_head.png and look_body.png (left = raw import, right = ours). + +const DEFAULT_GLB := "res://assets/characters/skins/taila.glb" + +var _frames := 0 +var _out_dir := "." +var _glb_path := DEFAULT_GLB +var _cam: Camera3D = null + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + _out_dir = args[0] + if args.size() > 1: + _glb_path = args[1] + + var scene := Node3D.new() + root.add_child(scene) + current_scene = scene + + # Neutral studio light, matched to the game's tonemap so colours compare. + var env := WorldEnvironment.new() + var e := Environment.new() + e.background_mode = Environment.BG_COLOR + e.background_color = Color(0.30, 0.32, 0.38) + e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + e.ambient_light_color = Color(0.85, 0.87, 0.95) + e.ambient_light_energy = 1.1 + e.tonemap_mode = Environment.TONE_MAPPER_FILMIC + e.tonemap_white = 2.4 + env.environment = e + scene.add_child(env) + + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-38, 28, 0) + sun.light_energy = 2.0 + scene.add_child(sun) + + var raw := GLBLoader.load(_glb_path) + if not raw: + printerr("character_look_capture: could not load ", _glb_path) + quit(1) + return + raw.position = Vector3(-0.45, 0, 0) + scene.add_child(raw) + + var ours := GLBLoader.load(_glb_path) + ours.position = Vector3(0.45, 0, 0) + scene.add_child(ours) + LevelMaterials.apply_toon_recursive(ours) + LevelMaterials.apply_character_look(ours) + + _cam = Camera3D.new() + scene.add_child(_cam) + + +func _process(_delta: float) -> bool: + _frames += 1 + if _frames < 30: + return false + if _frames == 30: + _look(Vector3(0, 1.42, 1.05), Vector3(0, 1.38, 0)) + return false + if _frames == 32: + _snap("look_head") + _look(Vector3(0, 1.15, 2.2), Vector3(0, 0.95, 0)) + return false + if _frames == 34: + _snap("look_body") + return true + return false + + +func _look(pos: Vector3, at: Vector3) -> void: + _cam.global_position = pos + _cam.look_at(at, Vector3.UP) + + +func _snap(tag: String) -> void: + var img := root.get_viewport().get_texture().get_image() + img.save_png(_out_dir + "/" + tag + ".png") + print("character_look_capture: saved ", tag) diff --git a/debug/character_look_capture.gd.uid b/debug/character_look_capture.gd.uid new file mode 100644 index 0000000..ac13012 --- /dev/null +++ b/debug/character_look_capture.gd.uid @@ -0,0 +1 @@ +uid://dacludfhqj3fb diff --git a/debug/character_picker_check.gd b/debug/character_picker_check.gd new file mode 100644 index 0000000..8f42c73 --- /dev/null +++ b/debug/character_picker_check.gd @@ -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) diff --git a/debug/character_picker_check.gd.uid b/debug/character_picker_check.gd.uid new file mode 100644 index 0000000..e81e5af --- /dev/null +++ b/debug/character_picker_check.gd.uid @@ -0,0 +1 @@ +uid://br28g1yiljtpp diff --git a/debug/cloth_allow_check.gd b/debug/cloth_allow_check.gd new file mode 100644 index 0000000..7b6e1df --- /dev/null +++ b/debug/cloth_allow_check.gd @@ -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 diff --git a/debug/cloth_allow_check.gd.uid b/debug/cloth_allow_check.gd.uid new file mode 100644 index 0000000..f35d12d --- /dev/null +++ b/debug/cloth_allow_check.gd.uid @@ -0,0 +1 @@ +uid://dck4ag2tssaea diff --git a/debug/cloth_clip_check.gd b/debug/cloth_clip_check.gd new file mode 100644 index 0000000..0f898c5 --- /dev/null +++ b/debug/cloth_clip_check.gd @@ -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 .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("") diff --git a/debug/cloth_clip_check.gd.uid b/debug/cloth_clip_check.gd.uid new file mode 100644 index 0000000..6ca0353 --- /dev/null +++ b/debug/cloth_clip_check.gd.uid @@ -0,0 +1 @@ +uid://c4x5gy6vjcvb0 diff --git a/debug/cloth_perf_check.gd b/debug/cloth_perf_check.gd new file mode 100644 index 0000000..3383d15 --- /dev/null +++ b/debug/cloth_perf_check.gd @@ -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 diff --git a/debug/cloth_perf_check.gd.uid b/debug/cloth_perf_check.gd.uid new file mode 100644 index 0000000..36531b8 --- /dev/null +++ b/debug/cloth_perf_check.gd.uid @@ -0,0 +1 @@ +uid://d3td7sln77h1i diff --git a/debug/cloth_settle_check.gd b/debug/cloth_settle_check.gd new file mode 100644 index 0000000..b24f253 --- /dev/null +++ b/debug/cloth_settle_check.gd @@ -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 diff --git a/debug/cloth_settle_check.gd.uid b/debug/cloth_settle_check.gd.uid new file mode 100644 index 0000000..1c4c507 --- /dev/null +++ b/debug/cloth_settle_check.gd.uid @@ -0,0 +1 @@ +uid://bk7pst2vhawvt diff --git a/debug/cloth_stretch_check.gd b/debug/cloth_stretch_check.gd new file mode 100644 index 0000000..f143b36 --- /dev/null +++ b/debug/cloth_stretch_check.gd @@ -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) diff --git a/debug/cloth_stretch_check.gd.uid b/debug/cloth_stretch_check.gd.uid new file mode 100644 index 0000000..98fc7a6 --- /dev/null +++ b/debug/cloth_stretch_check.gd.uid @@ -0,0 +1 @@ +uid://ccx4eh7wgfwrh diff --git a/debug/dump_surfaces.gd b/debug/dump_surfaces.gd new file mode 100644 index 0000000..8e5b833 --- /dev/null +++ b/debug/dump_surfaces.gd @@ -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 := "" 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 "" + 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 "" + 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) diff --git a/debug/dump_surfaces.gd.uid b/debug/dump_surfaces.gd.uid new file mode 100644 index 0000000..021d648 --- /dev/null +++ b/debug/dump_surfaces.gd.uid @@ -0,0 +1 @@ +uid://ch7c3vv6t2gei diff --git a/debug/hold_pose_check.gd b/debug/hold_pose_check.gd new file mode 100644 index 0000000..e8a1ca7 --- /dev/null +++ b/debug/hold_pose_check.gd @@ -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) diff --git a/debug/hold_pose_check.gd.uid b/debug/hold_pose_check.gd.uid new file mode 100644 index 0000000..d48de7d --- /dev/null +++ b/debug/hold_pose_check.gd.uid @@ -0,0 +1 @@ +uid://dydhylqo46x1n diff --git a/debug/idle_jitter_check.gd b/debug/idle_jitter_check.gd new file mode 100644 index 0000000..c10dd36 --- /dev/null +++ b/debug/idle_jitter_check.gd @@ -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 -- +## +## 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 diff --git a/debug/idle_jitter_check.gd.uid b/debug/idle_jitter_check.gd.uid new file mode 100644 index 0000000..e44fd36 --- /dev/null +++ b/debug/idle_jitter_check.gd.uid @@ -0,0 +1 @@ +uid://6qrwda6ux54f diff --git a/debug/leg_radius_check.gd b/debug/leg_radius_check.gd new file mode 100644 index 0000000..1cb25dd --- /dev/null +++ b/debug/leg_radius_check.gd @@ -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 diff --git a/debug/leg_radius_check.gd.uid b/debug/leg_radius_check.gd.uid new file mode 100644 index 0000000..9d8530c --- /dev/null +++ b/debug/leg_radius_check.gd.uid @@ -0,0 +1 @@ +uid://co25textiw4vq diff --git a/debug/limb_deform_check.gd b/debug/limb_deform_check.gd new file mode 100644 index 0000000..61d1baa --- /dev/null +++ b/debug/limb_deform_check.gd @@ -0,0 +1,250 @@ +extends SceneTree + +## Dev tool: measure whether a skin's LEGS actually squash or stretch, by +## skinning the vertices ourselves and comparing against the rest pose. +## +## godot --headless --path . -s res://debug/limb_deform_check.gd -- [skin_glb] +## +## Reports two numbers per surface, taken as the worst over a sweep of run, +## walk, jump, fall, slide and dash: +## +## LENGTHWISE the greatest distance between any two leg vertices, over the +## same at rest. Bending a knee can only SHRINK this, so anything +## above ~1.05 is real stretching. +## CROSS-SECTION the average spread of a ring of vertices about its own +## centre, over the same at rest. This is the "candy wrapper" +## collapse that linear-blend skinning causes at a bent joint; +## 0.85-1.00 is normal, well below that is a weighting fault. +## +## Written after several rounds of chasing reported leg "squashing". Renders +## were repeatedly misleading — a slim anime leg at full stride genuinely looks +## stretched — so measure before changing anything. +## +## READ THIS BEFORE TRUSTING ANY NUMBER YOU ADD HERE. Two earlier versions of +## this measurement were themselves wrong and nearly caused a bad "fix": +## +## * Comparing skinned lengths against BIND-POSE lengths. The character is +## never in bind pose, so ordinary posing showed up as 0.4x-2.5x +## "deformation" even with the animation frozen. +## * Flagging vertices weighted across "non-adjacent" leg bones by testing +## max_slot - min_slot >= 2. That flags shin+foot+toe, which is a perfectly +## normal contiguous run. +## +## So the baseline here is the skeleton's REST transforms, which makes every +## ratio exactly 1.00 when the pose is the rest pose — the tool validates +## itself. If you add a metric, prove it reads 1.00 on an unposed model first. + +var _frames := 0 +var _model: SkinnedPlayerModel = null +var _rest_radius := {} +var _min_ratio := {} +var _rest_span := {} +var _max_span := {} + +## state, speed +const SWEEP := [["ground", 9.0], ["ground", 3.0], ["air", 6.0], + ["air", -8.0], ["slide", 10.0], ["dash", 14.0]] +const FRAMES_PER_STATE := 40 + + +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 < 10: + return false + if _frames == 10: + _measure(true) # baseline from the skeleton's REST transforms + return false + var phase: int = clampi((_frames - 20) / 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) + if _frames > 20: + _measure(false) + if _frames > 20 + FRAMES_PER_STATE * SWEEP.size(): + _report() + return true + return false + + +func _report() -> void: + print("\n=== worst LENGTHWISE stretch vs rest (>1.05 = real stretching) ===") + var lk := _max_span.keys() + lk.sort() + for k in lk: + print(" %-14s %.2f (rest %.3f m, worst %.3f m)" % [ + k, _max_span[k] / _rest_span[k], _rest_span[k], _max_span[k]]) + print("\n=== worst CROSS-SECTION vs rest (1.00 = no loss) ===") + var keys := _min_ratio.keys() + keys.sort() + for k in keys: + print(" %-34s %.2f" % [k, _min_ratio[k]]) + + +func _measure(store_rest: bool) -> void: + var skel: Skeleton3D = _model.skeleton + if not skel: + return + skel.force_update_all_bone_transforms() + 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 mat = mi.mesh.surface_get_material(s) + # Keyed by MESH as well as material. A model that keeps its per-part + # meshes reuses one material across several of them — Taila has + # "FullBlack" on three — and keying by material alone compared one + # mesh's rest against another mesh's posed span, which reported a + # 4.9x stretch on a model that was fine. + var mname: String = "%s/%s" % [mi.name, mat.resource_name if mat else "?"] + 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() + _span(skel, skin, bone_of, mname, verts, bones, weights, per, store_rest) + _rings(skel, skin, bone_of, mname, verts, bones, weights, per, store_rest) + + +## `rest` skins against the skeleton's rest transforms instead of its current +## pose, which is what makes the baseline exact: ratios come out 1.00 on an +## unposed model, so a wrong reading is visible immediately. +func _skinned(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, p: Vector3, + bones: PackedInt32Array, weights: PackedFloat32Array, + base: int, per: int, rest: bool = false) -> Vector3: + var q := Vector3.ZERO + for k in per: + var w: float = weights[base + k] + if w <= 0.0: + continue + var bind: int = bones[base + k] + var bi: int = bone_of[bind] + if bi < 0: + continue + var m: Transform3D = skel.get_bone_global_rest(bi) if rest else skel.get_bone_global_pose(bi) + q += (m * skin.get_bind_pose(bind) * p) * w + return q + + +## Is this vertex mostly owned by a LEFT leg bone? +func _left_leg_driven(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, + bones: PackedInt32Array, weights: PackedFloat32Array, + base: int, per: int) -> bool: + var best := 0.0 + var best_name := "" + for k in per: + var w: float = weights[base + k] + if w <= best: + continue + var bind: int = bones[base + k] + var n: String = skin.get_bind_name(bind) + if n == "": + var bi: int = bone_of[bind] + n = skel.get_bone_name(bi) if bi >= 0 else "" + best = w + best_name = n + if not (best_name.ends_with(".L") or best_name.find(".L.") != -1): + return false + for hint in ["shin", "thigh", "foot", "toe"]: + if best_name.findn(hint) != -1: + return true + return false + + +func _span(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String, + verts: PackedVector3Array, bones: PackedInt32Array, + weights: PackedFloat32Array, per: int, store_rest: bool) -> void: + var th := skel.find_bone("DEF-thigh.L") + var fo := skel.find_bone("DEF-foot.L") + if th < 0 or fo < 0: + return + var ha: Vector3 = skel.get_bone_global_rest(th).origin + var hf: Vector3 = skel.get_bone_global_rest(fo).origin - ha + var lo := Vector3(INF, INF, INF) + var hi := Vector3(-INF, -INF, -INF) + var n := 0 + for v in verts.size(): + var p: Vector3 = verts[v] + var t: float = clampf((p - ha).dot(hf) / hf.length_squared(), 0.0, 1.0) + if p.distance_to(ha + hf * t) > 0.10: + continue + # Must be DRIVEN by the left leg, not merely near it. Taila's boots are + # a single mesh holding both feet, and at rest the right boot sits + # within 0.10 m of the left leg axis — so a purely positional filter + # collected both, and the span between them read as a 4.9x "stretch" + # the moment the legs separated. + if not _left_leg_driven(skel, skin, bone_of, bones, weights, v * per, per): + continue + var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per, store_rest) + lo = Vector3(minf(lo.x, q.x), minf(lo.y, q.y), minf(lo.z, q.z)) + hi = Vector3(maxf(hi.x, q.x), maxf(hi.y, q.y), maxf(hi.z, q.z)) + n += 1 + if n < 4: + return + var span: float = (hi - lo).length() + if store_rest: + _rest_span[mname] = span + elif _rest_span.has(mname) and (not _max_span.has(mname) or span > _max_span[mname]): + _max_span[mname] = span + + +func _rings(skel: Skeleton3D, skin: Skin, bone_of: Dictionary, mname: String, + verts: PackedVector3Array, bones: PackedInt32Array, + weights: PackedFloat32Array, per: int, store_rest: bool) -> void: + for seg in [ + ["DEF-thigh.L", "DEF-shin.L", 0.82, 1.0, "knee (thigh side)"], + ["DEF-shin.L", "DEF-foot.L", 0.0, 0.18, "knee (shin side)"], + ["DEF-thigh.L", "DEF-shin.L", 0.0, 0.18, "hip"], + ["DEF-shin.L", "DEF-foot.L", 0.82, 1.0, "ankle"], + ]: + var a_i := skel.find_bone(seg[0]) + var b_i := skel.find_bone(seg[1]) + if a_i < 0 or b_i < 0: + continue + var a: Vector3 = skel.get_bone_global_rest(a_i).origin + var ab: Vector3 = skel.get_bone_global_rest(b_i).origin - a + var sp: Array = [] + var c := Vector3.ZERO + for v in verts.size(): + var p: Vector3 = verts[v] + var t: float = clampf((p - a).dot(ab) / ab.length_squared(), 0.0, 1.0) + if t < float(seg[2]) or t > float(seg[3]): + continue + if p.distance_to(a + ab * t) > 0.09: + continue + var q := _skinned(skel, skin, bone_of, p, bones, weights, v * per, per, store_rest) + sp.append(q) + c += q + if sp.size() < 3: + continue + c /= sp.size() + var r := 0.0 + for q in sp: + r += (q - c).length() + r /= sp.size() + var key: String = "%-12s %s" % [mname, seg[4]] + if store_rest: + _rest_radius[key] = r + elif _rest_radius.has(key) and _rest_radius[key] > 0.0001: + var ratio: float = r / _rest_radius[key] + if not _min_ratio.has(key) or ratio < _min_ratio[key]: + _min_ratio[key] = ratio diff --git a/debug/limb_deform_check.gd.uid b/debug/limb_deform_check.gd.uid new file mode 100644 index 0000000..9267e3f --- /dev/null +++ b/debug/limb_deform_check.gd.uid @@ -0,0 +1 @@ +uid://d3pq0rn2bc0e6 diff --git a/debug/rest_pose_check.gd b/debug/rest_pose_check.gd new file mode 100644 index 0000000..edf1478 --- /dev/null +++ b/debug/rest_pose_check.gd @@ -0,0 +1,109 @@ +extends SceneTree + +## Which way does each character's REST pose point their limbs? +## +## The retarget bakes every clip as a rest-relative delta: it works out what the +## clip does to the LIBRARY's rest pose and applies that rotation to THIS rig's +## rest pose. That is what stops a foreign bone roll twisting a limb, and it is +## right — but it carries an assumption nobody has checked, which is that the two +## rest poses are broadly alike. If a character's arms rest overhead and the +## library's rest at its sides, then "what the clip does relative to the sides" +## applied to overhead leaves the arms overhead, in every clip, forever. +## +## So: measure it. For each character, the world-space direction each limb points +## in its BIND pose, and the angle between that and the library's. +## +## godot --headless --path . -s res://debug/rest_pose_check.gd + +## limb role -> the role of the bone it points AT. Not "its first child": a +## thigh's first child is as likely to be a skirt bone as a shin, and taking it +## reported kiyoko's and aria's legs as 71° off the library when their legs are +## fine — it was measuring the hang of a skirt panel. +const LIMBS := { + "upper_arm.L": "forearm.L", "upper_arm.R": "forearm.R", + "forearm.L": "hand.L", "forearm.R": "hand.R", + "thigh.L": "shin.L", "thigh.R": "shin.R", + "shin.L": "foot.L", "shin.R": "foot.R", +} +## The reference is the mannequin: it comes from the animation library itself, so +## its rest pose IS the pose every clip was authored against. +const REFERENCE := "mannequin" + + +func _init() -> void: + await process_frame + var data = JSON.parse_string(FileAccess.get_file_as_string( + "res://assets/characters/skins/skins.json")) + + var dirs := {} + for entry in data["skins"]: + var d := _limb_directions(entry.get("model", "")) + if not d.is_empty(): + dirs[entry["id"]] = d + + var ref: Dictionary = dirs.get(REFERENCE, {}) + print("Rest-pose limb directions, and the angle from the animation library's own rig\n") + for id in dirs: + var parts: PackedStringArray = [] + var worst := 0.0 + for limb in LIMBS: + if not dirs[id].has(limb) or not ref.has(limb): + continue + var v: Vector3 = dirs[id][limb] + var deg := rad_to_deg(v.angle_to(ref[limb])) + worst = maxf(worst, deg) + parts.append("%s %3.0f°" % [limb, deg]) + parts.sort() + print("%-10s worst %5.1f° %s" % [id, worst, " ".join(parts)]) + print("\nA limb tens of degrees from the reference will play every clip with") + print("that offset baked in, because the clip only ever supplied the DELTA.") + quit() + + +## limb role -> the unit vector the bone points along, in bind pose, in the +## model's own space. +## +## Read from the sidecar's roles, never by bone name — a rig whose bones are +## called `Unused_Noname_010` is exactly the case this has to survive. +func _limb_directions(path: String) -> Dictionary: + if path == "" or not ResourceLoader.exists(path): + return {} + var rig = JSON.parse_string(FileAccess.get_file_as_string( + path.get_basename() + ".rig.json")) + if typeof(rig) != TYPE_DICTIONARY: + return {} + var scene: Node = load(path).instantiate() + var skel: Skeleton3D = _first_skeleton(scene) + var out := {} + if skel: + for limb in LIMBS: + var roles: Dictionary = rig.get("roles", {}) + var from_name: String = roles.get(limb, "") + var to_name: String = roles.get(LIMBS[limb], "") + if from_name == "" or to_name == "": + continue + var bi := skel.find_bone(from_name) + var ti := skel.find_bone(to_name) + if bi < 0 or ti < 0: + continue + # Where the limb points: at the NEXT limb along, by role. A bone's + # own axes say nothing on their own — every rig rolls them + # differently, which is the whole reason the retarget works in + # deltas — so the direction has to come from the geometry. + var here := skel.get_bone_global_rest(bi).origin + var there := skel.get_bone_global_rest(ti).origin + var v := there - here + if v.length() > 1e-5: + out[limb] = v.normalized() + scene.free() + return out + + +func _first_skeleton(node: Node) -> Skeleton3D: + if node is Skeleton3D: + return node + for c in node.get_children(): + var f := _first_skeleton(c) + if f: + return f + return null diff --git a/debug/rest_pose_check.gd.uid b/debug/rest_pose_check.gd.uid new file mode 100644 index 0000000..e424c6c --- /dev/null +++ b/debug/rest_pose_check.gd.uid @@ -0,0 +1 @@ +uid://cmh6vowe1f6d5 diff --git a/debug/rig_anchor_check.gd b/debug/rig_anchor_check.gd new file mode 100644 index 0000000..4f4551a --- /dev/null +++ b/debug/rig_anchor_check.gd @@ -0,0 +1,115 @@ +extends SceneTree + +## Does a rig anchor actually move anything? +## +## The anchor system is easy to build so that it looks right and does nothing: +## the lab shows sliders, the file saves, the JSON round-trips, and the gun does +## not move a millimetre — because the value was read into a variable nobody +## consumed, or because the weapon was measured before the offset was applied +## and the hands are still being solved onto the old grip. +## +## So this asserts the physical consequence. Set an anchor, and the weapon must +## move by that much, in the hand bone's own axes, on every character. +## +## godot --headless --path . -s res://debug/rig_anchor_check.gd + +const OFFSET := Vector3(0.03, -0.02, 0.05) +## Millimetres of slack. The weapon is parented to a BoneAttachment3D that +## follows an animating skeleton, so the two samples are a frame apart on a +## moving arm; this has to be loose enough to survive that and tight enough that +## "did not move at all" fails. +const TOLERANCE := 0.004 + +var _fails := 0 + + +func _init() -> void: + # Autoloads are not in the tree yet when a `-s` script's _init runs. + await process_frame + await process_frame + var data = JSON.parse_string(FileAccess.get_file_as_string( + "res://assets/characters/skins/skins.json")) + var weapon := _first_weapon() + if weapon == "": + print("no weapon script in the loadout database") + quit(1) + return + for entry in data["skins"]: + await _check_skin(entry["id"], entry.get("model", ""), weapon) + print("\n=== RIG ANCHORS ===\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 script: String = db[id].get("script", "") + if script != "" and ResourceLoader.exists(script): + return script + return "" + + +func _check_skin(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 4: + await process_frame + + var w := _weapon_node(model) + if w == null: + print(" FAIL: '%s' did not mount a weapon at all" % id) + _fails += 1 + model.queue_free() + return + + # Measured in the ATTACHMENT's frame, not the world's. The attachment tracks + # a bone on an animating skeleton, so a world-space delta would be the sum of + # the anchor and a frame of the idle animation — and the idle is bigger. + var before: Vector3 = w.position + model.set_anchors({"grip_offset": OFFSET}) + await process_frame + w = _weapon_node(model) + var moved: Vector3 = w.position - before + + var err := (moved - OFFSET).length() + if err <= TOLERANCE: + print(" OK: '%s' grip anchor moved the weapon by %.1f, %.1f, %.1f mm" + % [id, moved.x * 1000.0, moved.y * 1000.0, moved.z * 1000.0]) + else: + print(" FAIL: '%s' asked for (%.3f, %.3f, %.3f), got (%.3f, %.3f, %.3f)" + % [id, OFFSET.x, OFFSET.y, OFFSET.z, moved.x, moved.y, moved.z]) + _fails += 1 + + # ...and back to nothing tuned, which must restore the untouched mount + # exactly. An anchor that cannot be cleared is a one-way door in a tool whose + # whole purpose is trying things out. + model.set_anchors({}) + await process_frame + w = _weapon_node(model) + if w.position.distance_to(before) <= TOLERANCE: + print(" OK: '%s' clearing the anchor restores the derived mount" % id) + else: + print(" FAIL: '%s' did not return to the derived mount" % id) + _fails += 1 + model.queue_free() + + +## The attachment is a child of the SKELETON, which is nested somewhere inside +## the loaded glTF scene rather than being a direct child of the model — so it +## is reached from the skeleton, not by a path from the model. +func _weapon_node(model) -> Node3D: + if model.skeleton == null: + return null + var attach: Node = model.skeleton.get_node_or_null("WeaponAttachment") + if attach == null or attach.get_child_count() == 0: + return null + return attach.get_child(0) as Node3D diff --git a/debug/rig_anchor_check.gd.uid b/debug/rig_anchor_check.gd.uid new file mode 100644 index 0000000..539a402 --- /dev/null +++ b/debug/rig_anchor_check.gd.uid @@ -0,0 +1 @@ +uid://colk7su4vk56m diff --git a/debug/rig_lab.gd b/debug/rig_lab.gd new file mode 100644 index 0000000..290e702 --- /dev/null +++ b/debug/rig_lab.gd @@ -0,0 +1,892 @@ +extends Node3D + +## Rig lab — the room where a character's rig is adjusted by eye, in 3D, live. +## +## godot --path . res://debug/rig_lab.tscn +## +## Everything about a rig that CAN be derived from the skeleton is derived: the +## bone roles, the mount rotation, the wrist roll, the weapon size, which +## surfaces are hair. What is left over is not a missing derivation, it is +## judgement — how high a stock rides, how far an elbow flares, how far down the +## palm a grip sits on THIS character's hand. Judgement wants an eye and a +## slider, not another constant tuned against one rig and wrong on the next. +## +## This is the environment for that. Three things can be adjusted here: +## +## HOLD how this character holds this weapon. Per character AND weapon, +## because a rifle and a pistol are not held alike. +## -> assets/characters/weapon_holds.json +## ANCHORS where the grip sits in the palm and how the gun rolls in the +## fingers. Per character only — that is a fact about the hand. +## -> assets/characters/rig_anchors.json +## SURFACES what the importer decided each surface of the model IS. Read-only +## here, but isolating a class is how you check the decision: click +## `hair` and only the hair should remain. +## +## Both tuning files are layered (defaults -> this character -> this weapon) and +## an absent file means "use what the code derives", so nothing here is required +## for the game to run correctly. +## +## CONTROLS +## drag a MARKER move that anchor left drag orbit +## middle drag pan wheel zoom +## F frame the hands +## R reset knobs S save C copy JSON to clipboard +## +## The three coloured markers ARE the anchors — red is the trigger grip, green +## the support hand on the handguard, blue the buttstock — and they can be +## dragged. The marker under the mouse swells; drag it and the hand follows. +## +## Dragging an anchor is not the same as any slider above it. The sliders move +## the GUN: `grip_offset` slides the weapon around inside the fist, and +## `gun_fore` / `gun_stock` slide the hands along the weapon's own axis. Neither +## can take a hand OFF that axis, which is what a handguard below the bore, an +## angled foregrip or a pistol grip all need. A drag writes `grip_shift` or +## `fore_shift`, in the gun's own across/up/along frame, so a nudge sideways +## stays sideways as the weapon pitches; the buttstock marker writes the shoulder +## pocket for whichever pose is showing. +## +## If a hand is not ON its marker, the IK could not reach — a different problem +## from the marker being in the wrong place, and dragging the marker further will +## not fix it. +## +## HAND versus GUN. These rotate different things, and the split is the point: +## +## TRIGGER/SUPPORT WRIST (hold) turns the HAND. The gun stays exactly where +## the solver put it, on the aim line. +## Grip roll/pitch/yaw (anchors) turns the GUN inside the hand. +## +## The weapon is a child of a BoneAttachment3D on the trigger hand, so the two +## used to be welded — every degree the wrist turned swung the barrel the same +## degree, and took with it every control that could have corrected for it. The +## pose layer now publishes the counter-rotation and the mount applies it, so +## the wrist sliders align the hand TO the gun rather than dragging it around. + +const POSES := [ + ["Low ready", "ground", 0.0, 0.0], + ["Aiming", "ground", 0.0, 1.0], + ["Running", "ground", 9.0, 0.0], + ["Crouched", "ground", 0.0, 0.0], +] + +## The two editable knob groups, and everything the UI needs to build, resolve +## and save each one. Adding a third group is adding a row here. +## +## `subject` is what the group is keyed on inside its file: a weapon id for the +## hold, a fixed word for anchors, because an anchor is per character and +## re-tuning it per gun would be re-answering the same question. +const GROUPS := { + "hold": { + "title": "HOLD · this character, this weapon", + "per_weapon": true, + }, + "anchors": { + "title": "ANCHORS · this character", + "per_weapon": false, + }, +} + +var _model: SkinnedPlayerModel +var _skins: Array = [] +var _weapons: Array = [] +var _skin := 0 +var _weapon := 0 +var _pose := 0 +## group -> resolved knob table, and group -> the whole file it came from. +var _knobs: Dictionary = {"hold": {}, "anchors": {}} +var _all: Dictionary = {"hold": {}, "anchors": {}} +## group -> key -> {spec, label, x, y, z}. Only ever holds the knobs currently +## ON SCREEN, which for the hold means only the selected pose's. +var _sliders: Dictionary = {"hold": {}, "anchors": {}} +## group -> the container its slider rows live in, and its heading, so the hold +## can be rebuilt when the pose changes without rebuilding the whole panel. +var _knob_boxes: Dictionary = {} +var _headings: Dictionary = {} +## "" means the pose buttons drive the model; anything else is a canonical clip +## being played on its own so a single animation can be watched end to end. +var _clip := "" +var _clip_picker: OptionButton +var _surface_box: VBoxContainer +var _isolated := "" +## MeshInstance3D -> surface index -> the override material displaced by +## isolation, so it can be put back without rebuilding the character. +var _hidden_surfaces: Dictionary = {} +var _status: Label +var _cam: Camera3D +var _yaw := 0.6 +var _pitch := -0.1 +var _dist := 2.2 +var _pivot := Vector3(0, 1.25, 0) +var _markers: Array = [] +var _pickers: Dictionary = {} + + +## Frames to wait before the self-shot below. The model loads asynchronously and +## the cloth solver needs a moment to settle, so an immediate capture shows a +## half-built character. +const SHOT_WARMUP := 40 +var _shot_path := "" +var _frames := 0 + + +func _ready() -> void: + _build_world() + _collect_sources() + # `-- shot [skin] [weapon]` renders one frame and quits, so the lab + # can be checked without a human at the controls. + var args := OS.get_cmdline_user_args() + if args.size() >= 2 and String(args[0]) == "shot": + _shot_path = String(args[1]) + if args.size() > 2: + _skin = maxi(0, _index_of(_skins, String(args[2]))) + if args.size() > 3: + _weapon = maxi(0, _index_of(_weapons, String(args[3]))) + if args.size() > 4: + # Which pose to photograph — the panel differs per pose now, so a + # shot of only the default one says nothing about the others. + _pose = clampi(int(args[4]), 0, POSES.size() - 1) + _build_ui() + _reload_model() + + +func _index_of(list: Array, id: String) -> int: + for i in list.size(): + if String(list[i].id) == id: + return i + return -1 + + +# ── scene ───────────────────────────────────────────────────────────────────── + +func _build_world() -> void: + var env := WorldEnvironment.new() + var e := Environment.new() + e.background_mode = Environment.BG_COLOR + e.background_color = Color(0.17, 0.18, 0.22) + e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + e.ambient_light_color = Color(0.55, 0.57, 0.65) + e.ambient_light_energy = 1.0 + env.environment = e + add_child(env) + + var key := DirectionalLight3D.new() + key.rotation_degrees = Vector3(-42, 132, 0) + key.light_energy = 1.5 + add_child(key) + var fill := DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-18, -40, 0) + fill.light_energy = 0.5 + add_child(fill) + + _cam = Camera3D.new() + _cam.fov = 45.0 + add_child(_cam) + _update_camera() + + # Grip / support / stock, so the points under the sliders are visible. + for c in [Color(1, 0.3, 0.3), Color(0.3, 1, 0.4), Color(0.4, 0.6, 1)]: + var m := MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 0.012 + sphere.height = 0.024 + m.mesh = sphere + var mat := StandardMaterial3D.new() + mat.albedo_color = c + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + # Depth-tested on purpose. Drawn through the body they look like they are + # floating in front of the chest when they are in fact behind an arm, + # which is exactly the wrong impression for judging whether a hand is on + # its target. `_process` lifts this for the marker under the mouse, so + # the one you are about to grab is the one you can see. + mat.no_depth_test = false + m.material_override = mat + add_child(m) + _markers.append(m) + + +func _collect_sources() -> void: + for id in SkinManager.skins: + var s = SkinManager.skins[id] + # GLB-backed skins only; the colour tints have no skeleton to pose. + if "model_path" in s and String(s.model_path) != "": + _skins.append({"id": id, "name": s.skin_name, "path": s.model_path}) + _skins.sort_custom(func(a, b): return a.id < b.id) + for id in LoadoutManager.weapon_db: + var w: Dictionary = LoadoutManager.weapon_db[id] + if String(w.get("script", "")) != "": + _weapons.append({"id": id, "name": w.get("name", id), + "script": w["script"]}) + _weapons.sort_custom(func(a, b): return a.id < b.id) + _all["hold"] = WeaponHoldTuning.load_all() + _all["anchors"] = RigAnchors.load_all() + + +func _reload_model() -> void: + if _model: + _model.queue_free() + _model = SkinnedPlayerModel.new() + _model.model_path = _skins[_skin].path + _model.skin_id = _skins[_skin].id + add_child(_model) + await get_tree().process_frame + _load_knobs() + _model.set_weapon(_weapons[_weapon].script) + _push("hold") + _push("anchors") + _clip = "" + _refresh_clip_picker() + _rebuild_surface_list() + _apply_pose() + + +## Fill the clip list from what this character actually HAS. +## +## Canonical names, from the model's own resolution table, rather than the raw +## clip names in the GLB — those differ per character (one rig's "CrouchIdle" is +## another's "Crouch_Idle_Loop") and the canonical name is the one the game asks +## for, so it is the one worth being able to audition. +func _refresh_clip_picker() -> void: + if _clip_picker == null: + return + _clip_picker.clear() + _clip_picker.add_item("(pose driven)") + if _model: + var names: Array = _model.clip_names_debug() + names.sort() + for n in names: + _clip_picker.add_item(n) + _clip_picker.select(0) + + +func _select_clip(index: int) -> void: + _clip = "" if index <= 0 else _clip_picker.get_item_text(index) + if _clip != "" and _model: + _model.play_clip_debug(_clip) + _status.text = "Pose buttons driving" if _clip == "" \ + else "Playing '%s' on its own" % _clip + + +func _apply_pose() -> void: + if not _model or not _model.loaded: + return + # A clip chosen in the scrubber owns playback: driving update_state as well + # would blend straight back to whatever the pose implies, and the clip would + # never be seen. + if _clip != "": + return + var p: Array = POSES[_pose] + _model.update_state(p[1], p[2], _pose == 3) + _model.set_locomotion(0.0, 1.0 if p[2] > 0.1 else 0.0, p[3]) + + +func _process(_delta: float) -> void: + _apply_pose() + if _shot_path != "": + _frames += 1 + if _frames == SHOT_WARMUP: + # Frame the hands, which is the only part anyone is judging. + if _model and _model._pose_mod: + _pivot = _model.skeleton.global_transform * _model._pose_mod.dbg_grip + _dist = 0.7 + _yaw = 1.15 + _pitch = -0.15 + _update_camera() + elif _frames > SHOT_WARMUP + 2: + var img := get_viewport().get_texture().get_image() + img.save_png(_shot_path) + print("rig_lab: saved ", _shot_path) + get_tree().quit() + return + if _model and _model._pose_mod and _model.skeleton: + var to_world: Transform3D = _model.skeleton.global_transform + var pm = _model._pose_mod + var pts := [pm.dbg_grip, pm.dbg_fore, pm.dbg_stock] + for i in _markers.size(): + _markers[i].global_position = to_world * pts[i] + _markers[i].visible = pts[i] != Vector3.ZERO + # The one being dragged, or the one the mouse is over, swells AND + # draws through the body. + # + # Depth testing is right for the other two: drawn through the mesh + # they look like they are floating in front of the chest when they + # are in fact behind an arm, which is the wrong impression for + # judging whether a hand reached its target. But it is wrong for the + # one being grabbed — at any useful framing the hands occlude all + # three markers, and a handle you cannot see is a handle you cannot + # find. `_marker_under` never cared about occlusion; this makes that + # visible rather than a secret. + var hot: bool = (i == _drag_marker) or (_drag_marker < 0 and i == _hover_marker) + _markers[i].scale = Vector3.ONE * (1.9 if hot else 1.0) + var mat := _markers[i].material_override as StandardMaterial3D + if mat: + mat.no_depth_test = hot + + +# ── dragging the anchors ────────────────────────────────────────────────────── +# +# The markers are the anchor POINTS the hands are solved onto. Until now they +# could only be moved through the sliders, and two of the three could only move +# along the barrel — `gun_stock` and `gun_fore` are distances along the gun's own +# axis, so the trigger and support hands slid up and down the weapon and nowhere +# else. What the sliders DID move freely was the gun itself, under anchors that +# stayed put. +# +# So: grab a marker and drag it. Each one writes to the knob that expresses that +# motion, in the frame that knob is written in — which is why the pose layer now +# publishes the gun's basis. A drag left has to mean "left across the weapon" +# whether the muzzle is down at low ready or level at ADS. + +## marker index -> [knob key, frame]. "gun" is the weapon's own across/up/along +## basis; "skeleton" is the model's, which is where the shoulder pocket lives. +const MARKER_KNOB := [ + ["grip_shift", "gun"], + ["fore_shift", "gun"], + ["pocket", "skeleton"], # resolved to pocket_hip / pocket_ads by pose +] +## How close, in pixels, the mouse has to be to a marker to take hold of it. +const GRAB_RADIUS := 26.0 + +var _drag_marker := -1 +var _hover_marker := -1 +## The plane a drag is measured on: fixed at grab time so it does not drift +## toward the camera as the marker follows the mouse. +var _drag_plane_at := Vector3.ZERO +var _drag_last := Vector3.ZERO + + +## The stock anchor is a blend of two knobs; a drag edits whichever one this +## pose is actually showing. Dragging at low ready must not silently rewrite the +## aiming pocket. +func _stock_knob() -> String: + return "pocket_%s" % _hold_pose() + + +func _knob_for(marker: int) -> String: + var key: String = MARKER_KNOB[marker][0] + return _stock_knob() if key == "pocket" else key + + +func _marker_under(mouse: Vector2) -> int: + var best := -1 + var best_d := GRAB_RADIUS + for i in _markers.size(): + if not _markers[i].visible: + continue + var p: Vector3 = _markers[i].global_position + # Behind the camera projects to a nonsense point that can still land + # within the grab radius. + if _cam.is_position_behind(p): + continue + var d := _cam.unproject_position(p).distance_to(mouse) + if d < best_d: + best_d = d + best = i + return best + + +## Where the mouse ray meets the drag plane — through the grab point, facing the +## camera. +func _plane_hit(mouse: Vector2) -> Vector3: + var origin := _cam.project_ray_origin(mouse) + var dir := _cam.project_ray_normal(mouse) + var n := -_cam.global_transform.basis.z + var denom := dir.dot(n) + if absf(denom) < 1e-5: + return _drag_last + return origin + dir * (((_drag_plane_at - origin).dot(n)) / denom) + + +func _begin_drag(marker: int, mouse: Vector2) -> void: + _drag_marker = marker + _drag_plane_at = _markers[marker].global_position + _drag_last = _plane_hit(mouse) + _status.text = "Dragging the %s anchor (%s)" % [ + ["trigger hand", "support hand", "buttstock"][marker], _knob_for(marker)] + + +func _drag_to(mouse: Vector2) -> void: + if _model == null or _model._pose_mod == null or _model.skeleton == null: + return + var now := _plane_hit(mouse) + var world_delta := now - _drag_last + _drag_last = now + + # World -> skeleton, because that is the space the pose layer works in. + var delta: Vector3 = _model.skeleton.global_transform.basis.inverse() * world_delta + if MARKER_KNOB[_drag_marker][1] == "gun": + # ...and on into the gun's frame for the two hand anchors. Orthonormal, + # so the inverse is the transpose, but say what is meant. + delta = _model._pose_mod.dbg_gun_basis.inverse() * delta + + var key := _knob_for(_drag_marker) + var spec := _spec_for("hold", key) + var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key)) + if not (cur is Vector3): + cur = Vector3.ZERO + var lo: float = spec[2] + var hi: float = spec[3] + _set_knob("hold", key, Vector3( + clampf(cur.x + delta.x, lo, hi), + clampf(cur.y + delta.y, lo, hi), + clampf(cur.z + delta.z, lo, hi))) + + +## The spec for a key, searched across every pose rather than the screenful on +## show. A drag writes the selected pose's key, which is on screen; a reset +## touches both. Looking only at `_specs` would silently hand back the +## catch-all below for anything belonging to the other pose. +func _spec_for(group: String, key: String) -> Array: + for spec in _all_specs(group): + if spec[0] == key: + return spec + return ["", "", -1.0, 1.0, true, Vector3.ZERO] + + +## Write a knob from somewhere other than its own slider, and keep the slider in +## step. Without the write-back the sliders would silently disagree with the +## model the moment anything was dragged, and Save would store the sliders. +func _set_knob(group: String, key: String, value) -> void: + _knobs[group][key] = value + var entry = _sliders[group].get(key) + if entry != null: + if entry.spec[4]: + entry.x.set_value_no_signal(value.x) + entry.y.set_value_no_signal(value.y) + entry.z.set_value_no_signal(value.z) + else: + entry.x.set_value_no_signal(float(value)) + _refresh_label(group, key) + _push(group) + + +# ── knobs ───────────────────────────────────────────────────────────────────── +# +# The two groups differ only in which spec table describes them, which file they +# live in and what they are keyed on. Everything below is written once against +# those three facts rather than twice against the two groups, so the anchors got +# a full editor — sliders, live preview, reset, save, clipboard — for the cost of +# a spec table. + +## The spec table for a group, for the pose CURRENTLY being adjusted. +## +## The hold's table is a function of the pose: half its knobs mean something +## different at low ready than they do down the sights, and one of them — +## muzzle pitch — does not exist down the sights at all, because there the +## muzzle follows the camera. Showing all of them at once meant every slider on +## screen was for one of two poses and nothing said which. +func _specs(group: String) -> Array: + if group == "anchors": + return RigAnchors.KNOBS + return WeaponHoldTuning.knobs_for(_hold_pose()) + + +## Which hold the selected pose is tuning. +## +## 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 as "Low ready" — and the heading says so, rather than +## letting someone tune "Running" and wonder why standing still changed. +func _hold_pose() -> String: + return WeaponHoldTuning.pose_for_ads(POSES[_pose][3]) + + +func _default_for(group: String, key: String): + return RigAnchors.default_for(key) if group == "anchors" \ + else WeaponHoldTuning.default_for(key) + + +## Every knob of a group across BOTH poses. Reset works on the whole table, not +## just the screenful on show — resetting while low ready is selected must not +## leave the aiming pocket half-tuned and invisible. +func _all_specs(group: String) -> Array: + return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.all_knobs() + + +func _load_knobs() -> void: + for group in GROUPS: + if group == "anchors": + _knobs[group] = RigAnchors.resolve(_all[group], _skins[_skin].id) + else: + _knobs[group] = WeaponHoldTuning.resolve(_all[group], + _skins[_skin].id, _weapons[_weapon].id) + _sync_sliders() + + +## Build the slider rows for each group, for the pose being adjusted. +## +## Called on startup and whenever the pose changes. Nothing about the underlying +## VALUES changes here — `_knobs` holds every pose's numbers at once, and Save +## writes all of them. This only decides which are on screen, so that every +## slider you can see belongs to the pose in the picker above it. +func _rebuild_knobs() -> void: + for group in GROUPS: + var rows: VBoxContainer = _knob_boxes.get(group) + if rows == null: + continue + for c in rows.get_children(): + rows.remove_child(c) + c.queue_free() + _sliders[group].clear() + for spec in _specs(group): + rows.add_child(_knob_row(group, spec)) + var head: Label = _headings.get(group) + if head: + head.text = _heading_for(group) + _sync_sliders() + + +func _heading_for(group: String) -> String: + if group != "hold": + return GROUPS[group]["title"] + var pose := _hold_pose() + var shown: String = WeaponHoldTuning.POSE_NAMES[pose] + # Say outright when the selected pose is not its own hold. Running and + # Crouched use the low-ready numbers, and someone tuning "Running" and + # finding standing still had changed too would rightly call that a bug. + var via := "" + if POSES[_pose][0].to_lower() != shown: + via = " (%s uses the %s hold)" % [POSES[_pose][0], shown] + return "HOLD · %s · this character, this weapon%s" % [shown.to_upper(), via] + + +## Push the stored values into whatever sliders are currently on screen. +func _sync_sliders() -> void: + for group in GROUPS: + for k in _sliders[group]: + var entry = _sliders[group][k] + var v = _knobs[group].get(k, _default_for(group, k)) + if entry.spec[4]: + if not (v is Vector3): + v = Vector3.ZERO + entry.x.set_value_no_signal(v.x) + entry.y.set_value_no_signal(v.y) + entry.z.set_value_no_signal(v.z) + else: + entry.x.set_value_no_signal(float(v)) + _refresh_label(group, k) + + +func _knob_changed(group: String, key: String) -> void: + var entry = _sliders[group][key] + var spec: Array = entry.spec + if spec[4]: + _knobs[group][key] = Vector3(entry.x.value, entry.y.value, entry.z.value) + else: + _knobs[group][key] = entry.x.value + _refresh_label(group, key) + _push(group) + + +func _push(group: String) -> void: + if _model == null: + return + if group == "anchors": + _model.set_anchors(_knobs[group]) + else: + _model.set_hold_tuning(_knobs[group]) + + +func _refresh_label(group: String, key: String) -> void: + var entry = _sliders[group][key] + var spec: Array = entry.spec + if spec[4]: + entry.label.text = "%s %.3f, %.3f, %.3f" % [spec[1], entry.x.value, + entry.y.value, entry.z.value] + else: + var v: float = entry.x.value + entry.label.text = "%s %s" % [spec[1], + "auto" if (key == "weapon_scale" and v < 0.01) else "%.3f" % v] + + +# ── ui ──────────────────────────────────────────────────────────────────────── + +func _build_ui() -> void: + var layer := CanvasLayer.new() + add_child(layer) + + # The lab is a dev tool, but it is still a screen in this game, and the + # theme is meant to be the ONE theme. It also has to be applied to this + # Control rather than only to the Window: a CanvasLayer is not a Control, so + # theme inheritance stops at it. + UITheme.apply_global(get_tree()) + var panel := PanelContainer.new() + panel.theme = UITheme.build() + panel.set_anchors_preset(Control.PRESET_LEFT_WIDE) + panel.custom_minimum_size = Vector2(440, 0) + layer.add_child(panel) + + var scroll := ScrollContainer.new() + panel.add_child(scroll) + var box := VBoxContainer.new() + box.custom_minimum_size = Vector2(410, 0) + box.add_theme_constant_override("separation", 2) + scroll.add_child(box) + + box.add_child(UITheme.title("RIG LAB", 34, 0.0)) + box.add_child(UITheme.divider(0.3)) + + box.add_child(_picker("Character", _skins, func(i): + _skin = i + _reload_model())) + box.add_child(_picker("Weapon", _weapons, func(i): + _weapon = i + _load_knobs() + if _model: + _model.set_weapon(_weapons[_weapon].script) + _push("hold") + _push("anchors"))) + var poses: Array = [] + for p in POSES: + poses.append({"name": p[0]}) + box.add_child(_picker("Pose", poses, func(i): + _pose = i + # The hold's knobs ARE the pose's knobs, so changing pose rebuilds them. + _rebuild_knobs())) + # Clip scrubber. The four poses above are the states the game drives; this + # plays one clip on its own, which is the only way to watch a whole + # animation end to end and see where a retarget went wrong. + var clip_row := _picker("Clip", [{"name": "(pose driven)"}], + func(i): _select_clip(i)) + _clip_picker = clip_row.get_child(1) + box.add_child(clip_row) + + for group in GROUPS: + box.add_child(UITheme.divider(0.5)) + var head := UITheme.heading("", 18) + _headings[group] = head + box.add_child(head) + # The hold's rows are rebuilt whenever the pose changes, so they live in + # their own container rather than loose in the panel. + var rows := VBoxContainer.new() + rows.add_theme_constant_override("separation", 2) + _knob_boxes[group] = rows + box.add_child(rows) + box.add_child(_group_buttons(group)) + _rebuild_knobs() + + box.add_child(UITheme.divider(0.7)) + box.add_child(UITheme.heading("SURFACES · click to isolate", 18)) + _surface_box = VBoxContainer.new() + _surface_box.add_theme_constant_override("separation", 1) + box.add_child(_surface_box) + + _status = Label.new() + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _status.custom_minimum_size = Vector2(400, 40) + _status.text = "DRAG A MARKER to move that anchor · left drag orbit · wheel zoom · middle drag pan · F frame hands" + box.add_child(_status) + # Reflect whatever the shot arguments or the defaults selected, or the + # dropdown says one thing while the scene shows another. + _pickers["Character"].select(_skin) + _pickers["Weapon"].select(_weapon) + _pickers["Pose"].select(_pose) + + +func _picker(label: String, items: Array, on_pick: Callable) -> Control: + var row := HBoxContainer.new() + var l := Label.new() + l.text = label + l.custom_minimum_size = Vector2(90, 0) + row.add_child(l) + var opt := OptionButton.new() + opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + for it in items: + opt.add_item(String(it.name)) + opt.item_selected.connect(on_pick) + row.add_child(opt) + _pickers[label] = opt + return row + + +func _knob_row(group: String, spec: Array) -> Control: + var key: String = spec[0] + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 0) + var label := Label.new() + label.add_theme_font_size_override("font_size", 16) + box.add_child(label) + var entry := {"spec": spec, "label": label} + var axes := ["x", "y", "z"] if spec[4] else ["x"] + for a in axes: + var sl := HSlider.new() + sl.min_value = spec[2] + sl.max_value = spec[3] + sl.step = 0.001 + sl.custom_minimum_size = Vector2(400, 14) + sl.value_changed.connect(func(_v): _knob_changed(group, key)) + box.add_child(sl) + entry[a] = sl + _sliders[group][key] = entry + return box + + +func _group_buttons(group: String) -> Control: + var row := HBoxContainer.new() + for b in [["Save", func(): _save(group)], ["Reset", func(): _reset(group)], + ["Copy", func(): _copy(group)]]: + var btn := Button.new() + btn.text = b[0] + btn.pressed.connect(b[1]) + row.add_child(btn) + return row + + +func _save(group: String) -> void: + var where := "" + if group == "anchors": + where = RigAnchors.save(_all[group], _skins[_skin].id, _knobs[group]) + _status.text = "Saved %s anchors to %s" % [_skins[_skin].id, where] + else: + where = WeaponHoldTuning.save(_all[group], _skins[_skin].id, + _weapons[_weapon].id, _knobs[group]) + _status.text = "Saved %s + %s hold to %s" % [_skins[_skin].id, + _weapons[_weapon].id, where] + + +## Clear a whole group — every pose, not just the one on screen. +## +## The sliders then take their DEFAULTS, not zero. Those are the same thing for +## every anchor and for most of the hold, but not for all of it: a slider parked +## at 0 next to a code default of 1.0 means reset quietly switched that behaviour +## off rather than restoring it. +func _reset(group: String) -> void: + _knobs[group] = {} + _sync_sliders() + _push(group) + _status.text = "Reset every %s knob, both poses, to what the code derives" % group + + +func _copy(group: String) -> void: + DisplayServer.clipboard_set(JSON.stringify( + TuningStore.flatten(_knobs[group]), " ")) + _status.text = "Copied %s to the clipboard" % group + + +# ── surfaces ────────────────────────────────────────────────────────────────── +# +# What the importer decided each surface of this model IS. Read-only, because +# the decision belongs in the sidecar where the whole game reads it — but +# ISOLATING a class is how the decision gets checked. Click `hair` and only the +# hair should be left standing. Anything else still visible was misclassified. + +func _rebuild_surface_list() -> void: + for c in _surface_box.get_children(): + c.queue_free() + _isolated = "" + _hidden_surfaces.clear() + if _model == null: + return + var counts := {} + for cls in [SkinSurfaces.BODY, SkinSurfaces.CLOTH, SkinSurfaces.HAIR, + SkinSurfaces.ACCESSORY, SkinSurfaces.LINEWORK]: + var n: int = _model.surfaces_of(cls).size() + if n > 0: + counts[cls] = n + for cls in counts: + var btn := Button.new() + btn.text = "%s ×%d" % [cls, counts[cls]] + btn.toggle_mode = true + btn.pressed.connect(func(): _isolate(cls if _isolated != cls else "")) + _surface_box.add_child(btn) + if counts.is_empty(): + _surface_box.add_child(UITheme.heading("no surface table", 16)) + + +## Show only one surface class, or "" for all of them. +## +## Hidden by swapping in a fully transparent material rather than by hiding the +## MeshInstance, because a mesh is not one class: Miku's body, face and hair are +## three surfaces of a single mesh, and hiding the node would take all three. +## The displaced materials are kept so this is reversible without rebuilding the +## character — which would also throw away whatever is being tuned. +func _isolate(cls: String) -> void: + for mi in _hidden_surfaces: + if is_instance_valid(mi): + for s in _hidden_surfaces[mi]: + mi.set_surface_override_material(s, _hidden_surfaces[mi][s]) + _hidden_surfaces.clear() + _isolated = cls + for b in _surface_box.get_children(): + if b is Button: + b.set_pressed_no_signal(b.text.begins_with(cls) and cls != "") + if cls == "" or _model == null: + _status.text = "Showing every surface" + return + + var keep := {} + for pair in _model.surfaces_of(cls): + keep["%s|%d" % [pair[0].get_instance_id(), pair[1]]] = true + var blank := StandardMaterial3D.new() + blank.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + blank.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + blank.albedo_color = Color(0, 0, 0, 0) + for mi in _model.find_children("*", "MeshInstance3D", true, false): + if mi.mesh == null: + continue + for s in mi.mesh.get_surface_count(): + if keep.has("%s|%d" % [mi.get_instance_id(), s]): + continue + if not _hidden_surfaces.has(mi): + _hidden_surfaces[mi] = {} + _hidden_surfaces[mi][s] = mi.get_surface_override_material(s) + mi.set_surface_override_material(s, blank) + _status.text = "Isolated '%s' — anything else still visible is misclassified" % cls + + +# ── camera ──────────────────────────────────────────────────────────────────── + +func _update_camera() -> void: + var b := Basis.from_euler(Vector3(_pitch, _yaw, 0)) + _cam.global_transform = Transform3D(b, _pivot + b * Vector3(0, 0, _dist)) + + +func _unhandled_input(e: InputEvent) -> void: + if e is InputEventMouseMotion: + # An anchor drag OWNS the left button — otherwise grabbing a marker + # would also orbit the camera, and the anchor would appear to move + # because the view did. + if _drag_marker >= 0: + _drag_to(e.position) + return + _hover_marker = _marker_under(e.position) \ + if e.button_mask == 0 else _hover_marker + if e.button_mask & MOUSE_BUTTON_MASK_LEFT: + _yaw -= e.relative.x * 0.006 + _pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4) + _update_camera() + elif e.button_mask & MOUSE_BUTTON_MASK_MIDDLE: + var b := _cam.global_transform.basis + _pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015 + _update_camera() + elif e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT: + if e.pressed: + var hit := _marker_under(e.position) + if hit >= 0: + _begin_drag(hit, e.position) + elif _drag_marker >= 0: + _status.text = "%s is now %s — Save to keep it" % [ + _knob_for(_drag_marker), + _knobs["hold"].get(_knob_for(_drag_marker), Vector3.ZERO)] + _drag_marker = -1 + elif e is InputEventMouseButton and e.pressed: + if e.button_index == MOUSE_BUTTON_WHEEL_UP: + _dist = maxf(_dist * 0.9, 0.15) + _update_camera() + elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _dist = minf(_dist * 1.1, 8.0) + _update_camera() + elif e is InputEventKey and e.pressed and not e.echo: + match e.keycode: + KEY_F: + # Frame the hands, which is what is actually being judged. + if _model and _model._pose_mod: + _pivot = _model.skeleton.global_transform \ + * _model._pose_mod.dbg_grip + _dist = 0.55 + _update_camera() + # The shortcuts act on the HOLD group, which is what they meant + # when it was the only group. Anchors have their own buttons. + KEY_S: _save("hold") + KEY_R: _reset("hold") + KEY_C: _copy("hold") + KEY_ESCAPE: get_tree().quit() diff --git a/debug/rig_lab.gd.uid b/debug/rig_lab.gd.uid new file mode 100644 index 0000000..a4221ab --- /dev/null +++ b/debug/rig_lab.gd.uid @@ -0,0 +1 @@ +uid://dmqu5ghj4pxl1 diff --git a/debug/rig_lab.tscn b/debug/rig_lab.tscn new file mode 100644 index 0000000..9f929e2 --- /dev/null +++ b/debug/rig_lab.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://c1aec1m0pwuk3"] + +[ext_resource type="Script" path="res://debug/rig_lab.gd" id="1_lab"] + +[node name="RigLab" type="Node3D"] +script = ExtResource("1_lab") diff --git a/debug/roster_capture.gd b/debug/roster_capture.gd new file mode 100644 index 0000000..615f717 --- /dev/null +++ b/debug/roster_capture.gd @@ -0,0 +1,84 @@ +extends SceneTree + +## Photograph every character in the picker, one shot each. +## +## The picker builds a real SkinnedPlayerModel per character, so this is the +## cheapest full-roster visual check there is: it catches a rig whose idle pose +## came out wrong, a skin that lost its textures, cloth that never settles, and +## a character standing the wrong way round — none of which any assertion in +## debug/character_picker_check.gd can see. +## +## godot --path . -s res://debug/roster_capture.gd -- + +var _out_dir := "." +var _frames := 0 +var _index := -1 +var _menu: Node = null +## The character currently posing. Tracked explicitly rather than recomputed +## from the list index at shutter time — _next() skips the colour-tint skins, +## so the index when the photo is taken is not the index it was selected at, +## and every file came out labelled with the wrong character. +var _posing: String = "" +## Frames to let a freshly-built character load, blend into idle and let its +## hair and skirt stop swinging before the shutter opens. +const SETTLE := 90 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + _out_dir = args[0] + var nm = root.get_node_or_null("NetworkManager") + if nm and nm.has_method("start_singleplayer_match"): + nm.start_singleplayer_match("Deathmatch") + change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") + + +func _process(_delta: float) -> bool: + _frames += 1 + if _frames < 150: + return false + if _menu == null: + _menu = root.get_node_or_null("PauseMenu") + if _menu == null: + print("no PauseMenu autoload") + quit(1) + return true + _menu.visible = true + _menu._show_character() + _next() + return false + if (_frames - 150) % SETTLE == 0: + _shoot() + _next() + return false + + +func _next() -> void: + _index += 1 + while _index < _menu.character_list.item_count: + var id: String = _menu.character_list.get_item_metadata(_index) + # Colour-tint skins have no model; there is nothing to photograph. + _menu.character_list.select(_index) + _menu._on_character_selected(_index) + if _menu._preview_model != null: + _posing = id + return + _index += 1 + _posing = "" + quit() + + +func _shoot() -> void: + if _posing == "": + return + var id := _posing + var img := root.get_texture().get_image() + # Crop to the preview panel — the rest of the frame is the level behind it. + var w := img.get_width() + var h := img.get_height() + img = img.get_region(Rect2i(int(w * 0.44), int(h * 0.15), + int(w * 0.32), int(h * 0.68))) + var path := "%s/roster_%s.png" % [_out_dir, id] + img.save_png(path) + print("wrote %s" % path) diff --git a/debug/roster_capture.gd.uid b/debug/roster_capture.gd.uid new file mode 100644 index 0000000..8386f5a --- /dev/null +++ b/debug/roster_capture.gd.uid @@ -0,0 +1 @@ +uid://cnx7lbjpa6gpu diff --git a/debug/skirt_clip_view.gd b/debug/skirt_clip_view.gd new file mode 100644 index 0000000..9ed29c3 --- /dev/null +++ b/debug/skirt_clip_view.gd @@ -0,0 +1,316 @@ +extends SceneTree + +## Fixed simulation step — see _lock_timestep. +const STEP := 1.0 / 60.0 + +## Dev tool: is the thigh THROUGH the skirt, or just showing past its edge? +## +## godot --path . --windowed --resolution 900x900 \ +## -s res://debug/skirt_clip_view.gd -- [skin] +## +## debug/skirt_closeup.gd renders the character as it ships, and at that point +## the two failures look identical: a wedge of thigh against dark cloth reads the +## same whether the leg is in front of a panel or simply visible between two of +## them. Both were guessed at, in both directions, before this existed. +## +## So: every cloth surface is painted flat MAGENTA and the body flat GREY, +## unshaded, no outline. +## +## Alongside each pair it writes `hip_N.txt`: the screen row of the hip joint. +## tools/measure_clipview.py counts only BELOW that line, because the torso is +## legitimately in front of the skirt's waistband and counting it reported 5% of +## the cloth covered on a pose that is actually clean. Colouring the legs +## separately does not work — the bare thigh is part of the body mesh, and a +## per-surface colour caught only the boots. Cloth is then a solid silhouette, and the question +## has one answer — any grey inside the magenta is the leg in front of the skirt, +## and grey outside it is just the leg past the hem, which is correct. +## +## Each shot is saved twice: `clipview_N` with the whole character, and +## `cloth_N` with the body hidden. A grey wedge that is a HOLE in the cloth-only +## image is the leg showing between two panels that have drifted apart, which is +## a different bug with a different fix; a grey wedge over solid magenta is the +## leg in front of the cloth. +## +## Cloth is identified from the skin itself (a surface whose vertices are mostly +## driven by the sidecar's cloth chains), not by material or surface name. + +const SHOT_EVERY := 3 +## Frames to let every exponential smoother settle before measuring anything. +const WARMUP := 120 +## Shots taken. A whole run cycle rather than a handful of samples of it: +## the capture is not locked to the simulation step, so a few samples land on +## a different phase every run and the total swings 2-3x. Averaged over the +## full cycle that phase noise cancels and the number is comparable again. +const SHOTS := 40 +## The movement states the skirt has to survive, one per shot pair. Running is +## where the defect was first seen, but a jump and a slide put the thigh through +## the front of the skirt in poses a run never reaches. +const STATES := [["ground", 9.0], ["ground", 3.0], ["air", 6.0], ["air", -8.0], + ["slide", 10.0], ["dash", 14.0], ["ground", 9.0], ["air", 6.0]] + +var _frames := 0 +var _out := "." +var _model: SkinnedPlayerModel = null +var _cam: Camera3D = null +var _shots := 0 +var _painted := false +## [MeshInstance3D, surface, body material] for every non-cloth surface, so the +## body can be blanked for the cloth-only frame and put back. +var _body: Array = [] +var _blank: Material = null +var _grey: Material = null + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + _out = args[0] if args.size() > 0 else "." + var path := "res://assets/characters/skins/taila.glb" + if args.size() > 1: + path = "res://assets/characters/skins/%s.glb" % args[1] + 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.0 + env.environment = e + scene.add_child(env) + + _model = SkinnedPlayerModel.new() + _model.model_path = path + scene.add_child(_model) + _cam = Camera3D.new() + _cam.fov = 30.0 + scene.add_child(_cam) + _cam.current = true + + +## Flat unshaded colours: magenta for anything the cloth chains drive, grey for +## the rest. Overrides, so the character's own toon materials are untouched. +func _paint(skel: Skeleton3D) -> void: + var cloth := {} + 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: + for c in info.get("chains", []): + for n in c.get("bones", []): + var i := skel.find_bone(String(n)) + if i >= 0: + cloth[i] = true + + # Which bones are LIMBS, so their surfaces can be told from the torso. + var limb := {} + if typeof(info) == TYPE_DICTIONARY: + for c in info.get("colliders", []): + for key in ["bone", "child"]: + var bn := String(c.get(key, "")) + for b in skel.get_bone_count(): + var n := skel.get_bone_name(b) + if n == bn or n.begins_with(bn + "."): + limb[b] = true + + # DEPTH, written into the colour channel — not a flat tag colour. + # + # A silhouette test cannot answer this question. With the legs apart you see + # the FAR side of the skirt through the gap between them, the thigh is + # correctly in front of that, and a mask test counts every one of those + # pixels: it reported 25% of the cloth covered on poses that are fine, and + # sent two rounds of tuning after a defect that was not there. Comparing + # distances instead, a leg only counts when it is nearer than the NEAREST + # CLOTH at that pixel, which is exactly what "the thigh is showing through + # the skirt" means. + # + # 0 stays "no geometry here", so real depths start just above it. + var sh := Shader.new() + # The cloth pass culls BACK FACES, so only the NEAR surface of the garment is + # drawn. Without that the far side of the skirt — what you see through the gap + # between the legs — is in the mask too, the thigh is correctly in front of it, + # and every one of those pixels reads as a defect. + sh.code = "shader_type spatial; +" + "render_mode unshaded, cull_back; +" + "varying float view_z; +" + "void vertex() { view_z = -(MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).z; } +" + "void fragment() { +" + " float d = clamp((view_z - 0.4) / 1.6, 0.0, 0.96); +" + " ALBEDO = vec3(0.02 + d); +" + "} +" + var mag := ShaderMaterial.new() + mag.shader = sh + var sh_body := Shader.new() + sh_body.code = sh.code.replace("cull_back", "cull_disabled") + var body_mat := ShaderMaterial.new() + body_mat.shader = sh_body + var cyan := body_mat + _grey = body_mat + var grey := body_mat + + for mi in _model.find_children("*", "MeshInstance3D", true, false): + if mi.mesh == null: + continue + if mi.skin == null: + # The model's own outline shell has no skin and would hide + # everything behind it. + mi.visible = false + 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 is_cloth := false + var is_limb := false + if not bones.is_empty() and not verts.is_empty(): + var per: int = bones.size() / verts.size() + var n := 0 + var legn := 0 + for v in verts.size(): + # The SUM of the cloth chains' share, not the single + # heaviest bone. tools/retarget.py's bind_cloth_to_legs + # hands cloth vertices resting on a thigh most of their + # weight, so on the panels that matter here the dominant + # bone is the LEG and a heaviest-bone test calls the whole + # skirt body. + var w := 0.0 + for k in per: + if cloth.has(bone_of[bones[v * per + k]]): + w += weights[v * per + k] + if w > 0.25: + n += 1 + var lw := 0.0 + for k in per: + if limb.has(bone_of[bones[v * per + k]]): + lw += weights[v * per + k] + if lw > 0.5: + legn += 1 + is_cloth = n * 4 > verts.size() + is_limb = not is_cloth and legn * 2 > verts.size() + var mat: Material = grey + if is_cloth: + mat = mag + elif is_limb: + mat = cyan + mi.set_surface_override_material(s, mat) + if not is_cloth: + _body.append([mi, s, mat]) + print(" %s/%d %s" % [mi.name, s, + "CLOTH" if is_cloth else ("LIMB" if is_limb else "body")]) + _blank = StandardMaterial3D.new() + _blank.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _blank.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _blank.albedo_color = Color(0, 0, 0, 0) + _blank.no_depth_test = false + _blank.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED + print("painted %d cloth bones' surfaces magenta" % cloth.size()) + + +## Screen row of the hip joint, so the measurement can ignore the torso. +func _write_hip_row() -> void: + var skel: Skeleton3D = _model.skeleton + var h := skel.find_bone("DEF-thigh.L") + if h < 0: + h = skel.find_bone("DEF-spine") + if h < 0: + return + var world: Vector3 = skel.global_transform * skel.get_bone_global_pose(h).origin + var f := FileAccess.open("%s/hip_%d.txt" % [_out, _shots], FileAccess.WRITE) + if f: + f.store_string("%d" % int(_cam.unproject_position(world).y)) + f.close() + + +## Drive the animation and the solver on a FIXED timestep. +## +## Both advance on the real frame delta otherwise, so the pose at a given frame +## drifts between runs and the same build measured 146k and 398k offending +## pixels. Every A/B comparison made without this was noise, and several tuning +## decisions were taken on the strength of it. +## +## The modifier stack needs pinning too: on PHYSICS it runs a variable number +## of times per rendered frame, so with a fixed step the amount of simulated +## time per frame still wandered. +func _lock_timestep() -> void: + var skel: Skeleton3D = _model.skeleton + if skel: + skel.modifier_callback_mode_process = \ + Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE + var spring := skel.get_node_or_null("SpringBones") + if spring: + spring.fixed_delta = STEP + for n in _model.find_children("*", "AnimationTree", true, false): + n.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + + +## One fixed step of the animation. Call once per rendered frame. +func _step_anim() -> void: + for n in _model.find_children("*", "AnimationTree", true, false): + n.advance(STEP) + +func _process(_delta: float) -> bool: + _frames += 1 + if _frames < 10 or not _model.loaded: + return false + if not _painted: + if _model.skeleton == null: + return true + # Fixed timestep, or nothing here is repeatable: the solver integrates + # against the real frame delta, so the same build measured 93k and 76k + # offending pixels on consecutive runs and every A/B comparison was noise. + _lock_timestep() + _paint(_model.skeleton) + _painted = true + return false + var st: Array = STATES[clampi(_shots * STATES.size() / SHOTS, 0, STATES.size() - 1)] + _model.update_state(st[0], st[1], false) + _model.set_locomotion(0.0, 1.0, 0.0) + _step_anim() + + 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 + # Level with the hem and dead in front: the view a player actually gets. + # Long warm-up before the first shot. The pose layer's lean/hold smoothers + # still run on the real frame delta and only converge exponentially, so + # sampling early made the run irreproducible however tightly the solver and + # the animation were pinned. + if _frames > WARMUP and _shots < SHOTS: + var phase := _frames % SHOT_EVERY + if phase == 0: + # 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.10, -0.95) + _cam.look_at(Vector3(0, hips - 0.14, 0), Vector3.UP) + elif phase == 1: + root.get_texture().get_image().save_png( + "%s/clipview_%d.png" % [_out, _shots]) + for e in _body: + e[0].set_surface_override_material(e[1], _blank) + elif phase == 2: + # Same pose, one frame later — near enough to read the coverage. + root.get_texture().get_image().save_png( + "%s/cloth_%d.png" % [_out, _shots]) + _write_hip_row() + for e in _body: + e[0].set_surface_override_material(e[1], e[2]) + print("saved clipview_%d (%s %.0f) + cloth_%d" % [ + _shots, st[0], st[1], _shots]) + _shots += 1 + if _shots >= SHOTS or _frames > WARMUP + SHOTS * SHOT_EVERY + 40: + return true + return false diff --git a/debug/skirt_clip_view.gd.uid b/debug/skirt_clip_view.gd.uid new file mode 100644 index 0000000..59835f0 --- /dev/null +++ b/debug/skirt_clip_view.gd.uid @@ -0,0 +1 @@ +uid://4skd3rvnpm4s diff --git a/debug/skirt_closeup.gd b/debug/skirt_closeup.gd new file mode 100644 index 0000000..787c4e9 --- /dev/null +++ b/debug/skirt_closeup.gd @@ -0,0 +1,91 @@ +extends SceneTree + +## Dev tool: close-up of the hip/thigh region through a run cycle. +## +## godot --path . --windowed --resolution 900x900 -s res://debug/skirt_closeup.gd -- [skin] +## +## debug/anim_capture.gd frames the whole character in 1280x720, which is far +## too small to judge whether a thigh is poking through a skirt — the question +## this exists to answer. Camera sits ~0.9 m from the hips at hip height and +## saves front and side views at several points across the stride. + +var _frames := 0 +var _out := "." +var _model: SkinnedPlayerModel = null +var _cam: Camera3D = null +var _shots := 0 +const SHOT_EVERY := 7 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + _out = args[0] if args.size() > 0 else "." + var path := "res://assets/characters/skins/taila.glb" + if args.size() > 1: + path = "res://assets/characters/skins/%s.glb" % args[1] + 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.15, 0.15, 0.2) + e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + e.ambient_light_color = Color(1, 1, 1) + e.ambient_light_energy = 1.6 + env.environment = e + scene.add_child(env) + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-40, 35, 0) + sun.light_energy = 1.4 + scene.add_child(sun) + + _model = SkinnedPlayerModel.new() + _model.model_path = path + scene.add_child(_model) + _cam = Camera3D.new() + _cam.fov = 26.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", 9.0, false) + _model.set_locomotion(0.0, 1.0, 0.0) + + # Frame the hips: that is where a skirt meets a thigh. + 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 + # Position on one frame, capture on the next: awaiting inside _process turns + # it into a coroutine and the SceneTree stops driving it. + if _frames > 20 and _shots < 8: + var phase := _frames % SHOT_EVERY + if phase == 0: + var side := (_shots % 2) == 1 + if side: + _cam.position = Vector3(0.85, hips - 0.02, 0.0) + else: + # 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.02, -0.85) + _cam.look_at(Vector3(0, hips - 0.16, 0), Vector3.UP) + elif phase == 1: + var tag := "side" if (_shots % 2) == 1 else "front" + root.get_texture().get_image().save_png( + "%s/skirt_%s_%d.png" % [_out, tag, _shots]) + print("saved skirt_%s_%d.png" % [tag, _shots]) + _shots += 1 + if _shots >= 8: + return true + if _frames > 200: + return true + return false diff --git a/debug/skirt_closeup.gd.uid b/debug/skirt_closeup.gd.uid new file mode 100644 index 0000000..f769d6d --- /dev/null +++ b/debug/skirt_closeup.gd.uid @@ -0,0 +1 @@ +uid://5mxt3sn3sq1v diff --git a/debug/skirt_probe.gd b/debug/skirt_probe.gd new file mode 100644 index 0000000..26897cc --- /dev/null +++ b/debug/skirt_probe.gd @@ -0,0 +1,145 @@ +extends SceneTree + +## Dev tool: WHY is the thigh inside the skirt? +## +## godot --headless --path . -s res://debug/skirt_probe.gd +## +## debug/cloth_clip_check.gd says how deep the leg is inside the cloth; +## this says which part of the solver let it in. For every cloth bone it reports, +## at the worst moment of a run cycle: +## +## TRUE how far the deepest hull point is inside the REAL leg capsule +## SEEN how far the solver thinks it is inside — i.e. after the per-point +## rest-clearance allowance in SpringBones._rest_clearances +## t where along the capsule that point sits (0 = hip joint, 1 = knee) +## +## TRUE >> SEEN means the allowance is the leak: the collision is satisfied while +## the leg is still visibly through the cloth. TRUE ~= SEEN means the solver sees +## the penetration and cannot correct it, which is a degree-of-freedom problem. +## +## Measured from an observer SkeletonModifier3D added AFTER SpringBones — see the +## rig-pipeline notes: _process runs before the modifier stack (rest pose) and +## force_update_all_bone_transforms() re-runs it (double-steps the solver). + +const SWEEP := [["ground", 9.0], ["ground", 3.0], ["air", 6.0], + ["air", -8.0], ["slide", 10.0], ["dash", 14.0]] +const FRAMES_PER_STATE := 40 + +var _frames := 0 +var _model: SkinnedPlayerModel = null +var _probe: Node = 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 + if _probe == null: + var spring := skel.get_node_or_null("SpringBones") + if spring == null: + print("no SpringBones on this model — nothing to probe") + return true + _probe = Probe.new() + _probe.name = "SkirtProbe" + _probe.spring = spring + skel.add_child(_probe) # AFTER SpringBones, so it sees the final pose + 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) + if _frames > 9 + FRAMES_PER_STATE * SWEEP.size(): + _probe.effort = _model.skeleton.get_node("SpringBones").debug_effort_report() + _probe.report() + return true + return false + + +## Reads the solver's own chain/collider tables and re-tests them against the +## final pose, so the numbers are the ones the solver actually acted on. +class Probe extends SkeletonModifier3D: + var spring: Node = null + var worst: Dictionary = {} # bone name -> [true_pen, seen_pen, t, class] + var effort: Dictionary = {} + + func _process_modification() -> void: + var skel := get_skeleton() + if skel == null or spring == null: + return + var to_world := skel.global_transform + var cols: Array = spring._colliders + for chain in spring._chains: + var bones: PackedInt32Array = chain["bones"] + var tips: PackedVector3Array = chain["tips"] + var hulls: Array = chain["hulls"] + var radii: Array = chain["radii"] + if radii.is_empty(): + continue # hair — does not collide with the legs by design + for i in bones.size(): + var bone: int = bones[i] + var posed: Transform3D = to_world * skel.get_bone_global_pose(bone) + var pts := SpringBones._sample_points( + posed, posed.origin, posed * tips[i], hulls[i]) + var rec: Dictionary = radii[i] + var caps: PackedFloat32Array = rec.get("cap", PackedFloat32Array()) + var name := skel.get_bone_name(bone) + for c in cols.size(): + var col: Dictionary = cols[c] + # Same `from` offset the solver uses, or this reports overlap with + # a part of the leg nothing is being asked to clear. + var ends: Array = spring._capsule(skel, to_world, col) + var a: Vector3 = ends[0] + var b: Vector3 = ends[1] + var ab := b - a + var d2 := ab.length_squared() + for j in pts.size(): + var p: Vector3 = pts[j] + var t := 0.0 if d2 < 0.000001 \ + else clampf((p - a).dot(ab) / d2, 0.0, 1.0) + var dist := p.distance_to(a + ab * t) + var r_true: float = lerpf(float(col["rh"]), float(col["rt"]), t) + var idx := c * pts.size() + j + var r_seen: float = minf(r_true, + caps[idx] if idx < caps.size() else r_true) + var pen := r_true - dist + if pen > float(worst.get(name, [0.0])[0]): + # `reach` is the lever the solver has on this point: + # a collision is resolved by ROTATING the bone, so a + # point sitting almost on the bone's own head cannot + # be moved by it at all, however deep it is. + # `above` is its height over the hip joint — anything + # positive is inside the fictional sphere the capsule + # puts at the top of the thigh, not inside the leg. + worst[name] = [pen, r_seen - dist, t, + (p - posed.origin).length(), p.y - a.y] + + func report() -> void: + var skel := get_skeleton() + print("\n=== deepest leg-in-cloth per bone (worst over the sweep) ===") + print(" %-26s %8s %8s %6s %8s %9s %8s %8s" % [ + "bone", "TRUE mm", "SEEN mm", "t", "reach mm", "above mm", "fix deg", "left mm"]) + var keys := worst.keys() + keys.sort_custom(func(a, b): return worst[a][0] > worst[b][0]) + for k in keys: + var w: Array = worst[k] + if w[0] <= 0.0005: + continue + var ef: Array = effort.get(skel.find_bone(k), [0.0, 0.0]) + print(" %-26s %8.1f %8.1f %6.2f %8.1f %9.1f %8.1f %8.1f" % [ + k, w[0] * 1000.0, w[1] * 1000.0, w[2], w[3] * 1000.0, w[4] * 1000.0, + ef[0], ef[1]]) + print("") diff --git a/debug/skirt_probe.gd.uid b/debug/skirt_probe.gd.uid new file mode 100644 index 0000000..a036ac9 --- /dev/null +++ b/debug/skirt_probe.gd.uid @@ -0,0 +1 @@ +uid://b6wvfdpt1whek diff --git a/debug/skirt_run_view.gd b/debug/skirt_run_view.gd new file mode 100644 index 0000000..c7a1013 --- /dev/null +++ b/debug/skirt_run_view.gd @@ -0,0 +1,129 @@ +extends SceneTree + +## Fixed simulation step — see _lock_timestep. +const STEP := 1.0 / 60.0 + +## Dev tool: the front of the skirt through a whole run cycle, as a player sees it. +## +## godot --path . --windowed --resolution 1280x960 \ +## -s res://debug/skirt_run_view.gd -- [skin] +## +## Every other check in debug/ looks at this from 0.9 m with flat colours or from +## a solver's point of view. Those answer "is a bone inside a capsule" and "did +## the mesh tear", and both can read clean while the render is obviously wrong — +## a material that does not occlude, an outline shell drawn over the cloth, or +## simply a pose none of the sampled frames happened to catch. +## +## So: the shipped materials, a straight-on front camera at normal viewing +## distance, and EVERY frame of the run cycle rather than eight samples of it. + +const FIRST := 40 # let the chains settle before recording +const FRAMES := 48 # a full stride at 60 fps and then some + +var _frames := 0 +var _out := "." +var _model: SkinnedPlayerModel = null +var _cam: Camera3D = null +var _shots := 0 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + _out = args[0] if args.size() > 0 else "." + var path := "res://assets/characters/skins/taila.glb" + if args.size() > 1: + path = "res://assets/characters/skins/%s.glb" % args[1] + 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.16, 0.16, 0.2) + e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + e.ambient_light_color = Color(1, 1, 1) + e.ambient_light_energy = 1.5 + env.environment = e + scene.add_child(env) + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-35, 25, 0) + sun.light_energy = 1.3 + scene.add_child(sun) + + _model = SkinnedPlayerModel.new() + _model.model_path = path + scene.add_child(_model) + _cam = Camera3D.new() + _cam.fov = 38.0 + scene.add_child(_cam) + _cam.current = true + + +## Drive the animation and the solver on a FIXED timestep. +## +## Both advance on the real frame delta otherwise, so the pose at a given frame +## drifts between runs and the same build measured 146k and 398k offending +## pixels. Every A/B comparison made without this was noise, and several tuning +## decisions were taken on the strength of it. +## +## The modifier stack needs pinning too: on PHYSICS it runs a variable number +## of times per rendered frame, so with a fixed step the amount of simulated +## time per frame still wandered. +func _lock_timestep() -> void: + var skel: Skeleton3D = _model.skeleton + if skel: + skel.modifier_callback_mode_process = \ + Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE + var spring := skel.get_node_or_null("SpringBones") + if spring: + spring.fixed_delta = STEP + for n in _model.find_children("*", "AnimationTree", true, false): + n.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + + +## One fixed step of the animation. Call once per rendered frame. +func _step_anim() -> void: + for n in _model.find_children("*", "AnimationTree", true, false): + n.advance(STEP) + +func _process(_delta: float) -> bool: + _frames += 1 + if _frames < 10 or not _model.loaded: + return false + if _frames == 10: + _lock_timestep() + # `nospring` renders the same cycle with the cloth solver removed, so a + # change can be told from no change at all. + var a := OS.get_cmdline_user_args() + if a.size() > 2 and String(a[2]) == "nospring": + var sp := _model.skeleton.get_node_or_null("SpringBones") + if sp: + sp.queue_free() + print("spring solver REMOVED") + _model.update_state("ground", 9.0, false) + _model.set_locomotion(0.0, 1.0, 0.0) + _step_anim() + + 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 + # Waist to knee, dead in front, from about where a third-person camera sits. + # + # NEGATIVE Z. SkinnedPlayerModel spins the imported scene 180 degrees + # (`facing_flip`: glTF forward is +Z, players face -Z), so a camera on +Z is + # looking at the character's BACK. Every earlier tool in here sat on +Z and + # every "front" render judged from them was the back of the skirt. + _cam.position = Vector3(0.0, hips - 0.05, -1.5) + _cam.look_at(Vector3(0, hips - 0.20, 0), Vector3.UP) + + if _frames >= FIRST and _shots < FRAMES: + root.get_texture().get_image().save_png( + "%s/run_%02d.png" % [_out, _shots]) + _shots += 1 + if _shots == FRAMES: + print("saved %d run frames" % FRAMES) + return true + return _frames > FIRST + FRAMES * 3 diff --git a/debug/skirt_run_view.gd.uid b/debug/skirt_run_view.gd.uid new file mode 100644 index 0000000..f1820b6 --- /dev/null +++ b/debug/skirt_run_view.gd.uid @@ -0,0 +1 @@ +uid://cxoj83cl5nnso diff --git a/debug/surface_class_check.gd b/debug/surface_class_check.gd new file mode 100644 index 0000000..9882e12 --- /dev/null +++ b/debug/surface_class_check.gd @@ -0,0 +1,70 @@ +extends SceneTree + +## Does every surface of every shipping skin get classified, and does the +## classification survive the trip from Blender into Godot? +## +## The surface table is written by tools/surface_map.py against the Blender +## scene and read by SkinSurfaces against the loaded glTF. Nothing forces the +## two to agree on what anything is called, so this checks that they do — by +## counting how many surfaces resolve from the TABLE versus how many fell +## through to the heuristic. A model whose names stopped matching still renders, +## because the fallback catches it; it just quietly loses its per-class art +## direction. That is exactly the kind of silent regression this catches. +## +## godot --headless --path . -s res://debug/surface_class_check.gd + +var _failures: int = 0 + +func _init() -> void: + var data = JSON.parse_string(FileAccess.get_file_as_string( + "res://assets/characters/skins/skins.json")) + for entry in data["skins"]: + _check_skin(entry["id"], entry.get("model", "")) + print("\n%s" % ("FAILED (%d)" % _failures if _failures > 0 else "All skins classified")) + quit(1 if _failures > 0 else 0) + + +func _check_skin(id: String, path: String) -> void: + if path == "" or not ResourceLoader.exists(path): + return + var side := path.get_basename() + ".rig.json" + var rig = JSON.parse_string(FileAccess.get_file_as_string(side)) + if typeof(rig) != TYPE_DICTIONARY: + print("%-10s NO SIDECAR" % id) + _failures += 1 + return + var surfaces := SkinSurfaces.from_rig_info(rig) + var scene: Node = load(path).instantiate() + + var tally := {} + var from_table := 0 + var from_guess := 0 + var unmatched: PackedStringArray = [] + for mi in scene.find_children("*", "MeshInstance3D", true, false): + if mi.mesh == null: + continue + for s in mi.mesh.get_surface_count(): + var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D + var mat_name := "" if src == null else src.resource_name + if surfaces.lookup(mi.name, s, mat_name).is_empty(): + from_guess += 1 + unmatched.append("%s|%d|%s" % [mi.name, s, mat_name]) + else: + from_table += 1 + var cls: String = surfaces.resolve(mi.name, s, src)[0] + tally[cls] = tally.get(cls, 0) + 1 + scene.free() + + var parts: PackedStringArray = [] + var keys: Array = tally.keys() + keys.sort() + for k in keys: + parts.append("%s x%d" % [k, tally[k]]) + print("%-10s %d surfaces — %s (table %d, fallback %d)" + % [id, from_table + from_guess, ", ".join(parts), from_table, from_guess]) + if from_guess > 0: + print(" unmatched: %s" % ", ".join(unmatched)) + _failures += 1 + if surfaces.is_empty(): + print(" sidecar has no surface table") + _failures += 1 diff --git a/debug/surface_class_check.gd.uid b/debug/surface_class_check.gd.uid new file mode 100644 index 0000000..9b14385 --- /dev/null +++ b/debug/surface_class_check.gd.uid @@ -0,0 +1 @@ +uid://bhafeud8lp38x diff --git a/debug/transition_check.gd b/debug/transition_check.gd new file mode 100644 index 0000000..169c6e8 --- /dev/null +++ b/debug/transition_check.gd @@ -0,0 +1,81 @@ +extends SceneTree + +## Dev tool: how abruptly does the model change posture when you start running? +## +## godot --headless --path . -s res://debug/transition_check.gd -- [skin_glb] +## +## Accelerates from a standstill the way the movement code does, then stops, and +## logs the clip in play plus the body's forward lean each frame. Reports the +## worst single-frame change in lean and how long the lean took to arrive. +## +## Written for "idle to running snaps the character leaning forward". The lean +## is procedural (ShooterPoseModifier), so it does NOT come from the clip +## crossfade and is not visible in an animation-blend graph — it was driven by a +## normalised input direction that steps 0 -> 1 the instant a key goes down, +## planting a full run posture in ~0.1 s while the Idle->Run crossfade still had +## 0.4 s to run. + +const ACCEL := 18.0 # m/s^2, roughly the controller's ground acceleration +const TOP := 9.0 # m/s +const DT := 1.0 / 60.0 + +var _t := 0.0 +var _frames := 0 +var _model: SkinnedPlayerModel = null +var _speed := 0.0 +var _prev_lean := 0.0 +var _worst_step := 0.0 +var _clip_changes: Array = [] +var _last_clip := "" +var _t_10 := -1.0 +var _t_90 := -1.0 +var _peak := 0.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: + return false + if not _model.loaded: + return false + + # Accelerate for 1.5 s, then coast at top speed to 2.5 s. + _t += DT + _speed = minf(TOP, _speed + ACCEL * DT) if _t < 2.5 else 0.0 + _model.update_state("ground", _speed, false) + _model.set_locomotion(0.0, 1.0 if _speed > 0.01 else 0.0, 0.0) + + var lean: float = _model.get_lean_debug() + if _last_clip != _model.current_clip_debug(): + _last_clip = _model.current_clip_debug() + _clip_changes.append("%.2fs %s @ %.1f m/s" % [_t, _last_clip, _speed]) + if _t < 2.5: + _peak = maxf(_peak, lean) + if _t_10 < 0.0 and lean > 0.1: + _t_10 = _t + if _t_90 < 0.0 and lean > 0.9: + _t_90 = _t + _worst_step = maxf(_worst_step, absf(lean - _prev_lean)) + _prev_lean = lean + + if _t > 3.5: + print("\n=== idle -> run transition ===") + print(" clip changes: " + ", ".join(_clip_changes)) + print(" lean reached 10%% at %.2fs, 90%% at %.2fs (peak %.2f)" % [_t_10, _t_90, _peak]) + print(" worst single-frame lean change: %.4f (%.2f per second at 60fps)" + % [_worst_step, _worst_step * 60.0]) + print(" a snap looks like ~0.10s to 90%%; a blended move is ~0.6s or more\n") + return true + return false diff --git a/debug/transition_check.gd.uid b/debug/transition_check.gd.uid new file mode 100644 index 0000000..c6ffc4c --- /dev/null +++ b/debug/transition_check.gd.uid @@ -0,0 +1 @@ +uid://deeo5pasohk5g diff --git a/debug/travel_dir_check.gd b/debug/travel_dir_check.gd new file mode 100644 index 0000000..1892573 --- /dev/null +++ b/debug/travel_dir_check.gd @@ -0,0 +1,162 @@ +extends SceneTree + +## Dev tool: do the LEGS point where the character is going, and does the CHEST +## stay pointed where the player is aiming? +## +## godot --headless --path . -s res://debug/travel_dir_check.gd -- [skin_glb] +## +## The clip library has one forward locomotion cycle and no strafe or backpedal +## clips, so nothing in the animation itself says which way the character is +## travelling. SkinnedPlayerModel._update_travel says it by yawing the hips onto +## the travel direction and unwinding that yaw up the spine. +## +## The number that matters is LEGS OFF BY: how far the stride points from the +## direction the character is actually travelling. A backpedal is achieved by +## running the cycle BACKWARDS with the hips near square, so it is scored on +## where the feet go, not on where the hips face. +## +## CHEST is reported for information only, and is not a pass/fail. Most of it is +## the clip's own torso twist — a run cycle counter-rotates the chest against the +## hips by tens of degrees twice a stride — and averaging over a stride only +## partly removes it. It is also not what keeps the gun on target: the rifle hold +## places the weapon from the SHOULDER and the aim direction and then solves the +## arms onto it, so the aim survives whatever the torso is doing. Read this +## column as "does the upper body still look square-ish", nothing stronger. + +## label, strafe, forward, the direction of travel this should read as (degrees +## about the character's up axis, 0 = straight ahead) +const CASES := [ + ["forward", 0.0, 1.0, 0.0], + ["strafe right", 1.0, 0.0, -90.0], + ["strafe left", -1.0, 0.0, 90.0], + ["fwd-right", 0.7, 0.7, -45.0], + ["fwd-left", -0.7, 0.7, 45.0], + ["backpedal", 0.0, -1.0, 180.0], + ["back-right", 0.7, -0.7, -135.0], +] +const SETTLE := 70 +## Frames averaged at the end of each case — comfortably more than one stride. +const MEASURE_OVER := 40 + +var _frames := 0 +var _model: SkinnedPlayerModel = null +var _probe = null +var _case := 0 +var _rows: Array = [] +var _hips_sum := 0.0 +var _chest_sum := 0.0 +var _n := 0.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) + + +## Bone poses read from INSIDE the modifier pass — Godot restores them after it, +## so anything read later is the animation alone with the pose layer missing. +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 _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 _probe == null: + _probe = PoseProbe.new() + _probe.name = "TravelProbe" + skel.add_child(_probe) + return false + + var c: Array = CASES[_case] + # Run fast enough that the lean, which scales with speed, is fully in. + _model.update_state("ground", 9.0, false) + _model.set_locomotion(c[1], c[2], 0.0) + # AVERAGED over the tail of each case, not sampled at one instant. + # + # A run cycle twists the torso against the hips by tens of degrees twice per + # stride, all of it authored and correct. Reading a single frame therefore + # measures wherever the clip happened to be, and the numbers swing far wider + # than anything the pose layer does — a backpedal read as 50 degrees of chest + # yaw with the hips almost square, which is the clip, not the layer. + var phase: int = (_frames - 9) % SETTLE + if phase >= SETTLE - MEASURE_OVER and _probe.pose.size() == skel.get_bone_count(): + _hips_sum += _yaw_of(skel, "hips") + _chest_sum += _yaw_of(skel, "spine_top") + _n += 1.0 + if phase != SETTLE - 1: + return false + + if _probe.pose.size() == skel.get_bone_count(): + _rows.append([c[0], _hips_sum / _n, _chest_sum / _n, + float(c[3]), _model.stride_reversed_debug()]) + _hips_sum = 0.0 + _chest_sum = 0.0 + _n = 0.0 + _case += 1 + if _case >= CASES.size(): + _report() + return true + return false + + +## Yaw of a bone away from its own rest orientation, in degrees, about the +## character's up axis. Measured against REST rather than against the parent, so +## it reads as "how far round from square" whatever the rig's bone axes are. +func _yaw_of(skel: Skeleton3D, which: String) -> float: + var roles: Dictionary = _model._rig_info.get("roles", {}) + var name := "" + if which == "hips": + name = String(roles.get("hips", "DEF-hips")) + else: + var spine: Array = roles.get("spine", []) + # The chest: the last spine bone that is not the neck or the head. + var neck := String(roles.get("neck", "")) + var head := String(roles.get("head", "")) + for n in spine: + if String(n) != neck and String(n) != head: + name = String(n) + var b := skel.find_bone(name) + if b < 0: + return 0.0 + var now: Basis = (_probe.pose[b] as Transform3D).basis + var rest: Basis = skel.get_bone_global_rest(b).basis + var delta := rest.inverse() * now + # The character faces +Z; how far that axis has swung in the ground plane. + var f: Vector3 = rest * (delta * (rest.inverse() * Vector3(0, 0, 1))) + return rad_to_deg(atan2(f.x, f.z)) + + +func _report() -> void: + print("\n=== does the body say which way it is going? ===") + print(" %-13s %9s %9s %9s %s" % [ + "input", "hips", "chest", "want legs", "stride"]) + for r in _rows: + # A reversed stride points the feet the opposite way to the hips. + var legs: float = r[1] + (180.0 if r[4] else 0.0) + var err: float = absf(wrapf(legs - r[3], -180.0, 180.0)) + print(" %-13s %+8.1f° %+8.1f° %+8.1f° %-8s legs off by %5.1f°%s" % [ + r[0], r[1], r[2], r[3], "reversed" if r[4] else "forward", err, + " CHEST OFF AIM" if absf(r[2]) > 25.0 else ""]) + print("\n 'legs off by' is how far the stride points from the travel") + print(" direction. The hips are capped (see MAX_TRAVEL_YAW), so a pure") + print(" sidestep is expected to fall short — a shooter keeps its aim.\n") diff --git a/debug/travel_dir_check.gd.uid b/debug/travel_dir_check.gd.uid new file mode 100644 index 0000000..46524d4 --- /dev/null +++ b/debug/travel_dir_check.gd.uid @@ -0,0 +1 @@ +uid://bccvs185gx4l7 diff --git a/debug/ui_capture.gd b/debug/ui_capture.gd new file mode 100644 index 0000000..8d2f2f1 --- /dev/null +++ b/debug/ui_capture.gd @@ -0,0 +1,68 @@ +extends SceneTree + +## Screenshot every UI screen, so the theme can be LOOKED at rather than +## asserted about. +## +## A theme is the one kind of change no headless check can validate: every +## contrast mistake, every panel that turns out to be invisible against the +## thing behind it, and every button whose label sits outside its own chip +## compiles perfectly. So this drives the real screens with rendering on and +## writes a PNG of each. +## +## godot --path . -s res://debug/ui_capture.gd -- +## +## Writes shot_menu.png, shot_pause.png, shot_character.png and +## shot_settings.png. + +var _frames := 0 +var _out_dir := "." + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() > 0: + _out_dir = args[0] + change_scene_to_file("res://ui/main_menu/main_menu.tscn") + + +func _process(_delta: float) -> bool: + _frames += 1 + match _frames: + 50: + _shot("menu") + # Into a real match. The pause menu deliberately refuses to open over + # the main menu, and it is the in-game background — a lit 3D level + # rather than a flat colour — that its legibility has to survive. + var nm = root.get_node_or_null("NetworkManager") + if nm and nm.has_method("start_singleplayer_match"): + nm.start_singleplayer_match("Deathmatch") + change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") + 200: + _open("_show_main_menu") + 215: + _shot("pause") + _open("_show_character") + 320: + # Long enough for the model to load and the springs to settle. + _shot("character") + _open("_show_settings") + 340: + _shot("settings") + quit() + return false + + +func _open(screen: String) -> void: + var menu = root.get_node_or_null("PauseMenu") + if menu == null: + push_warning("ui_capture: no PauseMenu autoload") + return + menu.visible = true + menu.call(screen) + + +func _shot(name: String) -> void: + var img := root.get_texture().get_image() + var path := "%s/shot_%s.png" % [_out_dir, name] + img.save_png(path) + print("wrote %s" % path) diff --git a/debug/ui_capture.gd.uid b/debug/ui_capture.gd.uid new file mode 100644 index 0000000..93a83b7 --- /dev/null +++ b/debug/ui_capture.gd.uid @@ -0,0 +1 @@ +uid://c2ucqqxfeaer5 diff --git a/debug/wrist_gun_check.gd b/debug/wrist_gun_check.gd new file mode 100644 index 0000000..5749341 --- /dev/null +++ b/debug/wrist_gun_check.gd @@ -0,0 +1,141 @@ +extends SceneTree + +## Does rotating the trigger wrist turn the HAND without taking the GUN with it? +## +## The two are welded by construction: the weapon is a child of a +## BoneAttachment3D on the trigger hand, so every degree the wrist turns swings +## the barrel the same degree off the aim line — and takes with it every control +## that could have corrected for it, because they are all expressed relative to +## that same hand. The wrist knob was therefore unusable for the one thing it +## exists for, which is aligning a hand to a gun. +## +## Both halves have to be asserted, and the second is the one that was broken: +## +## the HAND turns or the knob does nothing +## the GUN does not or the knob cannot be used +## +## Measured through a PoseProbe for the hand, because Godot restores every +## bone's local pose after the modifier pass and reading it from here would +## report the animation rather than the hold. The gun is a scene node, so its +## own global transform is the truth and no probe is needed. +## +## godot --path . -s res://debug/wrist_gun_check.gd + +const LAB := "res://debug/rig_lab.tscn" +## Large on purpose. A small twist could hide behind the tolerance of a gun that +## really was following the hand. +const TWIST := 0.5 +## Degrees the barrel may wander. Not zero: the hand's rotation feeds the IK +## chain that positions the whole arm, so the shoulder and the aim line settle +## very slightly differently — but a gun still welded to the wrist would move by +## something on the order of TWIST, which is 28 degrees. +const GUN_TOLERANCE := 3.0 +const HAND_MIN := 5.0 + +var _fails := 0 +var _probe: PoseProbe = null + + +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 + + var model = lab._model + if model == null or model._pose_mod == null or model.skeleton == null: + _expect(false, "the lab built a character holding a weapon") + _done() + return + var gun := _gun(model) + if gun == null: + _expect(false, "the character mounted a weapon") + _done() + return + _expect(true, "the lab built a character holding a weapon") + + _probe = PoseProbe.new() + _probe.name = "WristGunProbe" + model.skeleton.add_child(_probe) + for _i in 10: + await process_frame + + var hand: int = _role_bone(model, "hand.R") + for pose in ["hip", "ads"]: + lab._pose = 0 if pose == "hip" else 1 + for axis in 3: + var v := Vector3.ZERO + v[axis] = TWIST + model.set_hold_tuning({}) + await _settle(lab, pose) + var hand0 := _probe_rot(hand) + var gun0: Basis = gun.global_transform.basis.orthonormalized() + + model.set_hold_tuning({"wrist_r_%s" % pose: v}) + await _settle(lab, pose) + var hand1 := _probe_rot(hand) + var gun1: Basis = gun.global_transform.basis.orthonormalized() + + var hand_moved := rad_to_deg(hand0.angle_to(hand1)) + var gun_moved := rad_to_deg( + gun0.get_rotation_quaternion().angle_to(gun1.get_rotation_quaternion())) + _expect(hand_moved >= HAND_MIN, + "%s axis %d: the hand turned %.1f deg" % [pose, axis, hand_moved]) + _expect(gun_moved <= GUN_TOLERANCE, + "%s axis %d: the gun stayed put (%.1f deg)" % [pose, axis, gun_moved]) + model.set_hold_tuning({}) + _done() + + +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 _gun(model) -> Node3D: + if model.skeleton == null: + return null + var attach: Node = model.skeleton.get_node_or_null("WeaponAttachment") + if attach == null or attach.get_child_count() == 0: + return null + return attach.get_child(0) as Node3D + + +func _probe_rot(bone: int) -> Quaternion: + if _probe == null or bone < 0 or bone >= _probe.pose.size(): + return Quaternion.IDENTITY + var t: Transform3D = _probe.pose[bone] + return t.basis.orthonormalized().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=== WRIST vs GUN ===\nFailures: %d" % _fails) + quit(1 if _fails > 0 else 0) diff --git a/debug/wrist_gun_check.gd.uid b/debug/wrist_gun_check.gd.uid new file mode 100644 index 0000000..6c8cab4 --- /dev/null +++ b/debug/wrist_gun_check.gd.uid @@ -0,0 +1 @@ +uid://cymueywoww4mg diff --git a/docs/3D_ASSET_PIPELINE.md b/docs/3D_ASSET_PIPELINE.md index 4240ba6..2381ff4 100644 --- a/docs/3D_ASSET_PIPELINE.md +++ b/docs/3D_ASSET_PIPELINE.md @@ -1,127 +1,498 @@ -# Character Pipeline: Sketchfab → Rigged → Animated → In-Game +# Character Pipeline: source model → rigged → animated → in-game -One command turns a Sketchfab model into a playable character skin: +One command turns a model into a playable character skin: ```bash python tools/pipeline.py --uid --name space_marine ``` -That downloads the model, auto-rigs it in Blender, merges the shared -animation library onto it, writes `assets/characters/skins/space_marine.glb`, -and registers it in `skins.json`. Restart the game — the skin is in the main -menu dropdown, fully animated in first AND third person, synced in multiplayer. +That downloads the model, moves the shared animation library onto it, writes +`assets/characters/skins/space_marine.glb` plus a `.rig.json` sidecar, verifies +the result, and registers it in `skins.json`. Restart the game — the skin is in +the main menu dropdown, fully animated in first AND third person, synced in +multiplayer. -## Prerequisites (one-time setup) +## The one rule: keep the model's own rig -1. **Sketchfab API token** — from . - Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named - `.sketchfab_token` in the project root (gitignored). -2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`. -3. **Animation library** — already bundled. `assets/characters/animations/_library.glb` - is the CC0 Quaternius Universal Animation Library (13 game-relevant clips - mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to - this library's skeleton (fitting the arm bones to the model's actual pose), - and `merge_animations.py` retargets each clip onto that fitted rest. To use a - different/larger set instead, see "Swapping the animation library" below. +**If a model arrives with a skeleton, that skeleton is what ships.** Its bones, +its artist-painted weights, its separate per-part meshes, and its dedicated +skirt/hair bone chains all survive; only the ANIMATION is moved onto it. + +This is the whole point, and the pipeline used to do the opposite. The old route +(`strip_rig.py` → `autorig.py` → `merge_animations.py`) discarded any foreign +skeleton, joined every mesh into one, and rebound the result by weighting each +vertex to its nearest four bone segments. It did that to work around a *naming* +problem — the retarget matched bones by exact name — and the cost was the entire +asset. Measured on the shipped `taila.glb` against the source it was built from: + +| | source model | old pipeline output | +|---|---|---| +| vertices pulled by BOTH legs | 17 (0.1%) | **2817 (16%)**, worst a dead 50/50 | +| vertices at the full 4 influences | 26% | **86%** | +| meshes (body / cloth / hair separable) | 18 | **1** | +| skirt bone chains | 21 bones | **0** | +| hair bone chains | ~50 bones | **0** | +| limb twist bones | 8 | **0** | + +A vertex pulled equally by both legs sits between them and stays there while +they separate, stretching every triangle around it — that is the squashing and +the "elongated boot". `characters/skin_leg_repair.gd` exists solely to undo this +at load time, by snapping weights and deleting triangles on a mesh it has to +rebuild every spawn. + +The naming problem is now solved properly, in `tools/rig_map.py`, so nothing has +to be thrown away. ## The pipeline, step by step -Each step is also runnable on its own: - | Step | Tool | What it does | |---|---|---| | 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) | -| 2. Download | `python tools/sketchfab_import.py download ` | GLB + license/attribution JSON into `assets/characters/incoming/` | -| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits the skeleton (incl. arm bones) to the mesh, binds smooth nearest-bone weights | -| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Retargets the clip library onto the fitted rest, strips root motion, canonical names | -| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` | +| 2. Download | `python tools/sketchfab_import.py download ` | GLB + license JSON into `assets/characters/incoming/` | +| 3. Retarget | `blender --background --python tools/retarget.py -- in.glb assets/characters/animations out.glb` | Keeps the rig; moves the clip library onto it | +| 3b. *(unrigged only)* | `blender --background --python tools/autorig.py -- in.glb rigged.glb` | Fits the library skeleton and solves weights — lossy, see below | +| 4. Verify | `blender --background --python tools/verify_character.py -- out.glb` | Gates the build on the defects listed below | +| 5. Register | (automatic in `pipeline.py`) | Copies to `skins/`, adds an entry to `skins.json` | -`tools/pipeline.py` chains all of it. Useful flags: +`pipeline.py` chains all of it and picks the path automatically — it reads the +glTF container to see whether a `skins` array is present. Useful flags: -- `--input file.glb` instead of `--uid` for local files (GLB/FBX/OBJ). -- `--rigged` — skip auto-rig for models that already have a skeleton - (Mixamo/AccuRig/Tripo output). Bones get renamed to Mixamo convention - automatically when recognizable. -- `--height 1.6` — target character height in meters. +- `--input file.glb` instead of `--uid` for local files. +- `--rigged` — force the keep-the-rig path (needed for FBX, which cannot be probed). +- `--rebind` — force the lossy path. Last resort. +- `--height 1.6` — target character height in metres. -## Auto-rigging: what to use when +### How the retarget works -Mixamo has **no public API** (and Adobe has said one isn't coming), so full -automation needs an alternative. In order of preference: +`tools/rig_map.py` resolves both skeletons to ROLES and pairs them up, so bone +names never have to match. It works structurally wherever a name would lie: -1. **Built-in autorig (`tools/autorig.py`)** — heuristic skeleton fit + - Blender automatic weights, fully automated, zero cost. Works well for - normal-proportioned upright humanoids. Weakest on flowing - dresses/capes/extreme proportions. -2. **Mixamo web (manual, ~2 min/model)** — upload FBX/OBJ at - , place 7 markers, download rigged FBX "without - animations", then `pipeline.py --input rigged.fbx --rigged --name x`. - Best weight quality for the effort. -3. **Reallusion AccuRig** (free desktop app) — better auto-weights than - Mixamo for tricky meshes; export FBX and feed with `--rigged`. -4. **UniRig / Tripo AI / Meshy / Anything World** — ML auto-riggers. UniRig is - open source (VAST/Tripo); Tripo, Meshy, and Anything World offer paid HTTP - APIs if you ever want a fully hosted rigging step with no Blender at all. +- **Hips** is found as the base of the longest non-limb, non-cosmetic chain — + not by looking for "hips". Rigify calls it `DEF-spine`. +- **The head** is wherever that chain ends. A stock Rigify rig has no bone with + "head" in its name at all; the head is `DEF-spine.006`. +- **Chains of different lengths** are matched by normalised position, so a + 6-bone spine is driven by a 5-bone one. -All roads lead to the same place: a GLB with Mixamo bone names, which is what -`merge_animations.py` and the game expect. +`tools/retarget.py` then bakes each clip as a rest-relative delta — +`R_world = src_pose · src_rest⁻¹`, applied as `R_world · tgt_rest` — rather than +copying absolute world orientation, which would force the library's bone roll +onto a mesh bound with a different one and twist every limb by a constant offset. + +It also **rebuilds parenting**. A Rigify DEF-rig exports its chain roots +parented straight to the armature root (Rigify drives them by constraint, not +hierarchy), so on import the thighs, skirt and hair all hang off the root and +would float in place while the body moves. Orphans are re-attached by anatomy +where it is known and by rest geometry otherwise. **Cloth may only ever attach +to the trunk, never to a limb** — anchor a skirt panel to the nearest bone and +16 of Taila's 21 land on a thigh, where the panel rides one leg like a trouser +leg. + +### What is deliberately NOT driven + +Skirt, hair, twist and face bones get **no animation tracks at all**. They rest +relative to their parents and belong to the runtime instead. That split — clips +animate the body, physics animates the cloth — is what makes clothes read as +clothes, and it is why the exported clips only carry the ~53 bones they need +(`taila.glb` went from 3.5 MB to 2.9 MB even after regaining its textures). + +## Secondary motion (`characters/spring_bones.gd`) + +Skin weights can only ever make a garment a rigid shell of whatever it is +weighted to: weight a skirt to the thighs and it becomes trousers, weight it to +the hips and it becomes a bell that never moves. Neither is cloth. A skirt is +cloth because it LAGS. That is inertia, and it has to be integrated, not skinned. + +Each cloth bone is a damped spring holding its tip toward where rigidly +following its parent would have put it, then pinned to the bone's length and +pushed out of the leg capsules so a skirt swings AROUND a thigh rather than +through it. Bone tip directions and capsule radii are **measured from the +model's own geometry at build time** and stored in the sidecar — a glTF skeleton +carries no bone tails, and Taila's skirt bones have no children either, so +nothing in the skeleton says which way a panel hangs or how thick a thigh is. + +Tuning per class (hair stiffer and lighter, skirt slacker and heavier) lives in +`SpringBones.TUNING`. + +### Collision, and why cloth "never settles" + +Getting the capsules wrong does not look like a collision bug — it looks like +cloth that jitters forever. Four things had to be right: + +1. **A limb tapers.** Taila's thigh is ~0.10 m across at the hip and ~0.055 m + above the knee. Head and tail radii are stored separately and interpolated. +2. **A leg's own vertices are not the leg.** Most of the thigh is weighted to + the TWIST bone; what is left dominated by `DEF-thigh.L` is the hip flare, + which measured a 0.154 m radius — a 30 cm thigh. Twist children are folded + in, and only vertices that clearly belong to the limb (dominant weight > 0.6) + are counted, or a hip/thigh/glute blend puts the flare straight back. +2b. **Measure the taper, do not pick a percentile of a pooled bucket.** Every + single-number answer is wrong at one end: the median leaves half the limb's + surface OUTSIDE its own collider, so cloth pushed out to it is clear of the + capsule while the thigh is visibly through it; a high percentile over- + measures the shaft by 30% because the top bucket is still the hip. Take the + 90th percentile in each of ten bands along the bone, drop the two contaminated + end bands, and fit a line. Taila's thigh: 0.116 m at the hip to 0.063 m above + the knee, against a real surface of 0.106 → 0.058 plus cloth thickness. +3. **The rest pose must be a valid state.** The artist modelled the skirt over + these legs, so a capsule is capped per bone to just inside its own rest + clearance. Without that, bones resting against the thigh were shoved out and + pulled straight back in every frame — measured 13 of 70 cloth bones in + permanent contact in a dead-still idle, a limit cycle that never decayed. + That was the "hair and skirt never settle", and it also left the collider + saturated and useless against real clipping. +4. **Resolve as a rotation, sampled along the bone.** A skirt panel is a sheet + and its bone tip is at the far edge; pushing only the tip out leaves the + middle of the panel inside the thigh — the thigh visibly clipping through the + front of the skirt at a stride. +5. **Carry the whole rotation back, not a tip.** Most of what lifts a wide sheet + off a thigh is rotation about the bone's OWN axis, and a twist moves the tip + not at all. Reading the correction off the corrected tip therefore threw away + the part that mattered: the solver measured 50–70 mm of thigh inside a panel, + corrected it every frame, and the render never changed. +6. **Apply the drape to the bone, not to the spring's target.** The final + rotation is measured FROM the drape target and applied TO the undraped basis, + so rotating only the target cancelled out exactly at equilibrium and the + drape was a silent no-op. This is why raising the drape weight — even to 0.99 + — never moved a panel off a thigh, and it sent three rounds hunting the + collision solver for a fault that was never there. In a chain, apply each + link's share as the DIFFERENCE from what its parent already carries, or three + segments at 0.45 compound to 1.35 of the thigh's swing at the hem. +7. **Fix the deepest contact, not the one wanting the largest angle.** The angle + is depth ÷ leverage, so a graze on a hull point sitting almost on the bone's + head outbids a 60 mm impalement further down and spends the pass rotating + about a point that barely moves. Cap the turn so it never throws the rest of + the panel further than the overlap being fixed, or a 5 mm contact swings a + panel 34°. +8. **Take the leg out of the REST TARGET too.** While the spring's target sits + inside a thigh, the collision pushes out and the spring hauls straight back + forever. Resolving the target first makes resting on a leg an equilibrium. + +### The drape has to RIDE the limb, not copy its angle + +This was the one that mattered, and it hid behind every other theory for a long +time. The drape used to take the thigh's ROTATION and apply it to the panel's +basis — which turns the panel about ITS OWN head, up at the waist. The thigh +turns about the HIP, some 20 cm lower. The two arcs are nothing alike, so the +panel never tracked the leg however high the drape weight went, and the thigh +walked straight out through the front of the skirt. + +Carry the bone's rest tip through the limb's FULL transform instead — pivot +included — and aim the bone at the result: + +``` +carried = L_now * L_rest⁻¹ * tip_rest # where the limb would take it +aim = slerp(current_dir, carried_dir, w) # w = how much of it rests there +``` + +That is what "the cloth rides the leg" actually means, and it is the difference +between a panel that gets overtaken and one that lifts over the thigh. + +Two supporting pieces, both needed: + +- **Which limb, decided per frame.** A static both-thigh split by rest distance + is right for a panel hanging between the legs and wrong the moment they + scissor — the halves cancel, the panel does not move, and the advancing thigh + walks into it. `DRAPE_BITE` keeps a limb's share climbing once its gap goes + negative, so the leg actually inside the cloth wins outright. +- **The correction walks UP the chain** (`_lift_chains`). A bone rotates about + its own head, so it can move a point by at most twice that point's distance + from the head — and the front panels' contacts sit 10-48 mm from their pivot + against a thigh ~100 mm inside them. Their ancestors have 5-15x the lever, so + a few degrees up the chain does what no local rotation could. Keep the per-bone + cap small (4°): at 9° over 3 passes the whole panel hiked up. + +Tuning that matters: `DRAPE_MAX` 0.55. At 0.9 the panel over-swings and hikes; +at 0.0 the thigh passes straight through. Panels subdivide into 4 segments — 3 +leaves the pivots too far from the contacts, 6 lets the chain curl up. + +### What a production cloth setup actually does + +Everything above was arrived at by measurement, and it converged on a solver that +was still missing the single most important piece. From Magica Cloth 2's BoneCloth +skirt guide (the Unity asset most anime-style games use for exactly this garment): + +> Normally, the skirt bones are linked vertically due to the parent-child +> structure, **but not horizontally** … If horizontal bones are not connected, the +> accuracy of collision detection will be significantly reduced. **This is the +> most important work when expressing a skirt with BoneCloth.** + +That was the gap. Every chain here solved alone, so each panel individually +satisfied its constraints while the garment came apart. Four things were taken +from that guide and its penetration page: + +1. **Horizontal links between chains** (`LINK_PASSES`, `_build_links`). Distance + constraints between the TIPS of bones that share mesh vertices, relaxed + Gauss-Seidel after the springs and collision — Magica's "Near Point" + connection. Adjacency comes from the sidecar's `neighbours`, which is shared + vertex weight: the artist's answer to what is sewn to what, not a guess from + names or rest distance. 215 links on Taila. +2. **A lid across the waist.** "Put one big sphere collider on your waist… it acts + as a lid that prevents particles in the skirt from slipping into the body." + There was no torso collider at all — leg capsules stop cloth going through a + thigh, but nothing stopped a panel swinging INWARD into the pelvis, which is + where several of the worst contacts sat. Marked `lid` in the sidecar so the + drape does not treat it as a limb to be carried by, and sized from non-cloth + geometry (including the garment measured the skirt itself, 0.24 m). +3. **A graded bend clamp.** "The start point can be bent up to 20 degrees and the + tip up to 50." A flat limit either lets the waist collapse or stops the hem + moving; `MAX_SWING_ROOT`/`MAX_SWING_TIP` interpolate along each chain. + Collision still overrides it, which is Magica's precedence too. +4. **Collision is not enough on its own** — "if you make a vigorous movement, you + will inevitably penetrate" — which is why the drape and the bodily shift exist + alongside it rather than instead of it. Magica's two extra modes are Surface + Penetration (needs the cloth weighted to the main bones) and Collider + Penetration (for cloth that does not follow the leg animation). This skirt is + deliberately the second kind, since any leg weighting on it tears. + +### The skirt "breaking" instead of stretching + +A separate failure from clipping, and it looks like clipping: the front of the +skirt is pushed aside and then splits, and the thigh shows through the split. +Measure it with `debug/cloth_stretch_check.gd`, which skins every cloth triangle +and compares each edge against its own rest length — a bone-level or capsule- +level number cannot see it, because every individual bone is behaving. + +It was the WEIGHTS. A band of skirt vertices carried `DEF-thigh.L` at 0.24 while +the vertices next to them carried none, so when the thigh swung one followed it +and its neighbour did not. Measured during a slide: 80 mm apart, 3.3x rest +length. 0.24 of the thigh's ~0.35 m of travel is 84 mm, so that discontinuity +was the whole of it. + +**Cloth is not skinned to the legs at all** — `tools/retarget.py:: +unbind_cloth_from_legs`. Smoothing the field instead of deleting it does not +work, and the arithmetic says why: differential motion is the weight GRADIENT +times how far the limb travels, so holding an edge under 10 mm against a thigh +that moves 350 mm needs under 0.03 of weight difference across it — and this +skirt's edges are ~48 mm long, so the falloff would have to span most of the +character. Tried: a 1.7-radius falloff put 0.24 on one vertex and 0.00 on its +neighbour and tore by 95 mm. After stripping, the worst cloth edge in the whole +sweep grows **0.0 mm**. + +There was also a `bind_cloth_to_legs()` that did this deliberately, on the +(wrong) conclusion that clipping was a weights problem. It is gone for the same +reason, plus two of its own: 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; and it +poisoned the collider measurement, fitting a 0.28 m thigh out of 2258 skirt +vertices. + +Keeping the leg out of the weights costs the one thing the weights were doing, +so the solver has to make it up: + +- **The capsule starts 30% down the thigh.** The top of a thigh is hip, not limb. + Cloth points there sit 20-30 mm from their own bone's head, and a rotation + moves a point near its pivot by at most twice the lever — 48 mm against 86 mm + of overlap. The solver spent all six passes saturated at its cap and still left + 60-90 mm. +- **The drape picks its limb per frame, by current proximity.** Splitting a panel + between both thighs by REST distance is right for a panel hanging between them + and wrong the moment they scissor: the two opposite swings cancel, the panel + does not move, and the advancing thigh walks into it. +- **Chains are shifted bodily as well as turned** (`PUSH_MAX`). Translation is + the degree of freedom rotation lacks, and it is safe here precisely because the + whole chain moves as one piece — unlike a weight gradient it cannot stretch the + mesh, and neighbouring panels see the same contact and shift the same way, so + the seams between them stay shut. +- **Neighbouring cloth bones are known to the runtime.** `neighbours` in the + sidecar means SHARED VERTICES — the artist's own answer to which pieces are + sewn together — and the drape is relaxed across them so sewn panels cannot be + handed wildly different shares. + +On the shipped materials the skirt now reads as one continuous sheet with an +unbroken hem through the whole sweep, and the thighs emerge from below it. + +Two measurement traps found while confirming that, both of which produced +"remaining clipping" that was not there: + +- **A silhouette test cannot see clipping.** When the legs are apart you see the + FAR side of the skirt through the gap between them, and the thighs are + legitimately in front of that. `tools/measure_clipview.py` counts those as + hits. It is a good continuity check — a tear is a hole in the cloth mask — and + a bad clipping check. Judge clipping from `debug/skirt_probe.gd` and the plain + render. +- **The torso is in front of the waistband, and that is correct.** Counting all + body pixels reported 5% of the cloth covered on a pose that is clean, so the + measurement is cut at the hip joint. Colouring the legs separately does not + work either: the bare thigh is part of the body mesh, and a per-surface colour + caught only the boots. + +Together those took idle penetration from 65 mm to ~1 mm and contacts from ~10 +per frame to ~2. Damping then matters: 0.3 rang for about three visible +oscillations after every step, so it sits near 0.6. The lag that makes a skirt +read as cloth comes from inertia, not from low damping, so this costs nothing in +the run cycle. Gravity is small for the same reason — a constant force offsets +the resting tip by `g/w²`, and the 5.0 first used pulled the hem 41 mm below +where it was modelled, into the thigh it then had to be pushed out of. + +## Locomotion transitions + +Two separate things change when a character starts running, and they have to +arrive together: + +- **The clip**, crossfaded by `AnimationNodeTransition` over `BLEND_TIMES` + (~0.4 s for locomotion). Tier selection has hysteresis and a minimum dwell: + without them, hard acceleration crossed Idle → Walk → Run in under a second + and each crossfade cut off the one before it, giving Walk 0.19 s of a 0.40 s + blend. With a dwell, a hard start now goes Idle → Run in one step. +- **The procedural lean** (`ShooterPoseModifier`), which is NOT part of the + blend graph. The controller passes a normalised input direction, so it stepped + 0 → 1 the instant a key went down and planted a full run posture in about a + tenth of a second — the body snapping forward ahead of the run cycle. It is + now scaled by actual speed and smoothed on its own slower rate + (`LEAN_SMOOTH`), so it grows as the character accelerates. + +Measure with `debug/transition_check.gd`: it reports clip changes, when the lean +reaches 10% and 90%, and the worst single-frame change. + +### `.rig.json` + +Written next to every built GLB, so the runtime never re-guesses anatomy: + +- `roles` — resolved bone per role. `ShooterPoseModifier` reads this; without it + its hips/neck/head lookups silently missed on any rig that names them + differently, and every lean and aim-pitch did nothing. +- `chains` — cloth/hair chains, with a measured tip vector per bone. +- `colliders` — leg capsules with measured radii. +- `twist` — limb twist bones. +- `weights_authored` — **measured**, not inferred from which branch ran. This is + what decides whether `SkinLegRepair` runs at load time. A model that arrives + unrigged still gets a sidecar, and its solved weights still need the repair. + +Raw `.glb` **and `.json`** must be in the export include filter, or the sidecar +is missing from a build and every character loses its cloth. + +## When a model has no skeleton + +Then there is no authored weighting to keep and `autorig.py` fits the library +skeleton with nearest-bone weights. This is genuinely lossy and the verifier +reports it as warnings rather than failures, because no better result is +available: + +``` +[WARN] cross-leg blending is limited to draping cloth — 590 verts (21.2%) +[WARN] influences look authored, not solved — 78% of verts carry 4 influences +[WARN] model keeps its per-part meshes — 1 meshes +``` + +`miku` is such a model. `SkinLegRepair` stays on for it at load time. + +Prefer, in order: a model that ships rigged → **Mixamo web** (upload FBX/OBJ, +place 7 markers, download rigged "without animations") → **Reallusion AccuRig** +→ UniRig / Tripo / Meshy. All of them produce a rig this pipeline will keep. + +## Materials + +Anime models 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. The game shades characters with its own +toon material off ALBEDO anyway. ## Swapping the animation library -`merge_animations.py` supports two library layouts in -`assets/characters/animations/`: +`assets/characters/animations/_library.glb` is the CC0 Quaternius Universal +Animation Library. Clips map through `LIBRARY_CLIP_MAP` in `tools/retarget.py` +(18 mapped: Idle, Walk, Run, Sprint, Jump, Fall, Land, CrouchIdle, CrouchWalk, +Dash, Death, Hit, Dance, Grapple, PistolIdle/Shoot/Reload, Throw). -**A. Multi-clip library GLB (the bundled default).** If `_library.glb` exists, -its clips are merged and mapped through `LIBRARY_CLIP_MAP` in -`tools/merge_animations.py`. The bundled file is the CC0 Quaternius Universal -Animation Library (13 clips: Idle, Walk, Run, Sprint, Jump, Fall, Land, -CrouchIdle, CrouchWalk, Dash, Death, Hit, Dance). **Important:** `autorig.py` -rigs characters to *this GLB's own skeleton*, so if you replace `_library.glb` -you must re-rig characters against the new one (the pipeline does this -automatically). To pull in more of Quaternius's 120-clip set, drop in the Pro -`_library.glb` and add rows to `LIBRARY_CLIP_MAP`. +Characters no longer have to be rigged to the library's skeleton, so **replacing +`_library.glb` does not require re-rigging anything** — just rebuild the skins. +Missing clips are fine: the game falls back along sensible chains +(`Slide → CrouchIdle → Idle`, `WallRun → Run` — see `CLIP_FALLBACKS` in +`characters/skinned_player_model.gd`). -**B. Loose one-clip-per-file (Mixamo).** Delete `_library.glb` and instead add -FBX/GLB files named by clip (`idle.fbx` → `Idle`, `crouch_walk.fbx` → -`CrouchWalk`). Download from mixamo.com as *FBX Binary, Without Skin, 30 fps* -("In Place" variants). In this mode characters must be on the Mixamo skeleton -(rig via Mixamo web / AccuRig and pass `--rigged`, or let the heuristic -`build_mixamo_armature` fit one). +## Measuring, not eyeballing -Missing clips are fine either way: the game falls back along sensible chains -(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see `CLIP_FALLBACKS` in -`characters/skinned_player_model.gd`). A model with just Idle/Walk/Run still -animates in every movement state. +- `blender --background --python tools/verify_character.py -- ` — the build + gate. Every check corresponds to a defect this project actually shipped. +- `godot --headless --path . -s res://debug/limb_deform_check.gd -- ` — + skins the mesh itself and reports lengthwise stretch and cross-section loss + against the skeleton's REST pose, so an unposed model reads exactly 1.00 and a + wrong metric is visible immediately. Read its header before trusting a number + you add: three earlier versions of this measurement were themselves wrong and + nearly caused bad "fixes". +- `godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- ` + — renders every movement state front and side. +- `godot --headless --path . -s res://debug/cloth_settle_check.gd -- ` — does + the cloth actually come to rest? Reports deviation, per-frame motion and, most + usefully, which bones are penetrating a leg capsule and by how much. A steady + non-zero contact count is a limit cycle that no amount of damping will fix. +- `godot --headless --path . -s res://debug/transition_check.gd -- ` — clip + changes and lean ramp when accelerating from a standstill. +- `godot --path . --windowed --resolution 900x900 -s res://debug/skirt_clip_view.gd -- ` + — **the tool that settles "is the leg through the skirt?"**. Paints every cloth + surface flat magenta and the body flat grey across a movement sweep, so grey + inside the magenta is the leg in front of the cloth and grey outside it is just + the leg past the hem. Those two look identical on the shipped materials and + were guessed at, in both directions, for several rounds. It also saves a + cloth-only frame, which separates clipping from a gap opening between panels. +- `godot --headless --path . -s res://debug/skirt_probe.gd` — per cloth bone, how + deep the leg is inside it and whether the solver can SEE that depth (the + rest-clearance allowance can hide it). Also reports the lever the solver has on + the point, and its height above the hip joint — anything positive is inside the + fictional sphere the capsule puts at the top of the thigh, not inside the leg. +- `godot --headless --path . -s res://debug/cloth_stretch_check.gd` — **the tool + for "the skirt breaks instead of stretching"**. Skins every cloth triangle over + a movement sweep and compares each edge against its own rest length, reporting + seams between panels separately from edges inside one. Nothing at bone or + capsule level can see a tear, because each bone individually is fine. Restrict + it to genuinely cloth-owned vertices — 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 moved. +- `godot --headless --path . -s res://debug/leg_radius_check.gd` — the real + per-band radius of each limb next to the capsule actually shipped. +- `godot --path . --windowed --resolution 900x900 -s res://debug/idle_jitter_check.gd -- ` + — consecutive frames of a still idle. Counting changed PIXELS between them is + the only trustworthy settling measure: `cloth_settle_check` reports LOCAL bone + rotation, and a parent's correction shows up as an equal and opposite delta on + each of its segments, so a hem that has not moved on screen can read 18 + deg/frame. Measured here: 24866 changed px/frame with collision on against + 38594 with it off — the collision was damping the idle, not driving it. + +Current Taila, worst over a run/walk/jump/fall/slide/dash sweep: knee +cross-section 0.85–0.86, everything else 0.89–1.00, worst stretch 1.16. ## How it works in-game -- **`SkinManager` (autoload)** reads `assets/characters/skins/skins.json` at - boot. Selected skin persists per-user and is synced to other players via - `synced_skin_id`. -- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip - names, sets loop modes, blends between clips (0.15 s), and scales - locomotion playback speed to actual movement speed. -- **First person (owner):** the model renders shadows-only for the owner (the - camera sits inside the head, so drawing the mesh would show its inside). It's - still fully animated and casts a real shadow; other players see the full body. -- **Third person:** press **V** (`toggle_camera_view`) to swap to an - over-the-shoulder `SpringArm3D` camera and reveal your own animated model — - the easiest way to eyeball that a new skin's animations look right. Firing - still uses the first-person camera, so aim is unchanged. Press V again to - return to first person. -- **Other players** always see the full third-person model, driven by the - synced movement state, with the current weapon attached to the hand bone. -- **Licensing:** every Sketchfab download writes `.license.json`. - CC-BY models require crediting the author — keep these files and surface - them in your credits screen before shipping. +- **`SkinManager`** (autoload) reads `skins.json` at boot; the selection persists + per user and syncs via `synced_skin_id`. +- **`SkinnedPlayerModel`** loads the GLB, reads the sidecar, installs + `ShooterPoseModifier` (lean / ADS / slide / wall-run / weapon hold) then + `SpringBones` — in that order, so the springs react to the FINAL body pose. +- **`SkinJointHelper`** runs for every model regardless of rig. It is not a + weight repair: linear-blend skinning collapses any joint by cos(θ/2) however + good the weights are. It subdivides the knee through helper bones. It resolves + the joint's parent from the SKELETON — hardcoding `DEF-thigh.L` meant it + silently did nothing on a rig with twist bones, and the knee measured 0.76 + instead of 0.85. +- **First person (owner):** the model renders shadows-only (the camera sits + inside the head). Press **V** to swap to the over-the-shoulder camera. +- **Licensing:** every Sketchfab download writes `.license.json`. CC-BY + models require crediting the author — surface these in a credits screen. ## Troubleshooting -- *Model T-poses in game* — the GLB has no animations; re-run - `merge_animations.py` and check it printed `Merged N clips`. -- *Mesh deforms badly at shoulders/hips* — heuristic rig didn't fit; rig via - Mixamo web or AccuRig and re-run with `--rigged`. -- *Character slides while walking* — clips exported with root motion; re-run - without `--keep-root-motion` (stripping is the default). -- *Skin missing in exported build* — raw `.glb` files must be included in the - export: Project → Export → Resources → include filter `*.glb, *.json`. +- *Character is a black silhouette* — unlit materials; see **Materials**. Check + the built GLB actually has images. +- *Model T-poses* — the GLB has no animations, or the retarget produced a frozen + rest pose. `verify_character.py` catches both. +- *Skirt rides one leg* — a cloth bone got parented to a limb. Cloth must anchor + to the trunk only. +- *Cloth is rigid* — the sidecar is missing (check the export filter) or the + clips are keying cloth bones (`verify_character.py` checks this). +- *Cloth flies off the model* — a spring instability. `SpringBones` substeps and + clamps for exactly this; do not remove those guards. +- *Cloth jitters and never settles* — almost certainly a capsule the rest pose is + already inside, not the damping. Run `cloth_settle_check.gd` and look at the + contact count before touching `TUNING`. +- *A limb clips through cloth* — check the measured capsule in the sidecar is a + believable size for that limb, and remember the collider only knows about the + legs. +- *Limbs squash at a stride* — measure with `limb_deform_check.gd` before + changing anything. Renders are repeatedly misleading; a slim anime leg at full + stride genuinely looks stretched. diff --git a/export_presets.cfg b/export_presets.cfg index f0a77bb..8a06545 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -6,7 +6,12 @@ runnable=true dedicated_server=false custom_features="" export_filter="all_resources" -include_filter="" +; Character skins are loaded as RAW files at runtime (GLBLoader reads the bytes +; and parses them with GLTFDocument), and each one needs its .rig.json sidecar +; for cloth chains, leg colliders and bone roles. Neither is a Godot resource, +; so without this filter an exported build ships characters with no secondary +; motion at all — or no character. +include_filter="*.glb, *.json" exclude_filter="" export_path="./Papaya-Shooter.exe" patches=PackedStringArray() diff --git a/movement/player_movement_controller.gd b/movement/player_movement_controller.gd index 64d7119..4ec6f30 100644 --- a/movement/player_movement_controller.gd +++ b/movement/player_movement_controller.gd @@ -225,6 +225,29 @@ func set_third_person(on: bool) -> void: if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer): wman.canvas_layer.visible = not on +## Swap this player's character model, live. +## +## The only supported way in from outside — the escape menu's character picker +## calls this. It exists because the two halves of a skin change are easy to do +## by halves: `synced_skin_id` is what REMOTE peers rebuild from, and they only +## check it because their `_process` watches for it to change, which the +## authority's does not. Setting the property alone would change every other +## player's view of you and not your own. +## +## Rebuilding drops the old model and its third-person reveal with it, so that +## is re-applied here too; otherwise picking a new character while in third +## person leaves you looking at a shadow. +func set_skin(skin_id: String) -> void: + if skin_id == "" or skin_id == _applied_skin_id: + return + synced_skin_id = skin_id + _apply_skin_model(skin_id) + if third_person: + var visual := get_visual_model() + if visual and visual.has_method("set_owner_visible"): + visual.set_owner_visible(true) + + ## Returns the node that visually represents this player (skinned GLB model ## if the active skin has one, otherwise the procedural HumanoidModel). func get_visual_model() -> Node3D: diff --git a/scenes/maps/level_materials.gd b/scenes/maps/level_materials.gd index 5d8d37e..3f0dc13 100644 --- a/scenes/maps/level_materials.gd +++ b/scenes/maps/level_materials.gd @@ -87,7 +87,7 @@ static func toonify(src: Material) -> Material: mat.set_shader_parameter("albedo_color", col) # Fully matte characters: NO specular (even a 2% stepped glint reads as # shine sweeping across hair when the camera moves) and only a whisper - # of rim for silhouette separation. Cel banding carries all the shape. + # of rim for silhouette separation. mat.set_shader_parameter("rim_strength", 0.05) mat.set_shader_parameter("rim_width", 0.28) mat.set_shader_parameter("specular_strength", 0.0) @@ -95,10 +95,171 @@ static func toonify(src: Material) -> Material: return mat +## Ink used for the line-work an imported character carries in its own mesh. +const CHARACTER_INK := Color(0.07, 0.06, 0.09) + + +## Second pass for IMPORTED CHARACTER models (the anime GLB skins), run right +## after apply_toon_recursive. Two things those models need that props don't: +## +## 1. Their line-work is part of the mesh, as extra UNTEXTURED surfaces, and it +## splits into two kinds that need opposite treatment: +## +## * The body/hair OUTLINE HULL (Taila's "FullBlack" and "material") is a +## duplicated shell. Its skin weights do not track the base mesh through a +## deep bend, so during a run it tears into spikes and stretches sheets +## between the ankles — that is what made the ankle cuffs look welded +## together. It is also redundant: characters already get an inverted-hull +## overlay from apply_toon_recursive AND the screen-space ink_edge pass. +## So it is HIDDEN outright, which removes the artefact and the redundancy +## in one go. +## +## * The EYE cards ("EyesFullBlack" lashes, "EyesInvL", "EyesHL" highlight) +## are real facial features, not a hull, and they are kept — flat, because +## the glTF import hands every untextured surface a default near-white +## albedo and toon-LIGHTING the black ones was the thin white rim that used +## to trace every hair strand. +## +## 2. Their textures are ALREADY painted with cel shading. Stacking the hard +## 3-tone break on top read as gloss — a bright stripe sliding across the +## hair as the camera moved. Characters get a soft terminator and an +## almost-invisible second step so the painted shading carries the form. +## +## Props and level geometry keep the crisp banding they were calibrated with. +## Is this untextured surface part of the model's own DRAWING, or is it just an +## untextured surface? +## +## "No albedo texture" alone is not the question, and answering it that way made +## every flat-coloured model render as a black silhouette — Quaternius' mannequin +## has two untextured materials, a yellow body and lilac joints, and both were +## being hidden as though they were an outline shell. +## +## What actually distinguishes line-work: +## +## DARK an ink shell or a lash card is black or nearly so. A flat-coloured +## character is any colour at all. This is the discriminator that +## does the work. +## INVERTED the classic inverted-hull outline is drawn front-face-culled so +## only its backfaces show. Nothing else on a character is. +## NAMED eye cards say so — they are kept, not hidden, and need to reach +## the branch below whatever colour they are. +## +## That heuristic now lives in `SkinSurfaces.guess`, where it is the FALLBACK +## rather than the only answer — see below. + + +## Shadow tint measured off the Sketchfab reference render: sampling Taila's +## hair there, shadow/midtone lands near (0.63, 0.53, 0.70). Green drops +## hardest, and that is what keeps copper hair COPPER in shadow — the level +## default (0.62, 0.65, 0.78) lifts green above red and washes ginger toward a +## dull brown. +const CHARACTER_SHADOW := Color(0.64, 0.56, 0.72) + +## Per-class art direction. This is what the surface table BUYS: until a +## character could say which of its surfaces were hair and which were a jacket, +## every one of them had to take the same numbers, and those numbers were +## calibrated on skin. +## +## outline how thick the inked silhouette is, in metres of normal offset +## band terminator softness — 0 is a hard cel break, 1 is a smooth ramp +## mid_tone where the second, subtler step sits +## rim backlight strength, for separation from the background +## +## `body` is deliberately identical to what every surface used to get, so the +## look this was calibrated against does not move. The others are departures +## from it, and each one is a departure for a reason: +## +## hair THE thinnest line, and it is not a small difference. A hair mesh +## is dozens of near-parallel strands a few millimetres apart; at +## the body's 5 mm every strand's hull swallows its neighbour and +## the whole head reads as one solid dark cap instead of hair. +## Softest banding too — the painted texture already carries the +## form, and a hard break on top of it slides across as gloss. +## cloth a heavier line and a crisper break. A garment's silhouette is +## most of what separates a character from the background at range, +## and folds need a defined terminator to read as fabric rather +## than as a painted-on costume. +## accessory heaviest and crispest, plus real rim. These are small, rigid and +## usually the most saturated thing on the character; they are +## supposed to pop. +const CHARACTER_LOOK := { + SkinSurfaces.BODY: {"outline": 0.0050, "band": 0.16, "mid_tone": 0.92, "rim": 0.05}, + SkinSurfaces.CLOTH: {"outline": 0.0058, "band": 0.13, "mid_tone": 0.90, "rim": 0.06}, + SkinSurfaces.HAIR: {"outline": 0.0034, "band": 0.20, "mid_tone": 0.94, "rim": 0.04}, + SkinSurfaces.ACCESSORY: {"outline": 0.0068, "band": 0.10, "mid_tone": 0.88, "rim": 0.10}, +} + + +## Second pass for IMPORTED CHARACTER models, run right after +## apply_toon_recursive. `surfaces` is the build-time surface table from the +## rig sidecar; pass null and every surface falls back to the heuristic. +static func apply_character_look(root: Node, surfaces: SkinSurfaces = null) -> void: + for mi in root.find_children("*", "MeshInstance3D", true, false): + if not mi.mesh: + continue + # The outline moves from the instance to the individual surfaces, which + # is the only way it can differ between them — and it has to, because + # 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. + mi.material_overlay = null + for s in range(mi.mesh.get_surface_count()): + var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D + if src == null: + continue + var resolved: Array = SkinSurfaces.guess(src) if surfaces == null \ + else surfaces.resolve(mi.name, s, src) + var surface_class: String = resolved[0] + var detail: String = resolved[1] + + if detail == "outline_hull": + # A duplicated ink shell. Its skin weights do not track the base + # mesh through a deep bend, so during a run it tears into spikes + # and stretches sheets between the ankles — that is what made the + # ankle cuffs look welded together. It is also redundant: the + # per-surface outline below and the screen-space ink_edge pass + # both already draw one. So it is hidden, fully transparent + # rather than deleted, so the surface indices — and therefore the + # mesh's own skin bindings — stay exactly as imported. + var hidden := StandardMaterial3D.new() + hidden.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + hidden.cull_mode = src.cull_mode + hidden.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + hidden.albedo_color = Color(0, 0, 0, 0) + mi.set_surface_override_material(s, hidden) + continue + + if detail == "eyes_ink" or detail == "eyes_highlight": + # A lash or iris card: a real facial feature, kept — and kept + # FLAT, because the glTF import hands every untextured surface a + # default near-white albedo, and toon-lighting the black ones was + # the thin white rim that used to trace every hair strand. + var card := StandardMaterial3D.new() + card.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + card.cull_mode = src.cull_mode + card.albedo_color = Color.WHITE if detail == "eyes_highlight" \ + else CHARACTER_INK + mi.set_surface_override_material(s, card) + continue + + var toon: ShaderMaterial = mi.get_surface_override_material(s) as ShaderMaterial + if toon == null: + continue + var look: Dictionary = CHARACTER_LOOK.get( + surface_class, CHARACTER_LOOK[SkinSurfaces.BODY]) + toon.set_shader_parameter("band_softness", look["band"]) + toon.set_shader_parameter("mid_tone", look["mid_tone"]) + toon.set_shader_parameter("rim_strength", look["rim"]) + toon.set_shader_parameter("shadow_color", CHARACTER_SHADOW) + # Eye irises and highlights are flat art on a curved ball; an ink + # line around them reads as a second pupil. + toon.next_pass = null if detail == "eyes" \ + else outline(look["outline"]) + + ## Swap every mesh surface under `node` to toon shading and add an ## inverted-hull outline overlay. Safe on skinned meshes (material_overlay ## re-renders the same deformed mesh). -static func apply_toon_recursive(node: Node, outline_width: float = 0.015) -> void: +static func apply_toon_recursive(node: Node, outline_width: float = 0.005) -> void: if node is MeshInstance3D: var mi := node as MeshInstance3D var surface_count: int = mi.mesh.get_surface_count() if mi.mesh else 0 @@ -112,7 +273,7 @@ static func apply_toon_recursive(node: Node, outline_width: float = 0.015) -> vo apply_toon_recursive(child, outline_width) -static func outline(width: float = 0.015) -> ShaderMaterial: +static func outline(width: float = 0.005) -> ShaderMaterial: var key := "%.4f" % width if _outline_cache.has(key): return _outline_cache[key] diff --git a/scenes/maps/neon_alley/neon_alley_builder.gd b/scenes/maps/neon_alley/neon_alley_builder.gd index 64b15ff..66218e2 100644 --- a/scenes/maps/neon_alley/neon_alley_builder.gd +++ b/scenes/maps/neon_alley/neon_alley_builder.gd @@ -115,7 +115,7 @@ func _hero(key: String, pos: Vector3, yaw_deg: float, col_size: Vector3, body.add_child(shape) var inst: Node3D = _kit_scene_at("res://assets/props/hero/" + key + ".glb").instantiate() body.add_child(inst) - LevelMaterials.apply_toon_recursive(inst, 0.015) + LevelMaterials.apply_toon_recursive(inst, 0.005) ## Small visual-only kit prop (parasols, awnings): no collider needed. @@ -125,7 +125,7 @@ func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> voi add_child(inst) inst.global_position = pos inst.rotation_degrees.y = yaw_deg - LevelMaterials.apply_toon_recursive(inst, 0.02) + LevelMaterials.apply_toon_recursive(inst, 0.007) ## Place a kit model with a box collider matching its scaled bounds. @@ -249,7 +249,7 @@ func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String = for mi in body.find_children("*", "MeshInstance3D", false, false): mi.mesh.surface_set_material(0, LevelMaterials.flat(color)) if maxf(size.x, maxf(size.y, size.z)) < 10.0: - var w := clampf(maxf(size.x, maxf(size.y, size.z)) * 0.008, 0.02, 0.05) + var w := clampf(maxf(size.x, maxf(size.y, size.z)) * 0.003, 0.007, 0.018) mi.material_overlay = LevelMaterials.outline(w) return body @@ -707,7 +707,7 @@ func _build_rail_line() -> void: fb.add_child(fb_shape) var fb_inst: Node3D = _kit_scene_at("res://assets/props/hero/footbridge.glb").instantiate() fb.add_child(fb_inst) - LevelMaterials.apply_toon_recursive(fb_inst, 0.015) + LevelMaterials.apply_toon_recursive(fb_inst, 0.005) # Walkable stair ramps at both ends (over the stair visuals) for e in [-1.0, 1.0]: var ang := rad_to_deg(atan2(5.5, 6.4)) diff --git a/tools/cloth_bones.py b/tools/cloth_bones.py new file mode 100644 index 0000000..e306364 --- /dev/null +++ b/tools/cloth_bones.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +""" +Grow skirt and hair BONE CHAINS on a model that shipped without any. + +A character whose source arrived unrigged goes through tools/autorig.py, which +fits a body skeleton and weights everything to it. The body then animates +correctly and the costume does not move at all: hair is welded to the skull and a +skirt is welded to the hips, because there is nothing there to move them. Miku +ships exactly like this — 0 cloth chains against Taila's 35 — so her twin tails +hang off her head like a helmet. + +Nothing downstream can fix that. characters/spring_bones.gd simulates cloth +BONES, and if the garment has none there is nothing to simulate. This builds +them, which is the job a technical artist does by hand on a model like this. + +The chains it grows are the same shape the Hoyoverse-class rigs use, because that +shape is dictated by what the geometry is: + + HAIR one chain per strand, found as connected islands of hair geometry, + each running from the scalp down the strand's own curve. + SKIRT a radial grid — the garment is split into panels around the body's up + axis and each panel gets a chain from the waistband to the hem, which + is what lets a skirt open around a leg instead of swinging as a bell. + +Vertices are then re-weighted onto the new chain with a falloff that keeps the +anchored end (scalp, waistband) on the body, so nothing detaches. + +Usage: + blender --background --python tools/cloth_bones.py -- \ + [--hair-segments 3] [--skirt-segments 4] + [--skirt-panels 12] [--classes hair,skirt] + +Run this BEFORE tools/retarget.py. retarget.py's describe_rig() then finds the +chains by name exactly as it would an artist's, and writes them to the sidecar. +""" +import bpy +import bmesh +import json +import math +import os +import sys +from collections import defaultdict, deque +from mathutils import Matrix, Vector + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gltf_fix +import rig_map +from rig_map import RigRoles + +argv = sys.argv +argv = argv[argv.index("--") + 1:] if "--" in argv else [] +if len(argv) < 2: + print(__doc__) + sys.exit(1) + +SOURCE, OUTPUT = argv[0], argv[1] + + +def _opt(flag, default): + return type(default)(argv[argv.index(flag) + 1]) if flag in argv else default + + +HAIR_SEGMENTS = _opt("--hair-segments", 3) +SKIRT_SEGMENTS = _opt("--skirt-segments", 4) +SKIRT_PANELS = _opt("--skirt-panels", 12) +CLASSES = _opt("--classes", "hair,skirt").split(",") + +# A strand shorter than this is a fringe or an ornament, not something that +# should swing. Simulating them costs the same as simulating a ponytail and only +# ever produces jitter around the face. +MIN_STRAND_LENGTH = 0.06 +MIN_STRAND_VERTS = 12 +# How much of the anchored end stays welded to the body. A strand's first +# fraction blends from "entirely the body bone" to "entirely the chain", so the +# scalp and the waistband never separate from the character. +ROOT_BLEND = 0.22 + + +def clear_scene(): + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def find_rig(): + arms = [o for o in bpy.data.objects if o.type == "ARMATURE"] + if not arms: + print("ERROR: no armature — run the auto-rig first") + sys.exit(1) + arm = max(arms, key=lambda a: len(a.data.bones)) + meshes = [o for o in bpy.data.objects if o.type == "MESH" + and any(m.type == "ARMATURE" and m.object == arm for m in o.modifiers)] + if not meshes: + meshes = [o for o in bpy.data.objects if o.type == "MESH"] + return arm, meshes + + +def cosmetic_slots(mesh, wanted): + """Material slot indices whose name says what that geometry IS. + + The artist already answered this. A slot called "hair" is hair; guessing from + position or from how far something hangs would be a guess, and on a joined + mesh — which is what the auto-rig leaves behind — the material slot is the + ONLY separation left. + """ + out = {} + for i, slot in enumerate(mesh.material_slots): + name = (slot.name or "").lower() + for cls in wanted: + if cls in name: + out[i] = cls + return out + + +def islands(mesh, vert_ids): + """Connected components of `vert_ids`, over the mesh's own edges. + + A strand of hair is a connected piece of surface. Clustering by position + instead would merge two ponytails that pass near each other and split a + single one that bends. + """ + adj = defaultdict(list) + keep = set(vert_ids) + for e in mesh.data.edges: + a, b = e.vertices + if a in keep and b in keep: + adj[a].append(b) + adj[b].append(a) + seen, out = set(), [] + for v in vert_ids: + if v in seen: + continue + comp, q = [], deque([v]) + seen.add(v) + while q: + n = q.popleft() + comp.append(n) + for m in adj[n]: + if m not in seen: + seen.add(m) + q.append(m) + out.append(comp) + return out + + +def radial_panels(mesh, vert_ids, centre, up, count): + """Split a garment into `count` wedges around the body's up axis. + + This is the skirt grid. A panel is a wedge rather than a connected island + because a skirt IS one connected surface — islands would return the whole + thing as a single piece, which is the bell-shaped failure. + """ + ref = Vector((1.0, 0.0, 0.0)) + ref = (ref - up * ref.dot(up)).normalized() + side = up.cross(ref).normalized() + out = defaultdict(list) + for v in vert_ids: + d = mesh.data.vertices[v].co - centre + d = d - up * d.dot(up) + if d.length < 1e-6: + continue + ang = math.atan2(d.dot(side), d.dot(ref)) + out[int((ang + math.pi) / (2 * math.pi) * count) % count].append(v) + return [v for v in out.values() if len(v) >= 6] + + +def fit_polyline(mesh, vert_ids, root_point, segments): + """A polyline down the middle of a clump of geometry. + + Vertices are binned by distance from the anchored end and each bin's centroid + becomes a joint, so the chain follows the piece's own CURVE. A straight line + from root to tip would cut the corner on a ponytail that bends, and every + vertex on the outside of that bend would then be weighted to a bone travelling + the wrong way. + """ + co = [mesh.data.vertices[v].co for v in vert_ids] + d = [(c - root_point).length for c in co] + lo, hi = min(d), max(d) + if hi - lo < 1e-5: + return [] + pts = [root_point.copy()] + for s in range(1, segments + 1): + a = lo + (hi - lo) * (s - 1) / segments + b = lo + (hi - lo) * s / segments + bucket = [c for c, dist in zip(co, d) if a <= dist <= b + 1e-9] + if not bucket: + # An empty band means the geometry does not reach here; carry the + # direction on rather than collapsing the bone to zero length, which + # would drop it out of the chain at runtime. + if len(pts) >= 2: + pts.append(pts[-1] + (pts[-1] - pts[-2])) + continue + pts.append(sum(bucket, Vector()) / len(bucket)) + return pts if len(pts) >= 2 else [] + + +def nearest_body_group(mesh, vert_ids, arm): + """Which body bone this clump is currently welded to. + + That bone is the right parent for the new chain, and its weight is what the + root blend fades out of — so the transition is to exactly what was holding + this geometry before. + """ + tally = defaultdict(float) + gname = {g.index: g.name for g in mesh.vertex_groups} + bones = set(b.name for b in arm.data.bones) + for v in vert_ids: + for g in mesh.data.vertices[v].groups: + n = gname.get(g.group, "") + if n in bones: + tally[n] += g.weight + return max(tally, key=tally.get) if tally else None + + +def make_chain(arm, name, points, parent_name): + """Create one bone chain along `points`. Returns the bone names.""" + bpy.context.view_layer.objects.active = arm + bpy.ops.object.mode_set(mode="EDIT") + made = [] + prev = arm.data.edit_bones.get(parent_name) + for i in range(len(points) - 1): + bn = name if i == 0 else "%s.seg%d" % (name, i) + eb = arm.data.edit_bones.new(bn) + eb.head = points[i] + eb.tail = points[i + 1] + if eb.length < 1e-5: + arm.data.edit_bones.remove(eb) + continue + eb.parent = prev + eb.use_connect = False + prev = eb + made.append(bn) + bpy.ops.object.mode_set(mode="OBJECT") + return made + + +def weight_chain(mesh, vert_ids, points, bone_names, anchor_bone): + """Move this clump's weights off the body and onto its new chain. + + Each vertex is projected onto the polyline and given the two bones either side + of where it lands, blended by how far between them it is — the same linear + split a hand-painted chain gets. Near the anchored end the ORIGINAL body + weight is kept and faded out over ROOT_BLEND, so the scalp stays on the skull + and the waistband stays on the hips. + """ + groups = [] + for bn in bone_names: + groups.append(mesh.vertex_groups.get(bn) or mesh.vertex_groups.new(name=bn)) + anchor = mesh.vertex_groups.get(anchor_bone) if anchor_bone else None + + # Cumulative length along the polyline, so position is measured in metres + # rather than in segment index — segments are not equal lengths. + seg_len = [(points[i + 1] - points[i]).length for i in range(len(points) - 1)] + total = sum(seg_len) or 1.0 + + for v in vert_ids: + co = mesh.data.vertices[v].co + best, best_d, best_run = 0, 1e18, 0.0 + run = 0.0 + for i in range(len(points) - 1): + a, b = points[i], points[i + 1] + ab = b - a + L2 = ab.length_squared + t = 0.0 if L2 < 1e-12 else max(0.0, min(1.0, (co - a).dot(ab) / L2)) + p = a + ab * t + d = (co - p).length + if d < best_d: + best_d, best, best_run = d, i, run + seg_len[i] * t + run += seg_len[i] + along = best_run / total + + # Clear whatever held this vertex before, then re-add the anchor share. + for g in mesh.data.vertices[v].groups: + grp = mesh.vertex_groups[g.group] + if grp.name not in bone_names: + grp.remove([v]) + hold = 0.0 + if anchor and along < ROOT_BLEND: + hold = 1.0 - along / ROOT_BLEND + anchor.add([v], hold, "REPLACE") + + # Split what is left between the two bones either side of the landing. + f = best_run / total * len(bone_names) + i0 = max(0, min(len(bone_names) - 1, int(f))) + i1 = min(len(bone_names) - 1, i0 + 1) + frac = f - i0 + groups[i0].add([v], (1.0 - hold) * (1.0 - frac), "REPLACE") + if i1 != i0: + groups[i1].add([v], (1.0 - hold) * frac, "REPLACE") + + +def grow(arm, meshes, roles): + head = roles.head + trunk = roles.spine[0] if roles.spine else roles.hips + made_chains = 0 + made_bones = 0 + + for mesh in meshes: + slots = cosmetic_slots(mesh, CLASSES) + if not slots: + continue + # Vertices per class, taken from the polygons that use each slot. + per_class = defaultdict(set) + for poly in mesh.data.polygons: + cls = slots.get(poly.material_index) + if cls: + per_class[cls].update(poly.vertices) + + for cls, verts in per_class.items(): + verts = list(verts) + if len(verts) < MIN_STRAND_VERTS: + continue + world = mesh.matrix_world + if cls == "hair": + anchor_default = head + clumps = islands(mesh, verts) + else: + anchor_default = trunk + centre = sum((mesh.data.vertices[v].co for v in verts), + Vector()) / len(verts) + clumps = radial_panels(mesh, verts, centre, + Vector((0.0, 0.0, 1.0)), SKIRT_PANELS) + + idx = 0 + for clump in clumps: + if len(clump) < MIN_STRAND_VERTS: + continue + anchor = nearest_body_group(mesh, clump, arm) or anchor_default + if anchor is None or anchor not in arm.data.bones: + continue + # The anchored end is the end nearest the bone currently holding + # it — the scalp for hair, the waistband for a skirt. + bone_head = (arm.matrix_world + @ arm.data.bones[anchor].matrix_local).translation + bone_head = world.inverted() @ bone_head + root = min((mesh.data.vertices[v].co for v in clump), + key=lambda c: (c - bone_head).length) + segs = HAIR_SEGMENTS if cls == "hair" else SKIRT_SEGMENTS + pts = fit_polyline(mesh, clump, root, segs) + if not pts: + continue + span = sum((pts[i + 1] - pts[i]).length for i in range(len(pts) - 1)) + if span < MIN_STRAND_LENGTH: + continue + name = "DEF-%s.%03d" % (cls, idx) + idx += 1 + names = make_chain(arm, name, [world @ p for p in pts], anchor) + if not names: + continue + weight_chain(mesh, clump, pts, names, anchor) + made_chains += 1 + made_bones += len(names) + print(" %-6s %-22s %d clumps -> %d chains" + % (cls, mesh.name, len(clumps), idx)) + return made_chains, made_bones + + +def main(): + clear_scene() + bpy.ops.import_scene.gltf(filepath=gltf_fix.prepare(SOURCE, + os.path.dirname(OUTPUT))) + arm, meshes = find_rig() + roles = RigRoles(arm) + missing = roles.missing_core() + if missing: + print("ERROR: could not identify %s on this rig" % missing) + sys.exit(1) + + before = len(arm.data.bones) + chains, bones = grow(arm, meshes, roles) + print("Grew %d cloth chains (%d bones); armature %d -> %d bones" + % (chains, bones, before, len(arm.data.bones))) + if chains == 0: + print("NOTE: no cosmetic material slots matched %s — nothing to grow. " + "Check the model's material names." % CLASSES) + + os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True) + bpy.ops.export_scene.gltf( + filepath=OUTPUT, + export_format="GLB", + export_yup=True, + export_apply=False, + export_skins=True, + export_animations=False, + ) + print("Wrote", OUTPUT) + + +main() diff --git a/tools/gltf_fix.py b/tools/gltf_fix.py new file mode 100644 index 0000000..8c741cd --- /dev/null +++ b/tools/gltf_fix.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Rewrite glTF materials that hide their albedo in the emissive slot. + +Anime models are very often exported "unlit": `KHR_materials_unlit`, a BLACK +`baseColorFactor`, and the actual texture wired to `emissiveTexture`. Renderers +that honour the unlit extension are supposed to use base colour and ignore +emission — so Blender reads black, never references the images at all, and +imports the model with `bpy.data.images` empty. The textures are not lost on +export; they are never loaded. Taila goes through the whole pipeline and comes +out a silhouette. + +Patching Blender's node graph afterwards cannot fix this, because by then there +is nothing to patch — so the file is normalised BEFORE it is imported: +emissive becomes base colour, and the unlit flag is dropped. The game shades +these characters with its own toon material off ALBEDO anyway. + +Pure stdlib, so it runs inside Blender's Python or out of it. +""" +import json +import os +import struct + +_MAGIC = 0x46546C67 +_JSON = 0x4E4F534A +_BIN = 0x004E4942 + + +def _read_glb(path): + with open(path, "rb") as f: + magic, version, _total = struct.unpack(" 3 else 1.0 + pbr["baseColorFactor"] = [emissive_factor[0], emissive_factor[1], + emissive_factor[2], alpha] + mat["emissiveFactor"] = [0.0, 0.0, 0.0] + # Unlit would tell the importer to ignore everything but base colour; + # the game lights these with its own toon shader. + ext = mat.get("extensions", {}) + ext.pop("KHR_materials_unlit", None) + if ext: + mat["extensions"] = ext + else: + mat.pop("extensions", None) + changed += 1 + + if changed: + used = doc.get("extensionsUsed", []) + still = any("KHR_materials_unlit" in m.get("extensions", {}) + for m in doc.get("materials", [])) + if not still and "KHR_materials_unlit" in used: + used.remove("KHR_materials_unlit") + if used: + doc["extensionsUsed"] = used + else: + doc.pop("extensionsUsed", None) + _write_glb(out_path, version, doc, chunks) + return changed + + +def prepare(in_path, work_dir): + """Return a path safe to import: the original, or a normalised copy.""" + if os.path.splitext(in_path)[1].lower() != ".glb": + return in_path + candidate = os.path.join( + work_dir, os.path.splitext(os.path.basename(in_path))[0] + ".albedo.glb") + try: + changed = normalize_unlit(in_path, candidate) + except (OSError, ValueError, KeyError, IndexError) as e: + print(f"WARNING: could not normalise materials in '{in_path}' ({e})") + return in_path + if changed: + print(f"Moved emissive albedo into base colour on {changed} materials " + "(model was exported unlit)") + return candidate + return in_path + + +if __name__ == "__main__": + import sys + if len(sys.argv) < 3: + print("Usage: python tools/gltf_fix.py ") + sys.exit(1) + n = normalize_unlit(sys.argv[1], sys.argv[2]) + print(f"{n} materials rewritten") diff --git a/tools/measure_clipview.py b/tools/measure_clipview.py new file mode 100644 index 0000000..3421f0d --- /dev/null +++ b/tools/measure_clipview.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""How much leg is rendering IN FRONT OF the skirt, in pixels. + + python tools/measure_clipview.py + +That tool saves each pose twice, both drawing DISTANCE FROM THE CAMERA into the +colour channel: `cloth_N.png` with only the cloth drawn and `clipview_N.png` with +only the body. A pixel counts when the body is nearer than the nearest cloth +there — which is exactly what "the thigh is showing through the skirt" means. + +Depth, not silhouettes. With the legs apart you see the FAR side of the skirt +through the gap between them and the thigh is correctly in front of that; a mask +test counts all of it, reported 25% of the cloth covered on poses that are fine, +and sent two rounds of tuning after a defect that was not there. + +`hip_N.txt` gives the screen row of the hip joint; only rows below it count, +because above it the torso is inside the skirt and in front of its waistband. Magenta is cloth, grey is body. So the cloth +silhouette comes from the second image, and any grey inside it in the first is +the leg in front of the skirt — the defect — while grey outside it is just the +leg past the hem, which is correct. + +Reading that off the two pictures by eye is unreliable: the two failures look +alike, the silhouettes interleave, and it was misjudged in both directions more +than once. This counts it. + +WHAT THIS IS AND IS NOT FOR. It measures the garment's CONTINUITY well — a torn +panel shows up as a hole in the cloth mask. It is NOT a clipping test. When the +legs are apart you see the far side of the skirt through the gap between them, +and the thighs are legitimately in front of that, so those pixels count as hits +while being correct rendering. Every large reading here traced back to that. +Judge clipping from debug/skirt_probe.gd and the plain textured render. + +It is also not repeatable enough to A/B tuning changes. The solver can be given a +fixed timestep, but the AnimationTree still advances on the real frame delta, so +the pose at a given frame drifts between runs: the same build measured 93k and +76k. Differences under about 20% here mean nothing. + +The cloth mask is eroded a little first. The two frames are one apart (the tool +swaps materials between them), so the character has moved a fraction of a +millimetre and the silhouette edges do not line up exactly; without the erosion +every outline pixel reads as a hit. +""" +import os +import sys + +from PIL import Image, ImageChops, ImageFilter + +## Depth difference, in 8-bit steps, that counts as the body being in front. +## One step is about 6 mm over the 1.6 m the shader encodes; three keeps +## coincident surfaces and the one-frame offset between the two shots quiet. +NEAR_EPS = 3 +MIN_BLOB = 40 # ignore specks — anti-aliasing along an edge, not a defect + + +def depth(path): + """Per-pixel camera distance as an 8-bit band; 0 means no geometry.""" + return Image.open(path).convert("RGB").split()[0] + + +def main(): + root = sys.argv[1] if len(sys.argv) > 1 else "." + total = 0 + shots = 0 + for n in range(256): + full = os.path.join(root, "clipview_%d.png" % n) + only = os.path.join(root, "cloth_%d.png" % n) + if not (os.path.exists(full) and os.path.exists(only)): + continue + # `clipview` draws EVERYTHING, so it holds the nearest of cloth-or-body; + # `cloth` draws only the garment. Where the combined pass is nearer than + # the cloth pass, something that is not cloth is in front of it. + both_pass = depth(full) + cloth = depth(only) + w, h = cloth.size + top = 0 + hipf = os.path.join(root, "hip_%d.txt" % n) + if os.path.exists(hipf): + with open(hipf) as fh: + top = max(0, int(fh.read().strip())) + box = (0, top, w, h) + b = both_pass.crop(box) + c = cloth.crop(box) + has_c = c.point(lambda p: 255 if p > 0 else 0) + # Nearer than the nearest cloth, by more than a little depth noise. + nearer = ImageChops.subtract(c, b).point(lambda p: 255 if p > NEAR_EPS else 0) + hit_mask = ImageChops.multiply(has_c, nearer) + hit = sum(hit_mask.point(lambda p: 1 if p else 0).get_flattened_data()) + area = sum(has_c.point(lambda p: 1 if p else 0).get_flattened_data()) + if hit >= MIN_BLOB: + over = Image.merge("RGB", (c, c, c)).convert("RGB") + over.paste(Image.new("RGB", over.size, (255, 32, 32)), (0, top), hit_mask) + over.save(os.path.join(root, "over_%d.png" % n)) + total += hit + shots += 1 + flag = "" if hit < MIN_BLOB else " <-- leg in front of cloth" + print(" shot %d: %6d px of leg over %7d px of cloth (%.2f%%)%s" + % (n, hit, area, 100.0 * hit / max(area, 1), flag)) + if shots: + print(" TOTAL %d px over %d shots" % (total, shots)) + + +main() diff --git a/tools/pipeline.py b/tools/pipeline.py index 9a77231..cacabb6 100644 --- a/tools/pipeline.py +++ b/tools/pipeline.py @@ -14,6 +14,9 @@ Examples: # From an ALREADY-RIGGED model (Mixamo/AccuRig/Tripo output) — skips autorig: python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged + # Rigged, but with no skirt/hair bones — grow them so the costume can move: + python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged --grow-cloth + The result is assets/characters/skins/.glb with the full canonical animation set, plus a registry entry in assets/characters/skins/skins.json that SkinManager picks up automatically — no code changes needed. @@ -25,6 +28,7 @@ import argparse import json import os import shutil +import struct import subprocess import sys @@ -56,6 +60,32 @@ def find_blender() -> str: sys.exit(1) +def has_skeleton(path: str) -> bool: + """Does this glTF already carry a skin? Read straight out of the container + so the check costs nothing — launching Blender just to ask takes seconds. + Non-glTF formats can't be probed this way; pass --rigged for those. + """ + ext = os.path.splitext(path)[1].lower() + try: + if ext == ".gltf": + with open(path, "r", encoding="utf-8") as f: + return bool(json.load(f).get("skins")) + if ext != ".glb": + return False + with open(path, "rb") as f: + magic, _ver, total = struct.unpack(" None: print(f"\n=== {step} ===") print(" ".join(f'"{c}"' if " " in c else c for c in cmd)) @@ -98,10 +128,18 @@ def main() -> None: p.add_argument("--name", required=True, help="skin id (snake_case)") p.add_argument("--display-name", help="name shown in menus (default: from --name)") p.add_argument("--description", default="", help="skin description") - p.add_argument("--rigged", action="store_true", help="input is already rigged — skip autorig") + p.add_argument("--rigged", action="store_true", + help="force the keep-the-rig path (auto-detected for glTF)") + p.add_argument("--rebind", action="store_true", + help="discard the source rig and fit the library skeleton " + "(last resort — destroys authored weights and cloth bones)") p.add_argument("--height", type=float, default=1.75, help="target character height in meters") p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips") p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory") + p.add_argument("--grow-cloth", action="store_true", + help="grow skirt/hair bone chains for a model that has none " + "(see tools/cloth_bones.py) — needed for anything that " + "went through the auto-rig, whose costume is welded solid") args = p.parse_args() name = args.name @@ -131,29 +169,60 @@ def main() -> None: blender = find_blender() print(f"Using Blender: {blender}") - # 2. Auto-rig (or pass through if already rigged). - rigged_path = os.path.join(STAGING, f"{name}_rigged.glb") - if args.rigged: - rigged_path = input_path - print("Skipping autorig (--rigged)") - else: - run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"), - "--", input_path, rigged_path, str(args.height)], - "Auto-rig (Blender)") - - # 3. Merge the shared animation library. - if not os.path.isdir(args.anim_dir) or not any( - f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)): - print(f"ERROR: animation library is empty: {args.anim_dir}") - print("Fill it with one clip per file (idle.fbx, run.fbx, ...) — see docs/ASSET_SOURCES.md") + # 2. Decide whether the model already has a skeleton worth keeping. + # + # Keeping it is strongly preferred and is now the default. The old route + # (strip_rig -> autorig) discarded the source skeleton, joined every mesh + # into one, and rebound with nearest-bone weights — which is what put 2817 + # both-legs-at-once vertices into the shipped Taila and cost her the skirt + # and hair bone chains outright. --rebind still exists for a model that + # genuinely has no usable rig, but it is the lossy path. + rigged = args.rigged or (not args.rebind and has_skeleton(input_path)) + if not os.path.isdir(args.anim_dir) or not os.path.exists( + os.path.join(args.anim_dir, "_library.glb")): + print(f"ERROR: animation library not found: {args.anim_dir}/_library.glb") + print("See docs/ASSET_SOURCES.md") sys.exit(1) final_path = os.path.join(SKINS_DIR, f"{name}.glb") - merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"), - "--", rigged_path, args.anim_dir, final_path] - if args.keep_root_motion: - merge_cmd.append("--keep-root-motion") - run(merge_cmd, "Merge animation library (Blender)") + + # Grow cloth chains BEFORE the retarget, so describe_rig() finds them by name + # exactly as it would an artist's and writes them to the sidecar. + # + # A model with no cosmetic bones has a costume that cannot move: the spring + # solver simulates cloth BONES, so a skirt with none is welded to the hips + # whatever the runtime does. That is every auto-rigged model, and it is why + # Miku shipped with 0 cloth chains against Taila's 35. + if args.grow_cloth: + grown = os.path.join(STAGING, f"{name}_cloth.glb") + run([blender, "--background", "--python", os.path.join(TOOLS, "cloth_bones.py"), + "--", input_path, grown], + "Grow skirt/hair bone chains (Blender)") + input_path = grown + rigged = True + + if rigged: + print("Model is rigged — keeping its skeleton, weights and cloth chains") + cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"), + "--", input_path, args.anim_dir, final_path, "--height", str(args.height)] + if args.keep_root_motion: + cmd.append("--keep-root-motion") + run(cmd, "Retarget animation library onto the model's own rig (Blender)") + else: + print("No skeleton found — fitting the library rig (authored weights unavailable)") + rigged_path = os.path.join(STAGING, f"{name}_rigged.glb") + run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"), + "--", input_path, rigged_path, str(args.height)], + "Auto-rig (Blender)") + cmd = [blender, "--background", "--python", os.path.join(TOOLS, "retarget.py"), + "--", rigged_path, args.anim_dir, final_path, "--height", str(args.height)] + if args.keep_root_motion: + cmd.append("--keep-root-motion") + run(cmd, "Retarget animation library (Blender)") + + # 3. Gate on the checks that encode every way this has gone wrong before. + run([blender, "--background", "--python", os.path.join(TOOLS, "verify_character.py"), + "--", final_path], "Verify the built character (Blender)") # 4. Carry the license file along if the model came from Sketchfab. lic_src = os.path.splitext(input_path)[0] + ".license.json" diff --git a/tools/retarget.py b/tools/retarget.py new file mode 100644 index 0000000..69e535b --- /dev/null +++ b/tools/retarget.py @@ -0,0 +1,1501 @@ +#!/usr/bin/env python3 +""" +Put the animation library onto a character WITHOUT touching how it deforms. + +This replaces the old strip_rig -> autorig -> merge_animations route, which +solved a naming problem by destroying the asset. That route threw away the +character's skeleton, joined every mesh into one blob, and rebound the result +with nearest-four-bones Euclidean weights. Measured on the shipped taila.glb it +produced 2817 vertices pulled by BOTH legs (worst a dead 50/50 split) and 86% +of all vertices carrying the full four influences — while the ORIGINAL file it +was built from had zero cross-leg bleed, one mesh per material, and dedicated +bone chains for the skirt and the hair. Every runtime "repair" in +characters/skin_leg_repair.gd exists to undo damage done right here. + +So: keep the character's own rig, weights, per-part meshes and cloth chains, +and move the ANIMATION onto it instead. + + 1. Rebuild parenting. A Rigify DEF-rig exports its chain roots parented + straight to the armature root, because Rigify drives them by constraint + rather than hierarchy. Left that way, rotating the hips would leave the + legs, skirt and hair floating in place. Orphans are re-attached by + anatomy where it is known and by rest geometry otherwise — and cloth may + only ever attach to the trunk, never to a limb, or a skirt would ride one + thigh. + 2. Retarget by ROLE, not by name (see tools/rig_map.py). + 3. Bake each clip as a rest-relative delta: + + R_world = src_pose_rot * src_rest_rot^-1 (what the clip does) + tgt_rot = R_world * tgt_rest_rot (done to THIS rig) + + Copying absolute world orientation instead — which is what the old + constraint bake did — forces the library's bone roll onto a mesh that was + bound with a different one, and twists every limb by a constant offset. + 4. Drive ONLY the body. Skirt, hair, twist and face bones are left with no + keys at all, so they rest relative to their parents and are free for the + spring solver at runtime. That split — clips animate the body, physics + animates the cloth — is the whole point. + +Usage: + blender --background --python tools/retarget.py -- \ + [--height 1.75] [--keep-root-motion] + +Writes .rig.json beside the GLB: resolved bone roles, cloth chains and +twist pairs, so the runtime never has to re-guess the skeleton's anatomy. +""" +import bpy +import math +import json +import os +import sys +import tempfile +from collections import defaultdict +from mathutils import Matrix, Quaternion, Vector + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gltf_fix +import rig_map +import surface_map +from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens + +argv = sys.argv +argv = argv[argv.index("--") + 1:] if "--" in argv else [] +if len(argv) < 3: + print(__doc__) + sys.exit(1) + +CHARACTER, ANIM_DIR, OUTPUT = argv[0], argv[1], argv[2] +STRIP_ROOT_MOTION = "--keep-root-motion" not in argv +TARGET_HEIGHT = 1.75 +if "--height" in argv: + TARGET_HEIGHT = float(argv[argv.index("--height") + 1]) +OVERRIDES = {} +if "--bone-map" in argv: + with open(argv[argv.index("--bone-map") + 1], "r", encoding="utf-8") as f: + OVERRIDES = json.load(f) + +# Library clip -> the game's canonical clip name. Unmapped clips are skipped so +# character GLBs stay small. +LIBRARY_CLIP_MAP = { + "Idle_Loop": "Idle", + "Walk_Loop": "Walk", + "Jog_Fwd_Loop": "Run", + "Sprint_Loop": "Sprint", + "Jump_Start": "Jump", + "Jump_Loop": "Fall", + "Jump_Land": "Land", + "Crouch_Idle_Loop": "CrouchIdle", + "Crouch_Fwd_Loop": "CrouchWalk", + "Roll": "Dash", + "Death01": "Death", + "Hit_Chest": "Hit", + "Dance_Loop": "Dance", + "Swim_Fwd_Loop": "Grapple", + "Pistol_Idle_Loop": "PistolIdle", + "Pistol_Shoot": "PistolShoot", + "Pistol_Reload": "PistolReload", + "Sword_Attack": "Throw", +} + +UP = Vector((0.0, 0.0, 1.0)) + +# Which cosmetic chains get secondary motion. A face-shape or eye chain is +# cosmetic but must never swing, so this is deliberately narrower than +# rig_map.COSMETIC. +SPRING_CLASSES = {"hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat", + "scarf", "sleeve", "breast", "bust", "feather", "strap", + "antenna", "wing"} + + +def spring_class(name): + """Which secondary-motion class this bone belongs to, or None. + + Whole token first, then a SHORT positional suffix — the same rule as + rig_map.is_cosmetic, and it must be the same rule, because a bone that reads + as cosmetic there and as nothing here is left out of every chain and its + geometry never moves. DANDADAN's hair is HairFL / HairFR / HairF_Top, which + tokenise to "hairfl" and matched no class at all: she imported with six + chains, all of them her bust, and not one strand of hair. + """ + for t in tokens(name): + if t in SPRING_CLASSES: + return t + for c in SPRING_CLASSES: + if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c): + return c + return None + + +# --------------------------------------------------------------------- import + + +def clear_scene(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete() + for blocks in (bpy.data.meshes, bpy.data.armatures, bpy.data.actions): + for b in list(blocks): + if b.users == 0: + blocks.remove(b) + + +def import_any(path): + ext = os.path.splitext(path)[1].lower() + if ext in (".glb", ".gltf"): + bpy.ops.import_scene.gltf(filepath=path) + elif ext == ".fbx": + bpy.ops.import_scene.fbx(filepath=path) + else: + print(f"ERROR: unsupported character format {ext}") + sys.exit(1) + + +def skinned_meshes(arm): + """Meshes actually driven by this armature. + + Anything else in the file is scene dressing — Sketchfab models routinely + ship a display base or a diorama, and those must not become part of the + player. + """ + out = [] + for o in [o for o in bpy.data.objects if o.type == "MESH"]: + driven = any(m.type == "ARMATURE" and m.object is arm for m in o.modifiers) + if driven or (o.parent is arm and o.vertex_groups): + out.append(o) + return out + + +def strip_import_suffixes(arm, meshes): + """`DEF-thigh.L_16` -> `DEF-thigh.L`. + + The glTF importer appends the node index to every bone name. Those names + ship in the exported GLB and are what the game code matches on, so clean + them up here rather than teaching every consumer about the suffix. + """ + renames = {} + taken = set(b.name for b in arm.data.bones) + for bone in arm.data.bones: + clean = rig_map.strip_gltf_suffix(bone.name) + if clean != bone.name and clean not in taken: + renames[bone.name] = clean + taken.discard(bone.name) + taken.add(clean) + for old, new in renames.items(): + arm.data.bones[old].name = new + # Vertex groups are matched to bones by NAME and are not renamed for us. + for m in meshes: + for vg in m.vertex_groups: + new = renames.get(vg.name) + if new and new not in m.vertex_groups: + vg.name = new + print(f"Cleaned {len(renames)} bone names") + + +# ----------------------------------------------------------------- hierarchy + + +def _seg_distance(p, a, b): + ab = b - a + d2 = ab.dot(ab) + t = 0.0 if d2 < 1e-12 else max(0.0, min(1.0, (p - a).dot(ab) / d2)) + return (p - (a + ab * t)).length + + +def rebuild_hierarchy(arm, roles): + """Re-attach chain roots that exported parented to the armature root. + + Anatomy first (a thigh belongs to the hips, a forearm to the upper arm), + rest geometry second. Cloth and hair are only ever allowed to attach to the + TRUNK: pick anchors by raw proximity and a skirt panel hanging beside a leg + attaches to that thigh and rides it like a trouser leg. + """ + trunk = [roles.hips] + list(roles.spine) + trunk = [n for n in trunk if n] + limb = roles.limb + + def anat(role, side): + return limb.get((role, side)) + + fixed = {} + for side in ("L", "R"): + chest = trunk[-3] if len(trunk) >= 3 else (trunk[-1] if trunk else None) + pairs = [ + (("thigh", side), roles.hips), + (("shin", side), anat("thigh", side)), + (("foot", side), anat("shin", side)), + (("toe", side), anat("foot", side)), + (("shoulder", side), chest), + (("upper_arm", side), anat("shoulder", side) or chest), + (("forearm", side), anat("upper_arm", side)), + (("hand", side), anat("forearm", side)), + ] + for key, parent in pairs: + name = limb.get(key) + if name and parent: + fixed[name] = parent + + bpy.context.view_layer.objects.active = arm + bpy.ops.object.mode_set(mode="EDIT") + eb = arm.data.edit_bones + + # Where each anchor bone actually EXTENDS TO, from authored data only. + # + # glTF stores joints as nodes with no tail, so the tails Blender reports are + # invented — every one of Taila's skirt bones comes back 0.78 m long. Using + # them, DEF-spine's phantom tail runs straight down through the whole skirt + # and every panel measures ~0.05 m from "the hips", which beat each panel's + # real 0.15 m link to its own chain root and flattened all 7 chains. + # A bone's true extent is the head of the next bone along. + span = {} + for i, name in enumerate(trunk): + nxt = trunk[i + 1] if i + 1 < len(trunk) else None + span[name] = (eb[name].head, + eb[nxt].head if nxt and nxt in eb else eb[name].tail) + for name in limb.values(): + if name not in eb: + continue + kids = [c for c in eb[name].children if not is_cosmetic(c.name)] + span[name] = (eb[name].head, kids[0].head if kids else eb[name].tail) + + def anchor_distance(point, name): + a, b = span.get(name, (eb[name].head, eb[name].tail)) + return _seg_distance(point, a, b) + + def is_root_like(bone): + return bone is None or any( + t in ("root", "master", "armature", "scene", "rootjoint") + for t in tokens(bone.name)) + + def descendants(bone): + out = {bone.name} + stack = list(bone.children) + while stack: + b = stack.pop() + out.add(b.name) + stack.extend(b.children) + return out + + trunk_set = set(trunk) + core = trunk_set | set(limb.values()) + orphans = [b for b in eb + if b.name != roles.hips and b.name not in trunk_set + and is_root_like(b.parent)] + + reparented = 0 + cosmetic_left = [] + for bone in orphans: + if is_cosmetic(bone.name): + cosmetic_left.append(bone) + continue + target = fixed.get(bone.name) + if target is None: + banned = descendants(bone) + cands = [n for n in core if n in eb and n not in banned] + if not cands: + continue + target = min(cands, key=lambda n: anchor_distance(bone.head, n)) + if target in eb and target != bone.name: + bone.parent = eb[target] + bone.use_connect = False + reparented += 1 + + # Cloth and hair hang from the TRUNK — never from a limb, and never from + # each other. + # + # Attaching cloth to whatever bone is nearest puts 16 of Taila's 21 skirt + # bones on a thigh, where the panel rides one leg like a trouser leg. The + # trunk restriction fixes that. + # + # Reconstructing multi-bone chains is deliberately NOT attempted. The + # temptation is obvious — Taila's skirt is really 7 panels of 3 — but the + # information is not in the file. glTF stores no bone tails, the panel + # numbering is not sequential (`skirt` -> `skirt.011` -> `skirt.002`), and + # neighbouring panel roots ring the waist 0.04 m apart, far closer than any + # of them is to the trunk. Successive attempts at distance, chain-direction + # and grow-outward rules each produced a topology that was still wrong + # somewhere — stitching panels together sideways, or hanging hair off an + # eye bone. A wrong chain is worse than no chain: the solver then swings + # bones along axes the mesh was never weighted for, and tears it. + # + # So each orphan becomes its own pendulum from the body. Chains the ARTIST + # authored survive untouched, because only chain ROOTS are orphans — which + # is why Taila's hair keeps its real 4-7 bone strands while her + # flat-exported skirt becomes per-panel pendulums. Both look like cloth; + # only the authored one gets true multi-segment drape. + for bone in cosmetic_left: + cands = [n for n in trunk_set if n in eb] + if not cands: + break + bone.parent = eb[min(cands, key=lambda n: anchor_distance(bone.head, n))] + bone.use_connect = False + reparented += 1 + + bpy.ops.object.mode_set(mode="OBJECT") + print(f"Re-attached {reparented} orphaned bones") + + +# ----------------------------------------------------------------- normalize + + +def flatten_and_scale(arm, meshes, target_height, roles=None): + """Bake the import hierarchy away and set the character's real-world size. + + Sketchfab wraps everything in scaled/rotated empties. Left in place they + turn up as a scale on the exported Skeleton3D, and every measurement the + game makes off bone rests reads in the wrong units. + """ + if arm.animation_data: + arm.animation_data_clear() + for pb in arm.pose.bones: + pb.matrix_basis = Matrix() + + for obj in [arm] + meshes: + world = obj.matrix_world.copy() + obj.parent = None + obj.matrix_world = world + for o in [o for o in bpy.data.objects if o.type == "EMPTY"]: + bpy.data.objects.remove(o, do_unlink=True) + + def apply_all(): + bpy.ops.object.select_all(action="DESELECT") + for obj in [arm] + meshes: + obj.select_set(True) + bpy.context.view_layer.objects.active = arm + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + apply_all() + + # STAND THE CHARACTER UP before measuring anything. + # + # The scale below normalises the bounding box along Z because Z is up in + # Blender. For a model that arrives lying along another axis that measures + # the character's THICKNESS — about 0.25 m — so it gets scaled by ~7 and left + # on its back. One assumption, both symptoms, and invisible afterwards + # because the exporter maps Blender Z to glTF Y: "is the height 1.75" comes + # out true on a character who is 7.5 m tall lying down. + # + # Which way is up is not a convention to assume, it is a property of the + # skeleton: the head is above the hips. Snapped to the nearest axis rather + # than aligned exactly, so a character with a slight lean in their rest pose + # is stood up, not straightened. + # 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 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 — which points this vector + # downwards and stands the character neatly on her head. Momo did exactly + # that. Feet cannot be mistaken: they are the bottom of a standing character + # on every rig, and foot.L/R resolve on every source met so far. + if roles is not None and roles.hips: + hips_b = arm.data.bones.get(roles.hips) + feet = [arm.data.bones.get(roles.limb.get(("foot", s), "")) + for s in ("L", "R")] + feet = [f for f in feet if f] + ref = None + if feet: + ref = sum((f.head_local for f in feet), Vector()) / len(feet) + elif roles.head and arm.data.bones.get(roles.head): + # No feet — fall back to the head, and accept the risk above. + ref = hips_b.head_local - ( + arm.data.bones[roles.head].head_local - hips_b.head_local) + if hips_b and ref is not None: + d = hips_b.head_local - ref + k = max(range(3), key=lambda i: abs(d[i])) + src = Vector((0.0, 0.0, 0.0)) + src[k] = 1.0 if d[k] > 0 else -1.0 + up = Vector((0.0, 0.0, 1.0)) + if src.dot(up) < 0.999: + axis = src.cross(up) + if axis.length < 1e-6: + axis = Vector((1.0, 0.0, 0.0)) # upside down + R = Matrix.Rotation(src.angle(up), 4, axis.normalized()) + for obj in [arm] + meshes: + obj.matrix_world = R @ obj.matrix_world + apply_all() + print(f"Stood the character up: feet->hips ran along " + f"{'XYZ'[k]}{'+' if d[k] > 0 else '-'}, rotated to Z+") + + # TURN THE CHARACTER TO FACE THE SAME WAY AS EVERY OTHER CHARACTER. + # + # Which way a model faces is a property of the file, not a constant, and two + # separate things were guessing at it: facing_correction() aligns the rest + # pose to the library's, and the runtime then applies a blanket 180 degrees + # because "glTF forward is +Z". When a source disagrees with either, the two + # compose into a character who runs backwards, and nothing measured the + # result. Kiyoko shipped like that. + # + # The skeleton knows: toes are in front of ankles. Snap that to Blender -Y, + # which is the convention the rest of the pipeline and the runtime flip are + # built around, so every character leaves here pointing the same way whatever + # the source did. + if roles is not None: + fwd = Vector((0.0, 0.0, 0.0)) + n_f = 0 + for side in ("L", "R"): + a = arm.data.bones.get(roles.limb.get(("foot", side), "")) + t = arm.data.bones.get(roles.limb.get(("toe", side), "")) + if a and t: + fwd += (t.head_local - a.head_local) + n_f += 1 + if n_f: + fwd.z = 0.0 + if fwd.length > 1e-4: + fwd.normalize() + want = Vector((0.0, -1.0, 0.0)) + ang = math.atan2(fwd.x, -fwd.y) # signed yaw from -Y to fwd + # Snap to the nearest quarter turn: a rest pose with the feet + # slightly splayed must not be counted as a turn. + q = round(ang / (math.pi / 2)) * (math.pi / 2) + if abs(q) > 1e-6: + R = Matrix.Rotation(-q, 4, Vector((0.0, 0.0, 1.0))) + for obj in [arm] + meshes: + obj.matrix_world = R @ obj.matrix_world + apply_all() + print("Turned the character to face -Y: was %.0f degrees off" + % math.degrees(q)) + + lo = Vector((1e9, 1e9, 1e9)) + hi = -lo.copy() + for m in meshes: + for corner in m.bound_box: + p = m.matrix_world @ Vector(corner) + lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))) + hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))) + height = hi.z - lo.z + if height > 1e-4: + s = target_height / height + for obj in [arm] + meshes: + obj.scale = (s, s, s) + apply_all() + lo *= s + hi *= s + print(f"Scaled by {s:.4f} to {target_height:.2f} m") + + offset = Vector((-(lo.x + hi.x) * 0.5, -(lo.y + hi.y) * 0.5, -lo.z)) + for obj in [arm] + meshes: + obj.location = offset + apply_all() + + # Re-parent meshes under the armature so the export writes one clean skin. + for m in meshes: + m.parent = arm + m.matrix_parent_inverse = Matrix() + if not any(mod.type == "ARMATURE" and mod.object is arm for mod in m.modifiers): + mod = m.modifiers.new("Armature", "ARMATURE") + mod.object = arm + + +def fix_unlit_materials(meshes): + """Route each material's texture into Base Color. + + Anime models are commonly authored UNLIT: black base colour with the albedo + wired to emission. Our toon shader reads ALBEDO, so left alone the + character renders pitch black. + """ + seen = set() + for m in meshes: + for mat in m.data.materials: + if not mat or not mat.use_nodes or mat.name in seen: + continue + seen.add(mat.name) + nt = mat.node_tree + tex = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image), None) + bsdf = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None) + if not bsdf or not tex: + continue + base = bsdf.inputs["Base Color"] + if not base.links: + nt.links.new(tex.outputs["Color"], base) + print(f"Material '{mat.name}': routed '{tex.image.name}' to base colour") + if "Emission Strength" in bsdf.inputs: + bsdf.inputs["Emission Strength"].default_value = 0.0 + + +# ------------------------------------------------------------------ retarget + + +def world_rest(arm): + mw = arm.matrix_world + return {b.name: mw @ b.matrix_local for b in arm.data.bones} + + +def rig_forward(arm, roles): + """Which way the rest pose faces, on the ground plane, from the feet.""" + mw = arm.matrix_world + acc = Vector((0.0, 0.0, 0.0)) + for side in ("L", "R"): + for role in ("toe", "foot"): + name = roles.limb.get((role, side)) + if not name: + continue + b = arm.data.bones[name] + v = (mw @ b.tail_local) - (mw @ b.matrix_local.translation) + v.z = 0.0 + if v.length > 1e-5: + acc += v.normalized() + break + return acc.normalized() if acc.length > 1e-5 else None + + +def facing_correction(src_arm, src_roles, tgt_arm, tgt_roles): + """Yaw that carries the source rig's forward onto the target's. + + Without it a library that rests facing -Y drives a character that rests + facing +Y and every clip plays backwards. + """ + a = rig_forward(src_arm, src_roles) + b = rig_forward(tgt_arm, tgt_roles) + if a is None or b is None: + return Quaternion() + # angle_signed is 2D-only in mathutils, which is what we want anyway: the + # correction is a yaw about world up, never a tilt. + angle = Vector((a.x, a.y)).angle_signed(Vector((b.x, b.y)), 0.0) + if abs(angle) < 1e-4: + return Quaternion() + print(f"Facing correction: {angle * 57.2958:.1f} deg") + return Quaternion(UP, angle) + + +def bone_order(arm): + """Every bone, parents before children.""" + out = [] + + def walk(b): + out.append(b.name) + for c in b.children: + walk(c) + + for b in arm.data.bones: + if b.parent is None: + walk(b) + return out + + +def solve_pose(arm, order, rest_w, desired_rot, hips, hips_head): + """Turn desired WORLD orientations into per-bone local basis transforms. + + Done arithmetically rather than by setting `pose_bone.matrix` and letting + Blender solve, because that needs a depsgraph update per bone — 150 bones + across 18 clips is tens of thousands of scene evaluations. + + Blender relates pose to rest as + pose = parent_pose * parent_rest^-1 * rest * basis + so with M standing for everything left of `basis`, a rotation-only basis of + M.rot^-1 * desired lands the bone on `desired` exactly. + """ + pose_w = {} + basis = {} + bones = arm.data.bones + for name in order: + b = bones[name] + rest = rest_w[name] + if b.parent is not None: + M = pose_w[b.parent.name] @ rest_w[b.parent.name].inverted() @ rest + else: + M = rest + q = Quaternion() + if name in desired_rot: + q = M.to_quaternion().inverted() @ desired_rot[name] + loc = Vector((0.0, 0.0, 0.0)) + if name == hips and hips_head is not None: + loc = M.inverted() @ hips_head + basis[name] = (loc, q) + pose_w[name] = M @ Matrix.Translation(loc) @ q.to_matrix().to_4x4() + return basis + + +def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action, + clip_name, yaw, scale): + src_rest = world_rest(src_arm) + tgt_rest = world_rest(tgt_arm) + order = bone_order(tgt_arm) + src_rest_rot = {n: m.to_quaternion() for n, m in src_rest.items()} + tgt_rest_rot = {n: m.to_quaternion() for n, m in tgt_rest.items()} + yaw_inv = yaw.inverted() + + src_hips = src_roles.hips + tgt_hips = tgt_roles.hips + src_hips_rest = src_rest[src_hips].translation.copy() + tgt_hips_rest = tgt_rest[tgt_hips].translation.copy() + + assign_action(src_arm, action) + f0, f1 = (int(round(v)) for v in action.frame_range) + + baked = bpy.data.actions.new(clip_name) + assign_action(tgt_arm, baked) + for pb in tgt_arm.pose.bones: + pb.rotation_mode = "QUATERNION" + + scene = bpy.context.scene + for frame in range(f0, f1 + 1): + scene.frame_set(frame) + dg = bpy.context.evaluated_depsgraph_get() + src_eval = src_arm.evaluated_get(dg) + smw = src_eval.matrix_world + + desired = {} + for tgt_name, src_name in mapping.items(): + if src_name not in src_eval.pose.bones or tgt_name not in tgt_rest_rot: + continue + pose_rot = (smw @ src_eval.pose.bones[src_name].matrix).to_quaternion() + delta = pose_rot @ src_rest_rot[src_name].inverted() + desired[tgt_name] = (yaw @ delta @ yaw_inv) @ tgt_rest_rot[tgt_name] + + hips_head = None + if src_hips in src_eval.pose.bones: + moved = (smw @ src_eval.pose.bones[src_hips].matrix).translation + d = yaw @ ((moved - src_hips_rest) * scale) + if STRIP_ROOT_MOTION: + d.x = 0.0 + d.y = 0.0 # gameplay code moves the body; keep the vertical bob + hips_head = tgt_hips_rest + d + + basis = solve_pose(tgt_arm, order, tgt_rest, desired, tgt_hips, hips_head) + for name in mapping: + if name not in basis: + continue + pb = tgt_arm.pose.bones[name] + pb.rotation_quaternion = basis[name][1] + pb.keyframe_insert("rotation_quaternion", frame=frame) + if hips_head is not None: + pb = tgt_arm.pose.bones[tgt_hips] + pb.location = basis[tgt_hips][0] + pb.keyframe_insert("location", frame=frame) + + assign_action(tgt_arm, None) + return baked + + +def assign_action(obj, action): + if not obj.animation_data: + obj.animation_data_create() + obj.animation_data.action = action + if action is None: + return + try: # Blender 4.4+ slotted actions + if not obj.animation_data.action_slot and len(action.slots): + obj.animation_data.action_slot = action.slots[0] + except (AttributeError, TypeError): + pass + + +def add_nla_clip(arm, action, name): + action.name = name + track = arm.animation_data.nla_tracks.new() + track.name = name + strip = track.strips.new(name, 0, action) + strip.name = name + track.mute = True + action.use_fake_user = True + + +# -------------------------------------------------------------------- sidecar + + +def _dominant_vertices(meshes, arm, min_weight=0.25): + """bone name -> world positions of the vertices it mostly owns. + + "Mostly" as in holds the largest share — a vertex belongs to one bone for + the purpose of measuring what that bone covers, even though it is skinned + to several. + + `min_weight` is how strongly a vertex must belong to its bone to count. + The collider pass raises it: a vertex split 0.3/0.3/0.4 across hip, thigh + and glute is a BLEND, and letting those in put the hip flare back into the + thigh's band samples — the fitted capsule came out 0.18 m at the head. + """ + out = defaultdict(list) + for m in meshes: + gname = {g.index: g.name for g in m.vertex_groups} + mw = m.matrix_world + for v in m.data.vertices: + best = None + for g in v.groups: + if best is None or g.weight > best.weight: + best = g + if best is not None and best.weight > min_weight: + out[gname.get(best.group, "")].append(mw @ v.co) + return out + + +def _bone_tip(arm, bone, chain, index, owned, fallback): + """Where a bone effectively points, in its own rest space. + + The next bone along when there is one. Otherwise the centroid of the + geometry this bone actually drives — which is the only real answer for + Taila's skirt, whose 21 panel bones export with no children and no usable + tail, so there is nothing in the skeleton to say which way a panel hangs. + """ + rest_world = arm.matrix_world @ arm.data.bones[bone].matrix_local + if index + 1 < len(chain): + nxt = arm.matrix_world @ arm.data.bones[chain[index + 1]].matrix_local + return rest_world.inverted() @ nxt.translation + pts = owned.get(bone, []) + if pts: + centroid = sum(pts, Vector((0.0, 0.0, 0.0))) / len(pts) + local = rest_world.inverted() @ centroid + # The centroid sits mid-panel, so the far edge is roughly twice out. + if local.length > 1e-4: + return local * 2.0 + return fallback + + +def _hull_samples(arm, bone, owned, limit=10): + """A few points spread across the geometry a cloth bone actually drives, + in that bone's own rest space. + + The runtime collides THESE, not points along the bone. A skirt panel is a + wide sheet hanging off a single stick from the waist, so keeping the stick + out of the thigh is nearly meaningless: measured over a movement sweep, the + bones were clear by ~1 mm while the leg was 85 mm inside the skirt MESH with + 190 vertices swallowed. + + Chosen by farthest-point sampling so the handful of points spans the panel + (edges, hem, middle) instead of clustering wherever the mesh is dense. + """ + pts = owned.get(bone, []) + if len(pts) < 4: + return [] + inv = (arm.matrix_world @ arm.data.bones[bone].matrix_local).inverted() + local = [inv @ p for p in pts] + picked = [max(local, key=lambda v: v.length)] + while len(picked) < min(limit, len(local)): + far = max(local, key=lambda v: min((v - q).length for q in picked)) + if min((far - q).length for q in picked) < 1e-4: + break + picked.append(far) + return [[round(v.x, 5), round(v.y, 5), round(v.z, 5)] for v in picked] + + +# How far off the skin a garment sits — its own thickness, plus the fact that +# cloth drapes over a limb rather than being painted onto it. +CLOTH_CLEARANCE = 0.008 + + +def _pct(sorted_values, p): + """Value at percentile `p` of an already-sorted list.""" + i = int(round(p * (len(sorted_values) - 1))) + return sorted_values[max(0, min(len(sorted_values) - 1, i))] + + +def _body_points(meshes): + """Every skinned vertex that is NOT cloth, in world space. + + The waist lid is sized from these. Including the garment measured the skirt + itself — a 0.24 m radius that would have held it out in a bell. + """ + out = [] + for m in meshes: + gname = {g.index: g.name for g in m.vertex_groups} + mw = m.matrix_world + for v in m.data.vertices: + cloth_w = sum(g.weight for g in v.groups + if spring_class(gname.get(g.group, ""))) + if cloth_w < 0.35: + out.append(mw @ v.co) + return out + + +def _leg_colliders(arm, roles, owned, body_pts=None): + """TAPERED capsules for the legs, sized from the body geometry itself. + + The skirt has to be kept off the thighs, and a guessed radius either lets it + clip through or holds it out in a bell. Three things the obvious version got + wrong, all measured on Taila: + + * A limb is not a cylinder. Her thigh is ~0.10 m across at the hip and + ~0.055 m just above the knee, so one radius is either too fat at the knee + or too thin at the hip. Head and tail radii are stored separately and + interpolated at runtime. + * A leg's own vertices are not the leg. Most of the thigh belongs to the + TWIST bone (`DEF-thigh.L.001`); what is left dominated by `DEF-thigh.L` + is mostly the hip flare, which measured a 0.154 m radius — a 30 cm thigh. + Twist children are folded in. + * Neither a low percentile nor a high one works on a POOLED bucket. The + 70th tracked that flare; the median then left half the limb's surface + outside its own collider, so cloth pushed out to it was clear of the + capsule while the thigh was visibly through it in the render; and the + 88th over-measured the shaft by 30% because the top bucket still holds + the hip. Measured per band along the bone instead and fitted as the line + it actually is, dropping the contaminated end bands. + * Garments have thickness and hang OFF a leg rather than painted onto it, + so a small clearance is added on top. Without it the cloth's rest state + is exactly tangent to the limb and every frame is a contact. + """ + names = set(b.name for b in arm.data.bones) + out = [] + # A LID across the waist first. + # + # Magica Cloth 2's skirt guide is blunt about this: put "one big sphere + # collider on your waist", because it "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 being swung INWARD + # between the legs and ending up inside the pelvis, which is where several + # of the worst contacts here were sitting. + trunk = [roles.hips] + [n for n in roles.spine if n != roles.hips] + if len(trunk) >= 2 and trunk[0] in names and trunk[1] in names: + a = (arm.matrix_world @ arm.data.bones[trunk[0]].matrix_local).translation + b = (arm.matrix_world @ arm.data.bones[trunk[1]].matrix_local).translation + # Sized from the geometry that actually surrounds the pelvis, not from + # the hip bone's own vertices: on a Rigify rig the hips own almost + # nothing (2 vertices here, and spine.001 none) because the torso + # belongs to spine.002, so there is nothing there to measure. + ab = b - a + d2 = ab.dot(ab) + pts = [] + if d2 > 1e-9: + for p in (body_pts or []): + t = (p - a).dot(ab) / d2 + if 0.0 <= t <= 1.0: + pts.append(p) + if len(pts) >= 12: + rr = sorted(_seg_distance(p, a, b) for p in pts) + # 60th percentile, not the 90th used for limbs: the lid only has to + # stop cloth being swung INTO the body. Sized to the widest thing + # near the hips it would hold the whole skirt out in a bell. + r = _pct(rr, 0.60) + CLOTH_CLEARANCE + out.append({ + "bone": trunk[0], "child": trunk[1], + "from": 0.0, + "radius_head": round(r, 4), + "radius_tail": round(r, 4), + "radius": round(r, 4), + # A lid is something to stay OUT of, not a limb to be carried by. + "lid": True, + }) + + for role, child_role in (("thigh", "shin"), ("shin", "foot")): + for side in ("L", "R"): + name = roles.limb.get((role, side)) + child = roles.limb.get((child_role, side)) + if not name or not child: + continue + a = (arm.matrix_world @ arm.data.bones[name].matrix_local).translation + b = (arm.matrix_world @ arm.data.bones[child].matrix_local).translation + ab = b - a + d2 = ab.dot(ab) + if d2 < 1e-9: + continue + # This bone plus any twist segment hanging off it — together they + # are the limb. + pts = list(owned.get(name, [])) + for other in names: + if other != name and other.startswith(name + ".") \ + and is_segment_of(other, names): + pts.extend(owned.get(other, [])) + if len(pts) < 12: + continue + + # A limb is a TAPER, so measure it as one. Ten bands along the + # bone, the 90th percentile radius in each, and a least-squares + # line through them. Two pooled buckets could not do this: the top + # bucket is contaminated by the hip flare and the bottom one by the + # knee and boot, so whatever percentile was chosen came out wrong + # at one end or the other — a median under-measured the limb by + # half its surface, and a high percentile over-measured it by 30% + # along the whole shaft. The two end bands are dropped for exactly + # that reason; the fit extrapolates back through them. + bands = [[] for _ in range(10)] + for p in pts: + t = max(0.0, min(1.0, (p - a).dot(ab) / d2)) + bands[min(int(t * 10.0), 9)].append(_seg_distance(p, a, b)) + samples = [] + for k in range(1, 9): + if len(bands[k]) < 3: + continue + bands[k].sort() + samples.append(((k + 0.5) / 10.0, _pct(bands[k], 0.90))) + if len(samples) < 3: + continue + n = len(samples) + mt = sum(t for t, _ in samples) / n + mr = sum(r for _, r in samples) / n + den = sum((t - mt) ** 2 for t, _ in samples) + slope = sum((t - mt) * (r - mr) for t, r in samples) / den if den > 1e-9 else 0.0 + head = mr + slope * (0.0 - mt) + CLOTH_CLEARANCE + tail = mr + slope * (1.0 - mt) + CLOTH_CLEARANCE + # A limb never widens toward the joint below it, and a fit through + # noisy bands occasionally says otherwise. + tail = max(0.01, min(tail, head)) + head = max(head, tail) + # The capsule starts BELOW the hip joint. + # + # The top of a thigh is not a free limb, it is the hip, and it is + # buried inside the body the skirt hangs from. Colliding against it + # asks the solver for something it cannot do: those cloth points sit + # 20-30 mm from their own bone's head, and rotating a bone moves a + # point near its pivot by almost nothing — measured, 24 mm of lever + # against 86 mm of overlap, where the most any rotation can achieve + # is twice the lever. The solver spent all six passes saturated at + # its per-pass cap and still left 60-90 mm. + # + # Only the sphere cap buried in the pelvis is cut. The upper thigh + # itself stays covered, because the runtime can also SHIFT a chain + # bodily (SpringBones.PUSH_MAX) and a shift does not care how much + # lever the bone has: rotation handles the contacts with leverage, + # translation handles the ones without. Trimming 30% instead of 10% + # stopped the solver even trying across the top of the thigh, and + # that band is exactly what then showed through the skirt. + head_t = 0.10 + out.append({ + "bone": name, "child": child, + "from": head_t, + "radius_head": round(head + (tail - head) * head_t, 4), + "radius_tail": round(tail, 4), + # Kept so an older runtime still gets a usable single radius. + "radius": round(tail, 4), + }) + return out + + +def _cloth_neighbours(meshes, cloth_names): + """bone -> {neighbouring bone: how strongly they share the same mesh}. + + Two cloth bones are neighbours when the SAME VERTICES are weighted to both. + That is the only definition that matters here: a vertex driven half by one + skirt panel and half by the next is the piece of mesh that has to absorb any + difference between them, and linear-blend skinning absorbs it by pulling + itself apart. Measured with debug/cloth_stretch_check.gd, adjacent panels + taking drape shares of 0.85 and 0.48 stretched the edge between them to 3.3x + its rest length — an 80 mm hole in the front of the skirt, which is the + skirt "breaking" around the thigh rather than deforming over it. + + Adjacency by NAME or by rest distance would both be guesses; the artist + already answered the question in the weights. + """ + shared = defaultdict(lambda: defaultdict(float)) + for m in meshes: + gname = {g.index: g.name for g in m.vertex_groups} + for v in m.data.vertices: + here = [(gname.get(g.group, ""), g.weight) for g in v.groups + if gname.get(g.group, "") in cloth_names and g.weight > 0.05] + for a_name, aw in here: + for b_name, bw in here: + if a_name != b_name: + shared[a_name][b_name] += aw * bw + return {a: dict(d) for a, d in shared.items()} + + +def describe_rig(arm, roles, mapping, meshes): + """Record what we worked out, so the runtime never re-guesses anatomy.""" + names = set(b.name for b in arm.data.bones) + owned = _dominant_vertices(meshes, arm) + roles_out = {"hips": roles.hips, "head": roles.head, "neck": roles.neck, + "spine": list(roles.spine)} + for (role, side), name in roles.limb.items(): + roles_out[f"{role}.{side}"] = name + + driven = set(mapping) + chains = [] + springy = {b.name: b for b in arm.data.bones + if b.name not in driven + and spring_class(b.name)} + neighbours = _cloth_neighbours(meshes, set(springy)) + + for name, bone in springy.items(): + if bone.parent is not None and bone.parent.name in springy: + continue # not the root of a chain + # One chain per leaf path, so each strand solves independently. + stack = [[name]] + while stack: + path = stack.pop() + kids = [c.name for c in arm.data.bones[path[-1]].children + if c.name in springy] + if not kids: + tips = [] + hulls = [] + fallback = Vector((0.0, 0.0, -0.06)) + for i in range(len(path)): + t = _bone_tip(arm, path[i], path, i, owned, fallback) + tips.append([round(t.x, 5), round(t.y, 5), round(t.z, 5)]) + hulls.append(_hull_samples(arm, path[i], owned)) + fallback = t + chains.append({ + "class": spring_class(path[0]) or "cloth", + "root_parent": bone.parent.name if bone.parent else None, + "bones": path, + "tips": tips, + "hulls": hulls, + # Which other cloth bones share mesh with each of these, + # so the runtime can stop neighbours drifting apart. See + # _cloth_neighbours. + "neighbours": [neighbours.get(n, {}) for n in path], + }) + continue + for k in kids: + stack.append(path + [k]) + + # Twist bones only — a hair link is also `X.001`, but it is cloth, and + # listing it here would have the twist distributor and the spring solver + # both writing the same bone. + twist = [] + for b in arm.data.bones: + if b.name in driven or is_cosmetic(b.name) or not is_segment_of(b.name, names): + continue + if b.parent is not None: + twist.append({"bone": b.name, "parent": b.parent.name, + "child": b.children[0].name if b.children else None}) + # Fingers, so the runtime can close a hand around a grip without knowing + # how this rig spells "index". Ordered knuckle -> fingertip. + finger_out = {} + for (digit, side), bones in roles.fingers.items(): + finger_out["%s.%s" % (digit, side)] = bones + + # What each surface IS — body, cloth, hair or accessory. Derived from the + # chains resolved just above, so the surface table and the cloth solver can + # never disagree about which bones are a skirt. See tools/surface_map.py. + chain_class = {} + for chain in chains: + for bone in chain["bones"]: + chain_class[bone] = chain["class"] + role_bone_names = [n for n in roles_out.values() if isinstance(n, str) and n] + for value in roles_out.values(): + if isinstance(value, list): + role_bone_names.extend(value) + + return {"roles": roles_out, "fingers": finger_out, + "chains": chains, "twist": twist, + "surfaces": surface_map.describe_surfaces( + meshes, chain_class, driven, role_bone_names), + "colliders": _leg_colliders( + arm, roles, _dominant_vertices(meshes, arm, min_weight=0.6), + _body_points(meshes)), + "weights_authored": _weights_look_authored(meshes, roles), + "driven_bones": sorted(driven)} + + +def _weights_look_authored(meshes, roles): + """Were these weights painted, or solved by a nearest-bone fit? + + The runtime decides from this whether to run its destructive load-time + weight repair, so it is MEASURED rather than inferred from which pipeline + branch ran — a model that arrives unrigged still goes through autorig and + out through this same tool, and must not be handed a sidecar that says its + weights are fine when they are not. + + Two signatures, both taken from the shipped-vs-source comparison that + started this rework: the nearest-four-bones fit left 16% of vertices pulled + by BOTH legs and gave 86% of them the full four influences, where the + artist's own weights had 0.1% and 26%. + """ + legs = {} + for (role, side), name in roles.limb.items(): + if role in ("thigh", "shin", "foot", "toe"): + legs[name] = -1 if side == "L" else 1 + + def side_of(group_name): + for name, s in legs.items(): + if group_name == name or group_name.startswith(name + "."): + return s + return 0 + + total = 0 + bleeding = 0 + four = 0 + for m in meshes: + gside = {g.index: side_of(g.name) for g in m.vertex_groups} + for v in m.data.vertices: + wl = wr = 0.0 + n = 0 + for g in v.groups: + if g.weight <= 0.005: + continue + n += 1 + s = gside.get(g.group, 0) + if s == -1: + wl += g.weight + elif s == 1: + wr += g.weight + total += 1 + if n >= 4: + four += 1 + if wl > 0.005 and wr > 0.005: + bleeding += 1 + if total == 0: + return False + authored = bleeding / total < 0.02 and four / total < 0.5 + print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), " + f"{four / total * 100:.0f}% at four influences — " + f"{'authored' if authored else 'solved, runtime repair stays on'}") + return authored + + +# ------------------------------------------------------------------------ main + + +def main(): + clear_scene() + print(f"Importing character {CHARACTER}") + # Normalise emissive-albedo materials FIRST. Blender honours + # KHR_materials_unlit and reads only base colour, so an unlit model with its + # texture in the emissive slot imports with no images at all and exports a + # black silhouette — there is no node graph left to repair afterwards. + import_any(gltf_fix.prepare(CHARACTER, tempfile.gettempdir())) + arms = [o for o in bpy.data.objects if o.type == "ARMATURE"] + if not arms: + print("ERROR: character has no armature. Rig it first (see docs/3D_ASSET_PIPELINE.md).") + sys.exit(1) + arm = max(arms, key=lambda a: len(a.data.bones)) + meshes = skinned_meshes(arm) + if not meshes: + print("ERROR: no skinned meshes bound to the armature") + sys.exit(1) + for o in [o for o in bpy.data.objects if o.type == "MESH" and o not in meshes]: + print(f"Dropping unskinned prop mesh: {o.name}") + bpy.data.objects.remove(o, do_unlink=True) + print(f"Character: {len(arm.data.bones)} bones, {len(meshes)} meshes " + f"({sum(len(m.data.vertices) for m in meshes)} verts) — weights kept as authored") + + strip_import_suffixes(arm, meshes) + for a in list(bpy.data.actions): + bpy.data.actions.remove(a) # the character's own clip is not ours + + roles = RigRoles(arm) + missing = roles.missing_core() + if missing: + print(f"ERROR: could not identify these bones on the character rig: {missing}") + print("Resolved so far:\n" + roles.describe()) + sys.exit(1) + rebuild_hierarchy(arm, roles) + subdivide_cloth_panels(arm, meshes, roles) + unbind_cloth_from_legs(arm, meshes, roles) + flatten_and_scale(arm, meshes, TARGET_HEIGHT, roles) + fix_unlit_materials(meshes) + roles = RigRoles(arm) # rest positions moved; re-read + + library = os.path.join(ANIM_DIR, "_library.glb") + if not os.path.exists(library): + print(f"ERROR: animation library not found: {library}") + sys.exit(1) + before = set(bpy.data.objects) + before_actions = set(bpy.data.actions) + bpy.ops.import_scene.gltf(filepath=library) + new_objects = [o for o in bpy.data.objects if o not in before] + src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None) + if not src_arm: + print("ERROR: no armature in the animation library") + sys.exit(1) + src_roles = RigRoles(src_arm) + + mapping = build_map(src_roles, roles, OVERRIDES) + print("\nLibrary rig:\n" + src_roles.describe()) + print("\nCharacter rig:\n" + roles.describe()) + print(f"\nDriving {len(mapping)} of {len(arm.data.bones)} bones from the library; " + f"{len(arm.data.bones) - len(mapping)} left free for secondary motion.") + + yaw = facing_correction(src_arm, src_roles, arm, roles) + src_h = (src_arm.matrix_world @ src_arm.data.bones[src_roles.hips].matrix_local).translation.z + tgt_h = (arm.matrix_world @ arm.data.bones[roles.hips].matrix_local).translation.z + scale = tgt_h / src_h if src_h > 1e-5 else 1.0 + print(f"Hips height: library {src_h:.3f} m, character {tgt_h:.3f} m (scale {scale:.3f})") + + if not arm.animation_data: + arm.animation_data_create() + for track in list(arm.animation_data.nla_tracks): + arm.animation_data.nla_tracks.remove(track) + + merged = 0 + kept_clips = set() + for action in [a for a in bpy.data.actions if a not in before_actions]: + clip = LIBRARY_CLIP_MAP.get(action.name.split(".")[0]) + if not clip: + continue + print(f" {action.name} -> {clip}") + baked = retarget_clip(src_arm, src_roles, arm, roles, mapping, action, + clip, yaw, scale) + add_nla_clip(arm, baked, clip) + kept_clips.add(clip) + merged += 1 + if merged == 0: + print("ERROR: no clips retargeted") + sys.exit(1) + + # Drop anything animated that we did not put here. + # + # Clearing bpy.data.actions before the library import is not enough: a + # character can carry its own clip in an NLA track that survives, and it is + # then exported alongside ours. `hikari` shipped two — " Girl|Loli Anime + # GirlAction" — which are keyed for a rig that no longer exists after the + # retarget, so they export as frozen rest-pose statues and the build fails a + # check that is right to fail it. + # Across EVERY object, not just the armature we retargeted onto. The export + # runs in NLA_TRACKS mode, so anything sitting in an NLA track anywhere in + # the file ships — including a second armature the model happened to carry, + # which is where hikari's two were hiding. + def _strip_foreign(ad): + if ad is None: + return + for track in list(ad.nla_tracks): + if not any(st.action and st.action.name in kept_clips + for st in track.strips): + ad.nla_tracks.remove(track) + if ad.action is not None and ad.action.name not in kept_clips: + ad.action = None + + for obj in list(bpy.data.objects): + _strip_foreign(obj.animation_data) + if obj.type == "MESH" and obj.data.shape_keys: + _strip_foreign(obj.data.shape_keys.animation_data) + for a in list(bpy.data.actions): + if a.name not in kept_clips: + a.use_fake_user = False + bpy.data.actions.remove(a) + print(f"Kept {len(kept_clips)} clips; dropped everything else") + + for o in new_objects: + bpy.data.objects.remove(o, do_unlink=True) + + os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True) + sidecar = os.path.splitext(OUTPUT)[0] + ".rig.json" + info = describe_rig(arm, roles, mapping, meshes) + with open(sidecar, "w", encoding="utf-8") as f: + json.dump(info, f, indent=2) + print(f"Wrote {os.path.basename(sidecar)}: {len(info['chains'])} cloth chains, " + f"{len(info['twist'])} twist bones, {len(info['colliders'])} leg colliders") + + print(f"Merged {merged} clips. Exporting {OUTPUT}") + bpy.ops.export_scene.gltf( + filepath=OUTPUT, + export_format="GLB", + export_yup=True, + export_apply=False, + export_skins=True, + export_animations=True, + export_animation_mode="NLA_TRACKS", + # Export ONLY the bones the clips actually key. Baking every bone + # writes rest-pose tracks for the skirt and hair too, which triples the + # clip data and — worse — has the AnimationPlayer overwrite exactly the + # bones the spring solver is meant to own. + export_bake_animation=False, + export_optimize_animation_size=True, + # "keep_anim_armature" is what forces a track onto every bone whether + # or not the clip touches it. Off, so the skirt and hair export with no + # tracks at all and belong entirely to the spring solver. + export_optimize_animation_keep_anim_armature=False, + ) + print("Done.") + + + + +def subdivide_cloth_panels(arm, meshes, roles, segments=4): + """Split single-bone cloth panels into a CHAIN so they can bend. + + A skirt panel modelled as one rigid bone off the waist cannot ride up over a + thigh, because riding up is a FOLD: the top of the panel stays at the waist + while the lower part lifts. One bone can only rotate the whole sheet about + the waist, so the hem swings forward while the middle stays put and the + thigh comes straight through it. No amount of collision or drape strength + fixes that — the degree of freedom does not exist. + + Taila's skirt is 21 such bones (see rebuild_hierarchy: the panels export + flat, and their real 3-segment chains are not recoverable from the file). + So the segments are BUILT here: each panel bone gains `segments - 1` + children strung along the direction it actually covers, and the vertices it + drives are handed to whichever segment spans them. The runtime spring + solver already treats chains properly, so the lower segment can lift over + the leg while the top stays anchored. + + SIX segments, not three. Magica Cloth 2's skirt guide: "if the bone position + deviates from the shape of the skirt, or if the bones are spaced too far + apart, the accuracy of collision detection will be significantly reduced." + That is measurable here — a collision is resolved by ROTATING a bone, which + moves a point at most twice its distance from that bone's head, and with + three segments the front panels had contacts sitting 10-48 mm from their own + pivot against a thigh 100 mm inside them. No amount of tuning can clear that; + the pivot has to be closer to the contact. + + Runs before flatten_and_scale, in the source's own units. + """ + owned = _dominant_vertices(meshes, arm) + panels = [] + for bone in arm.data.bones: + if not is_cosmetic(bone.name): + continue + cls = spring_class(bone.name) + if cls is None or cls == "hair": + continue + if bone.children: + continue # already a chain — the artist's own topology wins + pts = owned.get(bone.name, []) + if len(pts) < 12: + continue + rest = arm.matrix_world @ bone.matrix_local + tip = _bone_tip(arm, bone.name, [bone.name], 0, owned, + Vector((0.0, 0.0, -0.06))) + if tip.length < 1e-4: + continue + panels.append((bone.name, rest, tip)) + if not panels: + return 0 + + bpy.context.view_layer.objects.active = arm + bpy.ops.object.mode_set(mode="EDIT") + eb = arm.data.edit_bones + made = {} + for name, rest, tip_local in panels: + parent = eb[name] + head = parent.head.copy() + step = (rest.to_3x3() @ tip_local) / segments + chain = [name] + prev = parent + for s in range(1, segments): + seg = eb.new("%s.seg%d" % (name, s)) + seg.head = head + step * s + seg.tail = head + step * (s + 1) + seg.parent = prev + seg.use_connect = False + prev = seg + chain.append(seg.name) + parent.tail = head + step + made[name] = chain + bpy.ops.object.mode_set(mode="OBJECT") + + # Spread each vertex across the TWO nearest segments, so the panel bends + # where the geometry is and the mesh stays continuous while it does. + # + # Handing every vertex to exactly one segment puts a hard cut through the + # sheet: the vertices either side of a segment boundary are then driven + # 100% by different bones, and the moment those bones rotate apart the mesh + # opens along that line. Measured with debug/cloth_stretch_check.gd over a + # movement sweep, an edge inside DEF-skirt.L.003 grew 270 mm — 11.7x its own + # rest length. That is the skirt visibly BREAKING around a thigh instead of + # stretching over it, and it looks nothing like a collision bug. + # + # Weighting to the two nearest segment CENTRES instead makes the transition + # linear, which is the same trick that stops a limb tearing at an elbow. + for m in meshes: + for name, chain in made.items(): + vg = m.vertex_groups.get(name) + if vg is None: + continue + groups = [vg] + [m.vertex_groups.new(name=n) for n in chain[1:]] + rest = arm.matrix_world @ arm.data.bones[name].matrix_local + origin = rest.translation + axis = (rest.to_3x3() @ _bone_tip( + arm, name, [name], 0, owned, Vector((0.0, 0.0, -0.06)))) + span = axis.length + if span < 1e-5: + continue + axis = axis / span + mw = m.matrix_world + for v in m.data.vertices: + w = 0.0 + for g in v.groups: + if g.group == vg.index: + w = g.weight + if w <= 0.0: + continue + t = max(0.0, min(1.0, (mw @ v.co - origin).dot(axis) / span)) + # Position measured in segment-CENTRE space: the ends of the + # panel sit on one segment outright, everything between is a + # blend of the two it lies between. + u = t * segments - 0.5 + lo = int(math.floor(u)) + f = u - lo + if lo < 0: + lo, f = 0, 0.0 + elif lo >= segments - 1: + lo, f = segments - 1, 0.0 + groups[0].remove([v.index]) + if w * (1.0 - f) > 1e-5: + groups[lo].add([v.index], w * (1.0 - f), "REPLACE") + if f > 1e-5: + groups[lo + 1].add([v.index], w * f, "REPLACE") + print(f"Subdivided {len(made)} cloth panels into {segments} segments each") + return len(made) + + +def unbind_cloth_from_legs(arm, meshes, roles): + """Take the LIMBS out of any vertex the cloth chains own. + + A source model auto-weighted in Rigify leaves a band of skirt vertices + partly weighted to the thigh, and the vertices immediately next to them not + at all. Nothing about that is smooth: when the thigh swings, one vertex + follows it and its neighbour does not, and the mesh splits along the join. + Measured with debug/cloth_stretch_check.gd during a slide, two adjacent + front-panel vertices — identical weights except that one carried + DEF-thigh.L at 0.24 — pulled 80 mm apart, 3.3x their rest length. 0.24 of + the thigh's ~0.35 m of travel is 84 mm, so that is the whole of it. On + screen it is the skirt "breaking" around the thigh instead of stretching + over it. + + Smoothing the field instead of deleting it does NOT work, and the arithmetic + says why: differential motion is the weight GRADIENT times how far the limb + travels. Holding an edge to under 10 mm against a thigh that moves 350 mm + needs less than 0.03 of weight difference across it, and this skirt's edges + are ~48 mm long, so the falloff would have to span most of the character. + Tried it — a 1.7-radius falloff put 0.24 on one vertex and 0.00 on its + neighbour and tore by 95 mm. Any leg weighting on this cloth tears. + + Which is the project's own rule anyway: cloth moves by spring bones, never + by weights. Keeping the leg out of the weights is also what leaves the + spring solver able to move these vertices at all — see the note below on + bind_cloth_to_legs, which was this same mistake made deliberately. + """ + limbs = set() + for (role, side), name in roles.limb.items(): + if role not in ("thigh", "shin", "foot"): + continue + limbs.add(name) + # ...and the twist segments hanging off them, which are the same limb. + for b in arm.data.bones: + if b.name != name and b.name.startswith(name + "."): + limbs.add(b.name) + cloth = set(b.name for b in arm.data.bones + if any(t in SPRING_CLASSES for t in tokens(b.name))) + if not limbs or not cloth: + return 0 + + freed = 0 + for m in meshes: + gname = {g.index: g.name for g in m.vertex_groups} + for v in m.data.vertices: + cloth_w = 0.0 + limb_w = 0.0 + for g in v.groups: + n = gname.get(g.group, "") + if n in cloth: + cloth_w += g.weight + elif n in limbs: + limb_w += g.weight + # The cloth has to actually own the vertex. A thigh vertex with a + # little skirt on it is a LEG and must keep following the leg. + if limb_w <= 0.0 or cloth_w < 0.5 or cloth_w <= limb_w: + continue + scale = (cloth_w + limb_w) / cloth_w + for g in list(v.groups): + n = gname.get(g.group, "") + if n in limbs: + m.vertex_groups[g.group].remove([v.index]) + elif n in cloth: + m.vertex_groups[g.group].add( + [v.index], min(1.0, g.weight * scale), "REPLACE") + freed += 1 + print(f"Freed {freed} cloth vertices from the legs they were skinned to") + return freed + + +# Cloth is NOT skinned to the legs. +# +# There was a bind_cloth_to_legs() here that gave every cloth vertex near a +# thigh a share of that thigh, so the skirt would ride the leg the way a real +# one does. It was written because the drape and collision in +# characters/spring_bones.gd appeared to do nothing however hard they were +# driven — and they did nothing because of two bugs since fixed there (the +# drape rotated the spring's target but not the bone, so it cancelled out at +# equilibrium; and the collision's answer was read back off the bone tip, which +# discards the twist that is most of what lifts a sheet off a leg). +# +# With those fixed the binding is actively harmful. 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 the leg — and the leg still overtakes it, because 0.9 of a +# rotation always lags the surface doing 1.0 of it. It also poisoned the +# collider measurement below: 2258 skirt vertices counted as thigh geometry and +# fitted a 0.28 m thigh. +# +# Measured on the run cycle with debug/skirt_clip_view.gd, which paints cloth +# magenta so leg-in-front-of-cloth is unambiguous: removing it made the frames +# it was supposed to fix cleaner, not worse. + + +main() diff --git a/tools/rig_map.py b/tools/rig_map.py new file mode 100644 index 0000000..968b4a1 --- /dev/null +++ b/tools/rig_map.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +""" +Work out which bone on one rig means the same thing as which bone on another. + +This is the piece that lets us STOP throwing away a character's own skeleton. +The old pipeline discarded any foreign rig (tools/strip_rig.py) because +merge_animations.py retargeted by exact bone NAME, so a rig that named things +differently produced a rest-pose statue. Rebinding the mesh to the library +skeleton then destroyed everything an artist had done: per-part weights, skirt +and hair bone chains, limb twist bones. Solving the naming problem here is what +makes keeping the original rig possible. + +Roles are resolved STRUCTURALLY wherever a name would lie: + + * `hips` is the lowest common ancestor of both thighs and the head, not + whatever is called "hips". Rigify calls it `DEF-spine`; Mixamo calls it + `mixamorig:Hips`; both land on the same bone this way. + * The spine chain is walked from the hips upward, refusing to turn down a + limb or a cosmetic chain (hair/skirt/face). The bone it ends on is the + head. That matters because a stock Rigify rig has NO bone with "head" in + its name — the head is `DEF-spine.006`. + * Chains of different length are matched by normalised position along the + chain, so a 4-bone torso drives a 3-bone one and vice versa. + +Only bones the CLIPS need are mapped. Everything else on the character rig — +skirt chains, hair chains, twist bones, face bones — is deliberately left +unmapped so it rests relative to its parent and is free to be driven by +secondary motion at runtime. That is the whole point: the locomotion library +animates the body, physics animates the cloth. +""" +import re + +# Names that are never part of the body proper. Walking the spine must not turn +# down one of these, and they must never claim a limb role. +# +# Matched as whole NAME TOKENS, never as substrings. A plain `"ear" in name` +# test marks every `DEF-forearm.L` cosmetic — which silently cost both rigs +# their forearms and is exactly the class of bug this file exists to avoid. +COSMETIC = ("hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat", + "scarf", "sleeve", "breast", "bust", "ear", "horn", "wing", + "face", "cheek", "nose", "mouth", "eye", "brow", "jaw", "tongue", + "teeth", "tooth", "lip", "chin", "accessory", "prop", "weapon", + "bell", "strap", "belt", "buckle", "feather", "antenna") + +# role -> ordered alternative stems. Ordered because "leg" must not win before +# "upleg" has had its chance: Mixamo's LeftUpLeg is a thigh and its LeftLeg is +# a shin, so the more specific spelling has to be tested first. +LIMB_ROLES = { + "thigh": ("upleg", "upperleg", "thigh", "leg_upper", "upper_leg", "hip"), + "shin": ("lowerleg", "lowleg", "shin", "calf", "knee", "leg_lower", "leg"), + "foot": ("foot", "ankle"), + "toe": ("toebase", "toe", "ball"), + "shoulder": ("shoulder", "clavicle", "collar"), + "upper_arm": ("upperarm", "upper_arm", "arm_upper", "armupper", "arm"), + "forearm": ("forearm", "lowerarm", "lowarm", "arm_lower", "elbow"), + "hand": ("hand", "wrist"), +} +# Longest-first inside each role, so "upperarm" is tried before "arm". +LIMB_ORDER = ["toe", "foot", "shin", "thigh", "shoulder", "hand", "forearm", "upper_arm"] + +# digit -> spellings. Every rig met so far uses one of three families: +# Rigify DEF-f_index.01.L canon "findex01l" +# VRoid J_Bip_L_Index1 canon "index1" +# Blender IndexFinger1_L canon "indexfinger1l" +# so a substring test on the canon form covers all of them. The pinky is called +# "little" on VRoid and half the Blender exports. +DIGITS = { + "thumb": ("thumb",), + "index": ("index",), + "middle": ("middle",), + "ring": ("ring",), + "pinky": ("pinky", "little"), +} + +_PREFIXES = re.compile( + r"^(def[-_]|org[-_]|mch[-_]|ctrl[-_]|mixamorig\d*[:_]|bip\d*[-_ ]|" + r"j_bip_[clr]_|j_sec_[clr]_|valvebiped\.|bone_|b_)", re.I) +# The glTF importer appends _ to every bone name; strip it so +# `DEF-spine.006_2` reads as `DEF-spine.006`. +_GLTF_SUFFIX = re.compile(r"_\d+$") + + +def strip_gltf_suffix(name: str) -> str: + return _GLTF_SUFFIX.sub("", name) + + +def canon(name: str) -> str: + """Bone name reduced to a comparable stem: no rig prefix, no separators.""" + n = strip_gltf_suffix(name).lower() + while True: + stripped = _PREFIXES.sub("", n) + if stripped == n: + break + n = stripped + return re.sub(r"[^a-z0-9]", "", n) + + +def side_of(name: str): + """'L', 'R' or None. Checked on the ORIGINAL name so `.L` survives.""" + n = strip_gltf_suffix(name).lower() + if re.search(r"(^|[._\- ])l($|[._\- 0-9])", n) or "left" in n: + return "L" + if re.search(r"(^|[._\- ])r($|[._\- 0-9])", n) or "right" in n: + return "R" + return None + + +def tokens(name: str): + """Name split into alphabetic words: `DEF-hair.L.001` -> def, hair, l.""" + return [t for t in re.split(r"[^a-z]+", strip_gltf_suffix(name).lower()) if t] + + +def is_cosmetic(name: str) -> bool: + """Whole-token match, plus a short positional suffix — see COSMETIC. + + A bare token match misses the very common habit of gluing a position onto + the word: DANDADAN's hair bones are HairFL / HairFR / HairF_Top (front-left, + front-right, front-top), which tokenise to "hairfl" and match nothing, so + her hair was not detected as hair at all and none of it moved. + + Only a SHORT remainder counts — two characters at most, which covers l/r/f/b + and the fl/fr/bl/br pairs and nothing else. That is what keeps the original + rule intact: "forearm" still does not begin with any cosmetic stem, and + "earring" has a four-character remainder and is not swept in by accident. + """ + for t in tokens(name): + if t in COSMETIC: + return True + for c in COSMETIC: + if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c): + return True + return False + + +def is_segment_of(name: str, all_names: set) -> bool: + """True for twist/segment bones like `DEF-upper_arm.L.001`. + + Rigify subdivides a limb into a main bone plus numbered twist bones. Those + must never claim the limb's role — the clip would drive the twist bone and + the real limb would stay put. Detected by construction rather than by a + name list: strip a trailing `.001` and see whether the parent spelling is + itself a bone on this rig. + """ + base = strip_gltf_suffix(name) + m = re.match(r"^(.*)\.(\d{3})$", base) + if not m or m.group(2) == "000": + return False + stem = m.group(1) + return any(strip_gltf_suffix(n) == stem for n in all_names) + + +class RigRoles: + """Resolved anatomy of one armature.""" + + def __init__(self, arm): + self.arm = arm + self.bones = {b.name: b for b in arm.data.bones} + self.names = set(self.bones) + self.limb = {} # ("thigh", "L") -> bone name + self.fingers = {} # ("index", "L") -> [proximal, middle, distal] + self.spine = [] # hips-exclusive chain, ordered, ending on the head + self.hips = None + self.head = None + self.neck = None + self._resolve_limbs() + self._fill_limbs_by_anatomy() + self._resolve_spine() + self._resolve_fingers() + + # ---------------------------------------------------------------- limbs + + def _resolve_limbs(self): + """Claim limb roles by the MOST SPECIFIC spelling that matches. + + Not by walking LIMB_ORDER and taking the first role that matches at all. + That let a catch-all stem in an early role beat an exact one in a later + role, and the result depended on the order bones happened to arrive in: + + J_Bip_L_UpperLeg canon "upperleg" + shin matches "leg" <- claimed it, because shin is first + thigh matches "upperleg" <- never consulted + J_Bip_L_LowerLeg canon "lowerleg" + shin already taken; nothing in thigh matches -> unclaimed + + so a VRoid rig resolved with no thighs at all and the build stopped. The + same trap catches Mixamo, whose LeftUpLeg is a thigh and LeftLeg a shin — + it stayed hidden only because every character imported so far used the + Rigify DEF- spellings, where each role has an exact stem of its own. + + Every plausible claim is collected, then granted longest-stem first, so + "upperleg" always beats "leg" whatever order the bones are in. + """ + claims = [] + for name in self.names: + if is_cosmetic(name) or is_segment_of(name, self.names): + continue + side = side_of(name) + if side is None: + continue + stem = canon(name) + for role in LIMB_ORDER: + hits = [alt.replace("_", "") for alt in LIMB_ROLES[role] + if alt.replace("_", "") in stem] + if hits: + claims.append((len(max(hits, key=len)), role, side, name)) + # Longest stem wins; LIMB_ORDER breaks ties, so a bone matching two roles + # equally well still resolves the way the list intends. + order = {role: i for i, role in enumerate(LIMB_ORDER)} + claims.sort(key=lambda c: (-c[0], order[c[1]])) + used = set() + for _, role, side, name in claims: + if (role, side) in self.limb or name in used: + continue + self.limb[(role, side)] = name + used.add(name) + + # A leg is thigh -> shin -> foot, whatever the bones are called. Walk it. + # + # Names alone cannot settle this and it is not a matter of adding more + # spellings. A bare "leg" is the SHIN on a Mixamo rig (whose thigh is + # "UpLeg") and the THIGH on a rig whose shin is called "knee" — the same + # token means opposite bones, and both rigs are common. What does not vary + # is the skeleton: the foot's parent is the shin and the shin's parent is + # the thigh. + # + # Only ever FILLS IN what the names could not resolve; a confident name + # match is never overridden. Twist bones are stepped over, since a rig may + # put one between the thigh and the shin. + def _fill_limbs_by_anatomy(self): + for side in ("L", "R"): + chain = [] + b = self.bones.get(self.limb.get(("foot", side), "")) + while b is not None and len(chain) < 6: + b = b.parent + if b is None or b.name not in self.names: + break + if is_cosmetic(b.name) or is_segment_of(b.name, self.names): + continue # a twist segment is not a joint of its own + chain.append(b.name) + # chain is now [shin, thigh, hips, ...] going up from the foot. + for role, up in (("shin", 0), ("thigh", 1)): + if (role, side) in self.limb or up >= len(chain): + continue + name = chain[up] + # Never claim a bone another role already owns, and never claim + # the trunk — a two-bone leg would otherwise take the hips. + if name in self.limb.values(): + continue + if side_of(name) != side: + continue + self.limb[(role, side)] = name + + # -------------------------------------------------------------- fingers + + def _resolve_fingers(self): + """digit+side -> its bones, ordered from the knuckle outwards. + + Ordered by DEPTH BELOW THE HAND rather than by the number in the name. + The numbering is not consistent — Rigify counts .01/.02/.03, VRoid counts + 1/2/3, and some exports number from the tip — but the hierarchy always + runs knuckle to fingertip, so walking it is the only spelling-independent + way to know which segment is which. + """ + for side in ("L", "R"): + hand = self.limb.get(("hand", side)) + if not hand or hand not in self.bones: + continue + depth = {} + + def walk(b, d): + depth[b.name] = d + for c in b.children: + walk(c, d + 1) + + for c in self.bones[hand].children: + walk(c, 0) + for digit, stems in DIGITS.items(): + got = [n for n in depth + if any(st in canon(n) for st in stems) + and side_of(n) == side] + if got: + self.fingers[(digit, side)] = sorted(got, key=lambda n: depth[n]) + + # ---------------------------------------------------------------- spine + + def _resolve_spine(self): + """Find the trunk as the LONGEST chain of non-limb, non-cosmetic bones. + + Deliberately not "lowest common ancestor of the two thighs", which is + the obvious thing and is wrong on the rigs we actually get. A Rigify + DEF-rig exports its chain ROOTS parented straight to the armature root + (Rigify drives them by constraint, not by hierarchy), so on Taila the + thighs, shoulders, skirt and hair all hang off `rootJoint` and the LCA + is that root — which makes the hips the root bone and the spine one + bone long. Taking the longest trunk chain instead reads the same rig + correctly whether or not its hierarchy survived export. + """ + blocked = set(self.limb.values()) + + def candidate(name): + if name in blocked or is_cosmetic(name): + return False + return not any(t in ("root", "master", "armature", "scene", "rootjoint") + for t in tokens(name)) + + def walk(start): + chain = [start] + cur = self.bones[start] + while True: + nxt = [c for c in cur.children if candidate(c.name)] + if not nxt: + break + # If the walk forks, follow whichever branch reaches highest — + # the torso continues upward, a stray nub does not. + cur = max(nxt, key=self._branch_height) + chain.append(cur.name) + return chain + + best = [] + for name in self.names: + if not candidate(name): + continue + parent = self.bones[name].parent + if parent is not None and candidate(parent.name): + continue # not the base of a chain + chain = walk(name) + if len(chain) > len(best): + best = chain + if not best: + return + + self.hips = best[0] + self.spine = best[1:] + if self.spine: + self.head = self.spine[-1] + named = [n for n in self.spine if "neck" in tokens(n)] + self.neck = named[0] if named else ( + self.spine[-2] if len(self.spine) > 1 else None) + + def _branch_height(self, bone): + """How high this branch reaches, in WORLD space. + + Measured on bone HEADS. glTF has no concept of a bone tail — joints are + just nodes — so the tails Blender's importer shows are synthesised, and + on Taila every skirt bone comes back with an identical 0.78 m tail. + Heads are the only authored positions here. + + World space, not armature-local: the importer leaves the Y-up-to-Z-up + correction on the armature OBJECT, so a local-space test picked the + pelvis over the spine and the walk stopped one bone in. + """ + mw = self.arm.matrix_world + best = (mw @ bone.matrix_local.translation).z + for c in bone.children: + best = max(best, self._branch_height(c)) + return best + + # --------------------------------------------------------------- report + + def missing_core(self): + need = [("thigh", "L"), ("thigh", "R"), ("shin", "L"), ("shin", "R"), + ("foot", "L"), ("foot", "R"), ("upper_arm", "L"), ("upper_arm", "R"), + ("forearm", "L"), ("forearm", "R"), ("hand", "L"), ("hand", "R")] + miss = [f"{r}.{s}" for r, s in need if (r, s) not in self.limb] + if not self.hips: + miss.append("hips") + if not self.head: + miss.append("head") + return miss + + def describe(self): + lines = [f" hips {self.hips}", f" spine {' -> '.join(self.spine)}"] + for key in sorted(self.limb): + lines.append(f" {key[0]}.{key[1]:<10s} {self.limb[key]}") + return "\n".join(lines) + + +def _match_chains(src_chain, tgt_chain): + """Pair up two ordered chains of possibly different length. + + Each target bone takes the source bone nearest it in NORMALISED position, + so a 4-bone torso can be driven by a 3-bone one. Because the retarget + copies ABSOLUTE world orientation rather than composing local rotations, + two target bones sharing one source bone simply end up parallel — the chain + still finishes where the source says it does, it does not double the bend. + """ + pairs = [] + if not src_chain or not tgt_chain: + return pairs + for i, tgt in enumerate(tgt_chain): + t = (i + 0.5) / len(tgt_chain) + j = min(range(len(src_chain)), + key=lambda k: abs((k + 0.5) / len(src_chain) - t)) + pairs.append((src_chain[j], tgt)) + return pairs + + +def build_map(src: RigRoles, tgt: RigRoles, overrides: dict = None) -> dict: + """target bone name -> source bone name, for the core body only.""" + mapping = {} + + if src.hips and tgt.hips: + mapping[tgt.hips] = src.hips + + # Split both spines at the neck so a head never drives a chest, then match + # torso-to-torso and neck-to-neck by position. + def split(roles): + chain = roles.spine + if not chain: + return [], [], None + head = chain[-1] + rest = chain[:-1] + if roles.neck and roles.neck in rest: + i = rest.index(roles.neck) + return rest[:i], rest[i:], head + return rest, [], head + + s_torso, s_neck, s_head = split(src) + t_torso, t_neck, t_head = split(tgt) + for a, b in _match_chains(s_torso, t_torso): + mapping[b] = a + for a, b in _match_chains(s_neck or s_torso[-1:], t_neck): + mapping[b] = a + if s_head and t_head: + mapping[t_head] = s_head + + for key, tgt_name in tgt.limb.items(): + src_name = src.limb.get(key) + if src_name: + mapping[tgt_name] = src_name + + # Fingers and anything else that happens to share a spelling: map by + # canonical stem + side. Cheap, and it makes a shared trigger-finger pose + # come across when both rigs have fingers. + src_by_stem = {} + for n in src.names: + src_by_stem.setdefault((canon(n), side_of(n)), n) + for n in tgt.names: + if n in mapping or is_cosmetic(n) or is_segment_of(n, tgt.names): + continue + hit = src_by_stem.get((canon(n), side_of(n))) + if hit: + mapping[n] = hit + + if overrides: + for tgt_name, src_name in overrides.items(): + if src_name is None: + mapping.pop(tgt_name, None) + else: + mapping[tgt_name] = src_name + return mapping diff --git a/tools/strip_rig.py b/tools/strip_rig.py index e1b2c73..b8096df 100644 --- a/tools/strip_rig.py +++ b/tools/strip_rig.py @@ -1,5 +1,19 @@ #!/usr/bin/env python3 """ +DEPRECATED — do not use. Kept only to explain what it did and why it was wrong. + +This discarded a character's skeleton so autorig.py could refit the library one. +It "solved" a bone-NAMING problem by destroying the asset: on Taila it cost 21 +skirt bones, ~50 hair bones, 8 limb twist bones, split 18 per-part meshes into +one blob, and turned 17 both-legs-at-once vertices into 2817. Every runtime +weight repair in characters/skin_leg_repair.gd exists to undo its output. + +The naming problem is solved properly in tools/rig_map.py, which pairs two +skeletons by resolved ROLE instead of by name, so tools/retarget.py can move the +animation library onto a character's own rig and keep everything. Use that. + +Original description follows. + Strip an existing rig from a character GLB so tools/autorig.py re-rigs it on the animation-library skeleton. diff --git a/tools/surface_map.py b/tools/surface_map.py new file mode 100644 index 0000000..1f17928 --- /dev/null +++ b/tools/surface_map.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +""" +Say what every surface of a character IS: body, cloth, hair or accessory. + +The pipeline already refuses to join meshes, so a character arrives at runtime +as the eighteen separate pieces the artist authored. But nothing recorded what +those pieces WERE, so every system downstream had to re-guess from scratch: +`LevelMaterials.apply_character_look` decides "is this the model's own ink +line-work?" by asking whether the material is untextured and nearly black, and +that heuristic has already cost one debugging cycle — it rendered Quaternius' +flat-coloured mannequin as a black silhouette, because a yellow body and a +lilac joint are untextured too. + +Guessing per-frame from a material is the wrong place to ask the question. The +answer is knowable ONCE, at build time, where the mesh, the weights and the +skeleton are all in hand: + + * the material NAME is usually explicit, and on VRoid exports it is formal — + `N00_000_00_Body_00_SKIN_Instance` carries its own class infix, and every + VRoid character in the game uses SKIN / FACE / EYE / HAIR / CLOTH; + * the WEIGHTS are decisive when the name says nothing: a surface whose + vertices are pulled by the skirt chain is a skirt, whatever it is called; + * the MATERIAL FLAGS catch the model's own line-work, which is the one class + that is not really a surface of the character at all. + +So the classification is written into `.rig.json` beside the roles and the +cloth chains, and the runtime reads it instead of re-deriving it. That is what +lets per-class art direction exist at all — a different outline width on hair +than on the body, cloth that takes the garment shading, accessories that keep +their crisp banding — and it gives the rig lab something to show and to let an +artist correct. + +Two ways in: + + # As part of an import — retarget.py calls describe_surfaces() directly. + + # Backfilling a character that is already exported, without re-importing it + blender --background --python tools/surface_map.py -- \ + assets/characters/skins/taila.glb + +The backfill route loads the SHIPPED glb, so the names it records are by +construction the names Godot will see. Nothing else is touched: the surface +table is merged into the existing sidecar and every other key is left alone. +""" +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import rig_map + + +# ── What a surface can be ──────────────────────────────────────────────────── +# +# Four classes, because that is what the rest of the game wants to reason about, +# plus one that is not a surface of the character at all. +# +# body skin, face, eyes — anything that is the character themselves +# cloth garments: tops, skirts, shoes, sleeves, capes +# hair hair, including the parts of it that are welded to the head +# accessory rigid worn props: hats, glasses, bags, jewellery, horns +# linework the model's own outline shell — drawn, not worn (see below) +# +# `detail` narrows it without forcing a new class on anyone who does not care. +BODY, CLOTH, HAIR, ACCESSORY, LINEWORK = ( + "body", "cloth", "hair", "accessory", "linework") + +# Material-name tokens, most specific class first. Order is the whole design +# here: `N00_000_00_EyeIris_00_EYE_Instance` carries both "eye" and "iris" and +# must not be caught by a later, broader rule, while `FaceEyeline` tokenises to +# "face" + "eyeline" and stays a face. +NAME_RULES = [ + ({"eye", "eyes", "iris", "pupil", "sclera", "cornea"}, BODY, "eyes"), + ({"hair", "bang", "bangs", "fringe", "ponytail", "braid", "sidelock"}, + HAIR, "hair"), + ({"brow", "eyebrow", "eyeline", "eyelash", "lash", "mouth", "lip", "teeth", + "tooth", "tongue", "nose", "face", "facial"}, BODY, "face"), + ({"cloth", "clothes", "clothing", "garment", "outfit", "costume", "uniform", + "top", "tops", "bottom", "bottoms", "skirt", "dress", "onepiece", "shirt", + "blouse", "jacket", "coat", "hoodie", "pants", "trousers", "shorts", + "socks", "stocking", "stockings", "shoe", "shoes", "boot", "boots", + "sleeve", "apron", "cape", "cloak", "scarf", "tie", "necktie", "collar", + "ribbon", "frill", "underwear", "bra", "panty", "swimsuit"}, + CLOTH, "garment"), + ({"accessory", "acc", "hat", "cap", "helmet", "glasses", "goggle", + "goggles", "mask", "bag", "backpack", "belt", "buckle", "earring", + "necklace", "choker", "bracelet", "pin", "clip", "badge", "horn", "wing", + "weapon", "prop", "item", "jewel", "gem", "strap", "headphone", + "headphones", "antenna", "halo"}, ACCESSORY, "accessory"), + ({"body", "skin", "torso", "chest", "arm", "arms", "leg", "legs", "hand", + "hands", "foot", "feet", "head"}, BODY, "skin"), +] + +# Spring-chain classes, grouped by what the SURFACE they pull should be called. +# `spring_class` already tells the solver how a chain should behave; this says +# what the geometry hanging off it is. Bust bones move like cloth and belong to +# the body, which is exactly why the two questions are asked separately. +CHAIN_TO_SURFACE = { + "hair": HAIR, + "skirt": CLOTH, "cloth": CLOTH, "coat": CLOTH, "cape": CLOTH, + "scarf": CLOTH, "sleeve": CLOTH, "ribbon": CLOTH, + "breast": BODY, "bust": BODY, + "tail": ACCESSORY, "feather": ACCESSORY, "antenna": ACCESSORY, + "wing": ACCESSORY, "strap": ACCESSORY, +} + +# How much of a surface has to hang off one kind of chain before that decides +# it. Deliberately low: VRoid welds the whole cap of the hair to the head bone +# and springs only the strands, so kiyoko's hair mesh is 85% head — a majority +# rule would call it skin. A surface with a sixth of itself on skirt bones is a +# skirt; nothing else on a character has any weight on those bones at all. +CHAIN_SHARE = 0.05 + +# Darker than this, with no texture, and it is ink rather than a colour. Kept +# identical to LevelMaterials._is_line_work, which this replaces — the two must +# agree, or a model with no surface table would render differently from one +# with it. +INK_LEVEL = 0.18 + + +def datablock_name(name): + """A Blender datablock name as the glTF it came from spelled it. + + Blender uniquifies a name it has already seen by appending `.001`, so a + second import of the same character in one session gives `ClothA.001`. + That, and only that, is stripped. + + Explicitly NOT `rig_map.strip_gltf_suffix`, which removes a trailing + `_` — right for bones, whose names the glTF importer really does + suffix with a node index, and catastrophic here: every mesh in this game is + called something like `Object_15`, and stripping would collapse all + eighteen of Taila's to `Object`. + """ + return re.sub(r"\.\d{3}$", "", name) + + +def _tokens(name): + """Material name -> lowercase word tokens, splitting camelCase too. + + `N00_001_03_Bottoms_01_CLOTH_Instance` -> bottoms, cloth, instance (plus + the numbers), and `EyesFullBlack` -> eyes, full, black. Without the + camelCase split the VRoid names still work and the hand-authored ones do + not, and Taila's are all hand-authored. + """ + spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name) + return [t for t in re.split(r"[^A-Za-z0-9]+", spaced.lower()) if t] + + +def classify_by_name(material_name): + """(class, detail) from the material name alone, or (None, None).""" + toks = set(_tokens(material_name)) + for stems, cls, detail in NAME_RULES: + hit = toks & stems + if hit: + return cls, detail + return None, None + + +def classify_linework(material_name, textured, albedo, front_culled): + """Is this untextured surface part of the model's own DRAWING? + + Lifted wholesale from the runtime rule it replaces, including its history: + "no albedo texture" alone is not the question, and answering it that way + hid the mannequin's yellow body and lilac joints as though they were an + outline shell. What actually distinguishes line-work is being BLACK, being + drawn inside-out, or saying outright that it is an eye card. + + Returns a detail string, or None if this is an ordinary flat-coloured + surface that should shade normally. + """ + if textured: + return None + lower = material_name.lower() + if lower.startswith("eyes"): + # A lash or an iris card — a real facial feature, kept. "HL" is the + # glint in the pupil, and that one really is meant to be white. + return "eyes_highlight" if "hl" in lower else "eyes_ink" + if front_culled: + return "outline_hull" + if max(albedo[0], albedo[1], albedo[2]) < INK_LEVEL: + return "outline_hull" + return None + + +def classify(material_name, textured, albedo, front_culled, chain_shares, + cosmetic_share): + """Everything known about one surface -> (class, detail, evidence). + + Name first where it is explicit, because it is the artist's own statement of + intent and it survives every transform the pipeline applies. Weights second, + because they are the ground truth for anything the name does not say. The + fallback is `body`, which is the safe answer: it gets ordinary character + shading, which is what an unrecognised surface should get. + """ + ink = classify_linework(material_name, textured, albedo, front_culled) + if ink == "outline_hull": + return LINEWORK, ink, "material is untextured ink or inside-out" + if ink is not None: + return BODY, ink, "material names itself an eye card" + + cls, detail = classify_by_name(material_name) + if cls is not None: + return cls, detail, "material name says '%s'" % detail + + # Nothing in the name. Ask the skeleton what is pulling this geometry. + best = max(chain_shares.items(), key=lambda kv: kv[1], default=(None, 0.0)) + if best[0] is not None and best[1] >= CHAIN_SHARE: + surface_cls = CHAIN_TO_SURFACE.get(best[0], CLOTH) + return (surface_cls, best[0], + "%.0f%% of it hangs off %s bones" % (best[1] * 100.0, best[0])) + if cosmetic_share >= 0.5: + return ACCESSORY, "accessory", ( + "%.0f%% of it is on bones no clip drives" % (cosmetic_share * 100.0)) + return BODY, "skin", "no name or weight evidence — treated as body" + + +# ── Measuring one Blender scene ────────────────────────────────────────────── + + +def _dominant_bone_per_vertex(mesh_obj): + """vertex index -> the vertex group holding its largest weight.""" + gname = {g.index: g.name for g in mesh_obj.vertex_groups} + out = {} + for v in mesh_obj.data.vertices: + best = None + for g in v.groups: + if best is None or g.weight > best.weight: + best = g + if best is not None and best.weight > 0.25: + out[v.index] = gname.get(best.group, "") + return out + + +def _vertices_per_slot(mesh_obj): + """material slot index -> the set of vertices drawn with it. + + A Blender object with four material slots exports as one glTF mesh with + four primitives, which Godot loads as one MeshInstance3D with four + surfaces — Miku is exactly this, one object carrying body, body_parts, hair + and face. Partitioning by polygon is the only way to tell those apart; + reading the object's weights as a whole says all four are the same thing. + """ + per = {} + for poly in mesh_obj.data.polygons: + per.setdefault(poly.material_index, set()).update(poly.vertices) + return per + + +def describe_surfaces(meshes, chain_class, driven, role_bones): + """The surface table for one character. + + `chain_class` bone name -> spring class ("hair", "skirt", ...) + `driven` bones the animation drives — the body proper + `role_bones` resolved anatomy bone names, also body + + Keyed on the MATERIAL name rather than the mesh node name. Node names do not + survive as anything meaningful — every character in the game arrives with + its meshes called `Object_7` through `Object_32` — while material names come + through the glTF round trip intact and are what the artist actually named. + The mesh name and surface index are recorded alongside as hints, so a model + that reuses one material for two different things can still be told apart. + """ + body_bones = set(driven) | set(role_bones) + out = [] + for mesh_obj in meshes: + slots = _vertices_per_slot(mesh_obj) + dominant = _dominant_bone_per_vertex(mesh_obj) + mesh_name = datablock_name(mesh_obj.name) + # Surface index, not slot index. The exporter emits one primitive per + # material slot that any polygon actually uses, in slot order, so an + # empty slot in the middle shifts every surface after it down by one. + for surface_index, slot_index in enumerate(sorted(slots)): + verts = slots[slot_index] + mat = (mesh_obj.material_slots[slot_index].material + if slot_index < len(mesh_obj.material_slots) else None) + mat_name = mat.name if mat else "" + + # Weight evidence, as a share of the vertices that belong clearly + # to any one bone — not of all vertices, so a surface that is half + # unweighted is not written off as having no evidence. + counted = 0 + shares = {} + cosmetic = 0 + for vi in verts: + bone = dominant.get(vi) + if not bone: + continue + counted += 1 + cls = chain_class.get(bone) + if cls: + shares[cls] = shares.get(cls, 0) + 1 + elif bone not in body_bones and rig_map.is_cosmetic(bone): + cosmetic += 1 + denom = float(max(1, counted)) + shares = {k: v / denom for k, v in shares.items()} + + textured, albedo, front_culled = _material_look(mat) + cls, detail, why = classify( + mat_name, textured, albedo, front_culled, shares, + cosmetic / denom) + out.append({ + "mesh": mesh_name, + "surface": surface_index, + "material": datablock_name(mat_name), + "class": cls, + "detail": detail, + "why": why, + "verts": len(verts), + "textured": textured, + # Top chain classes pulling this surface, for the rig lab to + # show when someone disagrees with the answer above. + "chain_share": {k: round(v, 3) for k, v in + sorted(shares.items(), key=lambda kv: -kv[1])}, + }) + return out + + +def _material_look(mat): + """(textured, (r, g, b), front_culled) for a Blender material. + + Both routes to the same two facts: a glTF imported by Blender's own + importer keeps a Principled BSDF, and one written by gltf_fix may be a + plain emission or have no node tree at all. + + `front_culled` is always False here, and honestly so: glTF has no way to say + "draw only the backfaces" — it has a single `doubleSided` flag — so an + inverted-hull outline cannot survive the round trip as a cull mode, and + Blender has no property that would carry it. Every character in the game + imports as CULL_BACK or CULL_DISABLED, never CULL_FRONT. The runtime keeps + the cull-mode test as a backstop for a model that does arrive that way, so + nothing is lost by not being able to see it from here. + """ + if mat is None: + return False, (1.0, 1.0, 1.0), False + front_culled = False + albedo = tuple(mat.diffuse_color[:3]) + textured = False + if mat.use_nodes and mat.node_tree: + for node in mat.node_tree.nodes: + if node.type == "TEX_IMAGE" and node.image is not None: + textured = True + if node.type == "BSDF_PRINCIPLED": + base = node.inputs.get("Base Color") + if base is not None and not base.is_linked: + albedo = tuple(base.default_value[:3]) + elif node.type == "EMISSION": + col = node.inputs.get("Color") + if col is not None and not col.is_linked: + albedo = tuple(col.default_value[:3]) + return textured, albedo, front_culled + + +# ── Backfilling an already-exported character ──────────────────────────────── + + +def _chain_class_from_sidecar(sidecar): + """bone -> spring class, taken from the chains the pipeline already found. + + Backfilling deliberately reuses the sidecar's own answer rather than + re-deriving it from the bone names: if the two ever disagreed, the surface + table would describe a rig that the solver is not simulating. + """ + out = {} + for chain in sidecar.get("chains", []): + cls = chain.get("class", "cloth") + for bone in chain.get("bones", []): + out[bone] = cls + return out + + +def _role_bone_names(sidecar): + names = [] + for value in sidecar.get("roles", {}).values(): + if isinstance(value, list): + names.extend(value) + elif value: + names.append(value) + for bones in sidecar.get("fingers", {}).values(): + names.extend(bones) + return names + + +def _is_character_mesh(obj): + """Is this mesh part of the character, or is it riding along in the file? + + Every skin shipped so far carries a stray 42-vertex `Icosphere`: no parent, + no vertex groups, no material slot. It comes in from the animation library, + and `add_animations.py` and `rig_and_animate.py` both already skip it — by + checking `o.name != 'Icosphere'`, which is precisely the kind of hardcoded + spelling this project has a rule against. + + Asked by construction instead: a surface of the character is skinned to the + character. Anything with no vertex groups is not being deformed by the + skeleton, so whatever it is, it is not a body, a garment, hair or a worn + accessory — and it has no business in a table that says which of those a + surface is. + """ + return len(obj.vertex_groups) > 0 + + +def backfill(glb_path): + """Load a shipped skin, classify it, merge the table into its sidecar.""" + import bpy + + sidecar_path = os.path.splitext(glb_path)[0] + ".rig.json" + if not os.path.exists(sidecar_path): + raise SystemExit("no sidecar beside %s — run the pipeline first" + % glb_path) + with open(sidecar_path, "r", encoding="utf-8") as f: + sidecar = json.load(f) + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=glb_path) + meshes = [o for o in bpy.context.scene.objects + if o.type == "MESH" and _is_character_mesh(o)] + if not meshes: + raise SystemExit("no skinned meshes in %s" % glb_path) + + surfaces = describe_surfaces( + meshes, + _chain_class_from_sidecar(sidecar), + sidecar.get("driven_bones", []), + _role_bone_names(sidecar)) + sidecar["surfaces"] = surfaces + with open(sidecar_path, "w", encoding="utf-8") as f: + json.dump(sidecar, f, indent=1) + + tally = {} + for s in surfaces: + tally[s["class"]] = tally.get(s["class"], 0) + 1 + print("%s: %d surfaces — %s" % ( + os.path.basename(glb_path), len(surfaces), + ", ".join("%s x%d" % kv for kv in sorted(tally.items())))) + for s in surfaces: + print(" %-14s s%d %-30s %-10s %-14s %s" % ( + s["mesh"][:14], s["surface"], s["material"][:30], s["class"], + s["detail"], s["why"])) + + +if __name__ == "__main__": + argv = sys.argv + argv = argv[argv.index("--") + 1:] if "--" in argv else [] + if not argv: + print(__doc__) + sys.exit(1) + for path in argv: + backfill(path) diff --git a/tools/unlit_to_pbr.py b/tools/unlit_to_pbr.py deleted file mode 100644 index b62edbb..0000000 --- a/tools/unlit_to_pbr.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -""" -Convert KHR_materials_unlit "emissive albedo" materials in a GLB to plain PBR. - -Anime-style models often ship unlit: black baseColorFactor with the real -albedo in emissiveTexture. Blender's importer turns those into textureless -EMISSION node trees (the image is dropped), so everything downstream renders -pitch black. Rewriting the material JSON up front — baseColorTexture := -emissiveTexture, white base factor, unlit/emissive stripped — gives every -tool in the pipeline a normal textured PBR model. - -Usage: python tools/unlit_to_pbr.py -""" -import json -import struct -import sys - - -def main() -> None: - if len(sys.argv) < 3: - print("Usage: python tools/unlit_to_pbr.py ") - sys.exit(1) - src, dst = sys.argv[1], sys.argv[2] - - with open(src, "rb") as f: - data = f.read() - magic, version, _length = struct.unpack_from(" {dst}") - - -if __name__ == "__main__": - main() diff --git a/tools/verify_character.py b/tools/verify_character.py new file mode 100644 index 0000000..67e03c9 --- /dev/null +++ b/tools/verify_character.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +""" +Check a built character GLB against the things that have actually gone wrong. + +Every assertion here corresponds to a real defect this project shipped, so a +green run means those specific failures are gone rather than that the file +merely loads: + + CROSS-LEG BLEED A vertex pulled by both legs sits between them and stays + there while they separate, stretching every triangle around + it. The old nearest-bone rebind left 2817 of these on + Taila, worst at a dead 50/50 — the boots and thighs that + characters/skin_leg_repair.gd was written to patch at + runtime. Authored weights have none. + INFLUENCE SPREAD 86% of vertices carrying the full four influences is the + signature of K=4 Euclidean weighting, not of an artist. + Real weights are mostly one or two bones. + PART SPLIT One joined mesh means body, cloth and hair deform under one + rule. Separate meshes per material is what lets a thigh + stay solid while a skirt drapes. + ORPHAN CHAINS A Rigify DEF-rig exports its chain roots on the armature + root. If the rebuild missed one, that limb or strand floats + in place while the body moves. + CLIP MOTION A retarget that silently fails produces clips that exist + but never move — the rest-pose statue this pipeline has + produced before. Every clip must actually rotate the hips + and the legs. + CLOTH IS FREE Cloth bones must carry no keys, or the clips would fight + the spring solver for them. + +Usage: + blender --background --python tools/verify_character.py -- [rig.json] + +Exits non-zero if any check fails, so it can gate the pipeline. +""" +import bpy +import json +import os +import sys +from collections import defaultdict +from mathutils import Vector + +argv = sys.argv +argv = argv[argv.index("--") + 1:] if "--" in argv else [] +if not argv: + print(__doc__) + sys.exit(1) +PATH = argv[0] +SIDECAR = argv[1] if len(argv) > 1 else os.path.splitext(PATH)[0] + ".rig.json" + +LEG_HINTS = ("thigh", "shin", "foot", "toe", "upleg", "calf") +# Resolved bone roles from the sidecar — filled in below. Every check that needs +# to know "which bones are the legs" asks THIS, not the name hints. +# +# The hints are a fallback for a build with no sidecar, and they are only ever +# right for rigs that happen to spell things the way the library does. A VRoid +# rig calls its thigh J_Bip_L_UpperLeg: no hint matches it, so the cross-leg +# bleed check found no leg vertex groups at all and PASSED vacuously, and the +# leg-motion check declared every locomotion clip static while the legs were in +# fact animating perfectly. Two green-looking lies from one missing lookup. +ROLE_LEGS = {"L": set(), "R": set()} +failures = [] +warnings = [] +# Set from the sidecar. A model whose source had no skeleton at all has to go +# through autorig, and its weights are then a nearest-bone fit by construction — +# there is no better result to demand. The deformation checks still RUN and +# still print, so the cost is visible, but they cannot fail a build that had no +# alternative; SkinLegRepair covers those models at load time instead. +weights_authored = True + + +def check(ok, label, detail="", needs_authored_weights=False, advisory=False): + soft = advisory or (needs_authored_weights and not weights_authored) + tag = "PASS" if ok else ("WARN" if soft else "FAIL") + print(f" [{tag}] {label}" + (f" — {detail}" if detail else "")) + if ok: + return + if soft: + warnings.append(f"{label} — {detail}") + else: + failures.append(label) + + +def side_of(name): + if name in ROLE_LEGS["L"]: + return -1 + if name in ROLE_LEGS["R"]: + return 1 + n = name.lower() + if not any(h in n for h in LEG_HINTS): + return 0 + if n.endswith(".l") or ".l." in n or "left" in n: + return -1 + if n.endswith(".r") or ".r." in n or "right" in n: + return 1 + return 0 + + +SIDECAR_INFO = {} +if os.path.exists(SIDECAR): + with open(SIDECAR, "r", encoding="utf-8") as f: + SIDECAR_INFO = json.load(f) + weights_authored = bool(SIDECAR_INFO.get("weights_authored", True)) + _roles = SIDECAR_INFO.get("roles", {}) + for _r in ("thigh", "shin", "foot", "toe"): + for _s in ("L", "R"): + _b = _roles.get(f"{_r}.{_s}") + if _b: + ROLE_LEGS[_s].add(_b) + +bpy.ops.object.select_all(action="SELECT") +bpy.ops.object.delete() +bpy.ops.import_scene.gltf(filepath=PATH) + +arms = [o for o in bpy.data.objects if o.type == "ARMATURE"] +if not arms: + print("FAIL: no armature") + sys.exit(1) +arm = max(arms, key=lambda a: len(a.data.bones)) +meshes = [o for o in bpy.data.objects if o.type == "MESH" and o.vertex_groups] + +print(f"\n=== {os.path.basename(PATH)} — {len(arm.data.bones)} bones, " + f"{len(meshes)} meshes, {sum(len(m.data.vertices) for m in meshes)} verts ===\n") + +# ---------------------------------------------------------------- deformation +total_bleed = 0 +worst_bleed = 0.0 +infl = defaultdict(int) +for m in meshes: + gside = {g.index: side_of(g.name) for g in m.vertex_groups} + for v in m.data.vertices: + wl = wr = 0.0 + n = 0 + for g in v.groups: + if g.weight <= 0.005: + continue + n += 1 + s = gside.get(g.group, 0) + if s == -1: + wl += g.weight + elif s == 1: + wr += g.weight + infl[n] += 1 + if wl > 0.005 and wr > 0.005: + total_bleed += 1 + worst_bleed = max(worst_bleed, min(wl, wr) / (wl + wr)) + +nverts = sum(infl.values()) or 1 +four = infl.get(4, 0) / nverts +# Not zero: a skirt hem genuinely spans both legs, and Taila's artist left 17 +# such vertices (0.1%) on purpose. The failure mode being caught is the SOLVER +# signature — the nearest-bone rebind put 16% of the model in this state. +bleed_frac = total_bleed / nverts +check(bleed_frac < 0.005, "cross-leg blending is limited to draping cloth", + f"{total_bleed} verts ({bleed_frac * 100:.1f}%), worst minority share {worst_bleed:.2f}", + needs_authored_weights=True) +check(four < 0.5, "influences look authored, not solved", + f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}", + needs_authored_weights=True) +# ADVISORY, not a gate. Several meshes is what we want — it is how body, cloth +# and hair stay separable for materials, for the outline pass and for the cloth +# solver's hull extraction — but the OUTPUT cannot tell "the pipeline joined +# them" from "the artist authored one mesh". Quaternius' mannequin is a single +# mesh on purpose and was failing a check about damage that had not happened. +# +# The join path leaves two signatures that ARE unambiguous, and both are hard +# checks above: cross-leg weight bleed, and the 4-influences-everywhere spread +# of a nearest-bone rebind. Those catch what this was standing in for. +check(len(meshes) > 1, "model keeps its per-part meshes", + f"{len(meshes)} mesh{'es' if len(meshes) != 1 else ''}" + + (" — fine for a single-piece model; a costume should be several" + if len(meshes) == 1 else ""), + advisory=True) + +# ------------------------------------------------------------------- posture +# +# Is this character the right size and the right way up? +# +# Nothing asked before, and it is the single cheapest check in the file. The +# scale step normalises the bounding box along Blender's Z because Z is up — for +# a model that arrives lying along Y that measures the character's THICKNESS, so +# it gets scaled by ~7 and left on its back. Three of seven shipped that way. +# +# The trap is that the normalised number always comes out right: the export maps +# Blender Z to glTF Y, so "is the height 1.75" passes on a character who is +# 7.5 m tall lying down. The real question is whether the axis that was +# normalised is the one the SKELETON is tall along. +# From the VERTICES, not from object.bound_box — that is cached and is still +# stale immediately after an import, which quietly reported a 1.75 m character +# as 1.18 m tall. +lo = [1e9] * 3 +hi = [-1e9] * 3 +for m in meshes: + mw = m.matrix_world + for v in m.data.vertices: + p = mw @ v.co + for k in range(3): + lo[k] = min(lo[k], p[k]) + hi[k] = max(hi[k], p[k]) +ext = [hi[k] - lo[k] for k in range(3)] + +up_axis = None +_hips = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("hips", "")) +_head = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("head", "")) +if _hips and _head: + d = [abs(_head.head_local[k] - _hips.head_local[k]) for k in range(3)] + up_axis = d.index(max(d)) + +if up_axis is not None and max(ext) > 1e-4: + tall = ext.index(max(ext)) + others = [ext[k] for k in range(3) if k != up_axis] + detail = ("%.2f m tall along %s, %.2f x %.2f m across" + % (ext[up_axis], "XYZ"[up_axis], others[0], others[1])) + # Against WORLD up (Blender Z), not against the widest axis. A model that has + # been rotated as a whole is internally consistent — its spine IS its longest + # axis — so comparing the two agrees with itself and catches nothing. The + # question is whether the character stands up in the world the game runs in. + check(up_axis == 2, "character stands up in world space", + detail + ("" if up_axis == 2 else + " — the spine runs along %s, not Z, so the character is " + "lying down; whatever was normalised to the target height " + "was not their height" % "XYZ"[up_axis])) + check(max(others) < 2.5, "character is a plausible size", detail) + check(1.2 < ext[up_axis] < 2.4, "character is a plausible height", detail) + +# --------------------------------------------------- reachable from the runtime +# +# The game looks these up to attach a weapon and to drive the pose layer. It used +# to do it with hardcoded spellings, so four characters whose roles resolved +# perfectly still could not hold a gun. The roles are the contract; if one the +# runtime needs is missing from the sidecar, the character will be subtly broken +# in a way no other check here can see. +_roles = SIDECAR_INFO.get("roles", {}) +if _roles: + needed = ["hips", "head", "hand.R", "hand.L", "upper_arm.R", "upper_arm.L", + "forearm.R", "forearm.L", "thigh.L", "thigh.R", "shin.L", "shin.R"] + absent = [r for r in needed + if not _roles.get(r) or _roles[r] not in arm.data.bones] + check(not absent, "every role the runtime needs is resolved", + "missing or unresolvable: %s" % absent) + +# ------------------------------------------------------------------- skeleton +def is_rootish(b): + return b is None or any(t in b.name.lower() + for t in ("root", "master", "armature")) + + +orphans = [b.name for b in arm.data.bones + if is_rootish(b.parent) and not is_rootish(b)] +check(len(orphans) <= 1, "every chain is attached to the body", + f"{len(orphans)} bones still on the armature root: {orphans[:6]}") + +# ----------------------------------------------------------------------- clips +actions = {a.name: a for a in bpy.data.actions} +print(f"\n {len(actions)} clips: {', '.join(sorted(actions))}\n") +check(len(actions) >= 10, "the canonical clip set shipped", f"{len(actions)} clips") + + +def curves(action): + legacy = getattr(action, "fcurves", None) + if legacy is not None: + return list(legacy) + out = [] + for layer in getattr(action, "layers", []): + for strip in layer.strips: + for cbag in getattr(strip, "channelbags", []): + out.extend(cbag.fcurves) + return out + + +def bone_of(path): + if 'pose.bones["' not in path: + return None + s = path.index('"') + 1 + return path[s:path.index('"', s)] + + +cloth = set() +if os.path.exists(SIDECAR): + with open(SIDECAR, "r", encoding="utf-8") as f: + info = json.load(f) + for c in info.get("chains", []): + cloth.update(c["bones"]) + print(f" sidecar: {len(info.get('chains', []))} cloth chains " + f"({len(cloth)} bones), {len(info.get('twist', []))} twist bones") + # A chain whose bones have no measurable extent is DEAD: the runtime drops + # any bone whose tip is shorter than a millimetre, so such a chain simulates + # nothing and the costume is welded solid — silently, because the sidecar + # still reports it. hikari shipped ten of them; her cosmetic bones are + # zero-length terminators from a rig that had been through two toolchains. + dead = 0 + for c in info.get("chains", []): + ext = max((sum(v * v for v in t) ** 0.5 for t in c.get("tips", [])), + default=0.0) + if ext < 0.001: + dead += 1 + if info.get("chains"): + check(dead == 0, "cloth chains have measurable extent", + f"{dead} of {len(info['chains'])} chains are zero-length and will " + f"not simulate", advisory=True) +else: + warnings.append(f"no sidecar at {SIDECAR}") + +# Clips whose legs MUST move. The library's Pistol_Idle_Loop and Pistol_Shoot +# are upper-body clips with genuinely static legs, so demanding leg motion from +# every clip fails on a correct build. +LOCOMOTION = {"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land", + "CrouchIdle", "CrouchWalk", "Dash"} + +# The thighs and shins by ROLE, so this works on any rig's spelling. +leg_bones = {b for s in ROLE_LEGS.values() for b in s} +if not leg_bones: + leg_bones = {b.name for b in arm.data.bones + if any(h in b.name.lower() for h in ("thigh", "shin"))} + +frozen = [] +legless = [] +keyed_cloth = set() +for name, action in sorted(actions.items()): + moved = defaultdict(float) + for fc in curves(action): + b = bone_of(fc.data_path) + if not b or len(fc.keyframe_points) < 2: + continue + if b in cloth: + keyed_cloth.add(b) + vals = [kp.co.y for kp in fc.keyframe_points] + moved[b] = max(moved[b], max(vals) - min(vals)) + if max(moved.values(), default=0.0) < 0.005: + frozen.append(name) + legs = max((v for b, v in moved.items() if b in leg_bones), default=0.0) + if name in LOCOMOTION and legs < 0.01: + legless.append(f"{name}({legs:.4f})") + +check(not frozen, "no clip retargeted to a frozen rest pose", + f"frozen: {', '.join(frozen)}" if frozen else "") +check(not legless, "locomotion clips animate the legs", + f"static legs: {', '.join(legless)}" if legless else "") +check(not keyed_cloth, "cloth bones carry no animation keys", + f"{len(keyed_cloth)} keyed: {sorted(keyed_cloth)[:5]}") + +print() +for w in warnings: + print(f" [WARN] {w}") +if failures: + print(f"\n{len(failures)} CHECK(S) FAILED: {failures}\n") + sys.exit(1) +print("\nAll checks passed.\n") diff --git a/ui/bolt_rule.gd b/ui/bolt_rule.gd new file mode 100644 index 0000000..62c9209 --- /dev/null +++ b/ui/bolt_rule.gd @@ -0,0 +1,65 @@ +extends Control +class_name BoltRule + +## A lightning bolt struck through a horizontal rule. +## +## The motif the whole UI is built around, in one reusable widget: menus use it +## as a section divider, the HUD uses it to underline a heading. It is drawn +## rather than shipped as an image so it takes the theme's colours and scales to +## any width without a second asset. +## +## The zigzag is deliberately not centred. A bolt centred in its rule reads as an +## ornament; struck a third of the way along, it reads as something that HIT the +## line — which is the difference between decorative and energetic. + +@export var line_color: Color = Color(1.0, 0.47, 0.10) +@export var bolt_color: Color = Color(1.0, 0.93, 0.22) +@export var ink: Color = Color(0.045, 0.040, 0.075) +## Where along the rule the bolt strikes, 0..1. +@export var strike_at: float = 0.34 +@export var thickness: float = 3.0 + + +func _init() -> void: + custom_minimum_size = Vector2(0, 22) + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +func _draw() -> void: + var w := size.x + var h := size.y + if w <= 1.0: + return + var mid := h * 0.5 + var strike := w * clampf(strike_at, 0.05, 0.95) + var half := h * 0.42 + + # The rule, broken where the bolt lands so the bolt reads as passing + # THROUGH it rather than sitting on top. + var gap := h * 0.55 + draw_line(Vector2(0, mid), Vector2(maxf(0.0, strike - gap), mid), + line_color, thickness) + draw_line(Vector2(minf(w, strike + gap), mid), Vector2(w, mid), + line_color, thickness) + + # The bolt, as a SIMPLE polygon — six points, no edge crossing another. + # `draw_colored_polygon` triangulates, and a self-intersecting outline fails + # triangulation outright and draws nothing but a console full of "Invalid + # polygon data". The obvious zigzag (down-right, back-left, down-right) + # crosses itself; this is the same silhouette walked as a closed loop + # instead: down the left edge, out to the tip, back up the right edge. + var pts := PackedVector2Array([ + Vector2(strike + half * 0.55, mid - half), # top, right of centre + Vector2(strike - half * 0.45, mid + half * 0.05), # down-left to the notch + Vector2(strike + half * 0.00, mid + half * 0.05), # step right + Vector2(strike - half * 0.35, mid + half), # down-left to the tip + Vector2(strike + half * 0.55, mid - half * 0.15), # back up the right edge + Vector2(strike + half * 0.10, mid - half * 0.15), # step left + ]) + draw_colored_polygon(pts, bolt_color) + # Ink edge ON TOP as a closed polyline, not as a second, larger polygon — + # growing a concave shape from its centroid can push a point past its + # neighbour and produce exactly the self-intersection above. + var loop := pts.duplicate() + loop.append(pts[0]) + draw_polyline(loop, ink, 2.5) diff --git a/ui/bolt_rule.gd.uid b/ui/bolt_rule.gd.uid new file mode 100644 index 0000000..b562f92 --- /dev/null +++ b/ui/bolt_rule.gd.uid @@ -0,0 +1 @@ +uid://bsy66ipfm78ie diff --git a/ui/main_menu/main_menu.gd b/ui/main_menu/main_menu.gd index e75a924..9193d3c 100644 --- a/ui/main_menu/main_menu.gd +++ b/ui/main_menu/main_menu.gd @@ -97,8 +97,8 @@ func _ready() -> void: var ls_title = Label.new() ls_title.text = "Select Level" ls_title.add_theme_font_size_override("font_size", 64) - ls_title.add_theme_color_override("font_color", Color(1, 1, 1)) - ls_title.add_theme_color_override("font_outline_color", Color(0, 0, 0)) + ls_title.add_theme_color_override("font_color", UITheme.PAPAYA) + ls_title.add_theme_color_override("font_outline_color", UITheme.INK) ls_title.add_theme_constant_override("outline_size", 8) _level_selector_panel.add_child(ls_title) @@ -131,8 +131,8 @@ func _ready() -> void: var mp_title = Label.new() mp_title.text = "Multiplayer" mp_title.add_theme_font_size_override("font_size", 64) - mp_title.add_theme_color_override("font_color", Color(1, 1, 1)) - mp_title.add_theme_color_override("font_outline_color", Color(0, 0, 0)) + mp_title.add_theme_color_override("font_color", UITheme.PAPAYA) + mp_title.add_theme_color_override("font_outline_color", UITheme.INK) mp_title.add_theme_constant_override("outline_size", 8) _multiplayer_panel.add_child(mp_title) @@ -159,8 +159,8 @@ func _ready() -> void: var dc_title = Label.new() dc_title.text = "Direct Connect" dc_title.add_theme_font_size_override("font_size", 64) - dc_title.add_theme_color_override("font_color", Color(1, 1, 1)) - dc_title.add_theme_color_override("font_outline_color", Color(0, 0, 0)) + dc_title.add_theme_color_override("font_color", UITheme.PAPAYA) + dc_title.add_theme_color_override("font_outline_color", UITheme.INK) dc_title.add_theme_constant_override("outline_size", 8) _direct_connect_panel.add_child(dc_title) @@ -226,8 +226,8 @@ func _ready() -> void: var hl_title = Label.new() hl_title.text = "Host Lobby - Select Level" hl_title.add_theme_font_size_override("font_size", 64) - hl_title.add_theme_color_override("font_color", Color(1, 1, 1)) - hl_title.add_theme_color_override("font_outline_color", Color(0, 0, 0)) + hl_title.add_theme_color_override("font_color", UITheme.PAPAYA) + hl_title.add_theme_color_override("font_outline_color", UITheme.INK) hl_title.add_theme_constant_override("outline_size", 8) hl_left.add_child(hl_title) @@ -271,7 +271,7 @@ func _ready() -> void: var pl_title = Label.new() pl_title.text = "Players Connected" pl_title.add_theme_font_size_override("font_size", 48) - pl_title.add_theme_color_override("font_outline_color", Color(0, 0, 0)) + pl_title.add_theme_color_override("font_outline_color", UITheme.INK) pl_title.add_theme_constant_override("outline_size", 8) hl_right.add_child(pl_title) @@ -279,7 +279,7 @@ func _ready() -> void: if get_node_or_null("/root/NetworkManager"): port_label.text = "Port: " + str(get_node("/root/NetworkManager").DEFAULT_PORT) port_label.add_theme_font_size_override("font_size", 24) - port_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + port_label.add_theme_color_override("font_color", UITheme.PAPER_DIM) hl_right.add_child(port_label) _host_lobby_players_list = ItemList.new() @@ -352,7 +352,7 @@ func _build_level_card(parent: Container, title: String, scene_path: String, gra var lbl = Label.new() lbl.text = title lbl.add_theme_font_size_override("font_size", 32) - lbl.add_theme_color_override("font_color", Color.WHITE) + lbl.add_theme_color_override("font_color", UITheme.PAPER) lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER lbl.set_anchors_preset(Control.PRESET_FULL_RECT) overlay.add_child(lbl) diff --git a/ui/match_hud.gd b/ui/match_hud.gd index 0aef934..dbb0eed 100644 --- a/ui/match_hud.gd +++ b/ui/match_hud.gd @@ -33,7 +33,7 @@ func _ready() -> void: _time_label = Label.new() _time_label.add_theme_font_size_override("font_size", 48) - _time_label.add_theme_color_override("font_outline_color", Color.BLACK) + _time_label.add_theme_color_override("font_outline_color", UITheme.INK) _time_label.add_theme_constant_override("outline_size", 8) top_hbox.add_child(_time_label) @@ -48,7 +48,7 @@ func _ready() -> void: _leader_label = Label.new() _leader_label.text = "Leader: None" _leader_label.add_theme_font_size_override("font_size", 32) - _leader_label.add_theme_color_override("font_outline_color", Color.BLACK) + _leader_label.add_theme_color_override("font_outline_color", UITheme.INK) _leader_label.add_theme_constant_override("outline_size", 8) top_hbox.add_child(_leader_label) @@ -131,7 +131,7 @@ func _populate_scoreboard_headers() -> void: var l = Label.new() l.text = h l.add_theme_font_size_override("font_size", 24) - l.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + l.add_theme_color_override("font_color", UITheme.PAPER_DIM) _scoreboard_grid.add_child(l) func _update_hud() -> void: @@ -204,7 +204,7 @@ func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: l.text = "[color=#%s]%s[/color] [%s] [color=#%s]%s[/color]" % [k_color, killer, weapon, v_color, victim] l.add_theme_font_size_override("normal_font_size", 20) - l.add_theme_color_override("font_outline_color", Color.BLACK) + l.add_theme_color_override("font_outline_color", UITheme.INK) l.add_theme_constant_override("outline_size", 4) _killfeed_vbox.add_child(l) diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index 10425d4..28676a7 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -4,11 +4,22 @@ var bg: ColorRect var main_vbox: VBoxContainer var resume_btn: Button var respawn_btn: Button +var character_btn: Button var loadouts_btn: Button var settings_btn: Button var return_btn: Button var exit_btn: Button +# Character picker +var character_editor: Control +var character_list: ItemList +var character_desc: Label +var character_back_btn: Button +var _preview_viewport: SubViewport +var _preview_model: SkinnedPlayerModel +var _preview_pivot: Node3D +var _preview_id: String = "" + # Loadout UI var loadout_editor: Control var loadout_list_vbox: VBoxContainer @@ -58,9 +69,15 @@ func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS layer = 100 # Ensure it's on top visible = false - + + # This screen is an autoload, so it can be the FIRST thing the player sees + # styled — opened over a level that never loaded the HUD, it used to render + # in Godot's default grey because it relied on some other screen having + # applied the theme. apply_global is idempotent; just call it. + UITheme.apply_global(get_tree()) _build_ui() _connect_signals() + UITheme.wire_sounds(self) func _input(event: InputEvent) -> void: if waiting_for_input_action != "": @@ -114,6 +131,13 @@ func _build_ui() -> void: bg = ColorRect.new() bg.color = Color(0, 0, 0, 0.7) bg.set_anchors_preset(Control.PRESET_FULL_RECT) + # Assigned here as well as on the root Window, because this screen hangs off + # a CanvasLayer. A Control inherits its theme from its nearest Control + # ANCESTOR, and a CanvasLayer is not one — so the chain breaks here and + # everything below fell back to Godot's default grey. It was not obvious, + # because the parts built with UITheme.title() carry their own overrides and + # looked correct next to a list and a button that did not. + bg.theme = UITheme.build() add_child(bg) # ── Main Menu ── @@ -125,42 +149,48 @@ func _build_ui() -> void: main_vbox.add_theme_constant_override("separation", 20) main_center.add_child(main_vbox) - var title = Label.new() - title.text = "MENU" + var title = UITheme.title("PAUSED", 56) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title.add_theme_font_size_override("font_size", 48) main_vbox.add_child(title) - + main_vbox.add_child(UITheme.divider(0.62)) + resume_btn = Button.new() resume_btn.text = "Resume" - resume_btn.custom_minimum_size = Vector2(200, 50) + resume_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(resume_btn) - + respawn_btn = Button.new() respawn_btn.text = "Respawn" - respawn_btn.custom_minimum_size = Vector2(200, 50) + respawn_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(respawn_btn) - + + character_btn = Button.new() + character_btn.text = "Character" + character_btn.custom_minimum_size = Vector2(260, 52) + main_vbox.add_child(character_btn) + loadouts_btn = Button.new() loadouts_btn.text = "Loadouts" - loadouts_btn.custom_minimum_size = Vector2(200, 50) + loadouts_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(loadouts_btn) - + settings_btn = Button.new() settings_btn.text = "Settings" - settings_btn.custom_minimum_size = Vector2(200, 50) + settings_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(settings_btn) return_btn = Button.new() return_btn.text = "Main Menu" - return_btn.custom_minimum_size = Vector2(200, 50) + return_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(return_btn) exit_btn = Button.new() exit_btn.text = "Exit Game" - exit_btn.custom_minimum_size = Vector2(200, 50) + exit_btn.custom_minimum_size = Vector2(260, 52) main_vbox.add_child(exit_btn) + _build_character_editor() + # ── Loadout Editor Root ── loadout_editor = Control.new() loadout_editor.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -680,6 +710,213 @@ func _build_ui() -> void: return_dialog.dialog_text = "Are you sure you want to return to the main menu?" add_child(return_dialog) +# ── Character picker ───────────────────────────────────────────────────────── +# +# The list on the left, the character themselves on the right, turning. A name +# in a dropdown is not a character selection screen — you pick a character by +# looking at them, which is the whole reason the models exist. +# +# The preview is a real SkinnedPlayerModel in its own world, not a rendered +# thumbnail, so it shows exactly what will spawn: the same cel look, the same +# per-class outlines, the same cloth and hair on springs. A thumbnail would go +# stale the first time a character was re-imported. + +const PREVIEW_SIZE := Vector2i(520, 640) + +func _build_character_editor() -> void: + character_editor = Control.new() + character_editor.set_anchors_preset(Control.PRESET_FULL_RECT) + character_editor.visible = false + bg.add_child(character_editor) + + var center = CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + character_editor.add_child(center) + + var row = HBoxContainer.new() + row.add_theme_constant_override("separation", 34) + center.add_child(row) + + # Left: the roster + var left = VBoxContainer.new() + left.add_theme_constant_override("separation", 14) + left.custom_minimum_size = Vector2(340, 0) + row.add_child(left) + + var heading := UITheme.title("CHARACTER", 52) + left.add_child(heading) + left.add_child(UITheme.divider(0.28)) + + character_list = ItemList.new() + character_list.custom_minimum_size = Vector2(340, 380) + character_list.auto_height = false + left.add_child(character_list) + + character_desc = UITheme.heading("", 20) + character_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + character_desc.custom_minimum_size = Vector2(340, 60) + left.add_child(character_desc) + + character_back_btn = Button.new() + character_back_btn.text = "Back" + character_back_btn.custom_minimum_size = Vector2(0, 46) + left.add_child(character_back_btn) + + # Right: the character, in a real 3D viewport + var frame := UITheme.card() + row.add_child(frame) + + var vp_container = SubViewportContainer.new() + vp_container.stretch = true + vp_container.custom_minimum_size = Vector2(PREVIEW_SIZE) + frame.add_child(vp_container) + + _preview_viewport = SubViewport.new() + _preview_viewport.own_world_3d = true + _preview_viewport.size = PREVIEW_SIZE + _preview_viewport.msaa_3d = Viewport.MSAA_4X + _preview_viewport.transparent_bg = true + # The tree is paused while this menu is up. Without this the preview would + # be a still frame: no turntable, and — worse — no spring solver, so the + # character's hair and skirt would hang in their bind pose. + _preview_viewport.process_mode = Node.PROCESS_MODE_ALWAYS + vp_container.add_child(_preview_viewport) + + var world = Node3D.new() + _preview_viewport.add_child(world) + + var env = WorldEnvironment.new() + env.environment = LevelEnvironment.make_environment("sunset") + world.add_child(env) + + # Three-point-ish lighting, warm key and cool fill, which is what makes a + # cel-shaded character read as rounded instead of as a sticker. + var key = DirectionalLight3D.new() + key.rotation_degrees = Vector3(-28, 38, 0) + key.light_color = Color(1.0, 0.93, 0.84) + key.light_energy = 1.6 + world.add_child(key) + + var fill = DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-16, -128, 0) + fill.light_color = Color(0.48, 0.62, 1.0) + fill.light_energy = 0.55 + world.add_child(fill) + + _preview_pivot = Node3D.new() + # Turned to face the camera. SkinnedPlayerModel yaws itself 180° on load + # because glTF forward is +Z and players face -Z, which is right in a level + # and means a preview camera sitting on +Z gets the back of the character's + # head. The turntable starts from here. + _preview_pivot.rotation_degrees.y = 180.0 + world.add_child(_preview_pivot) + + var camera = Camera3D.new() + camera.position = Vector3(0, 1.05, 2.65) + camera.rotation_degrees = Vector3(-4, 0, 0) + camera.fov = 38.0 + world.add_child(camera) + + +func _show_character() -> void: + main_vbox.visible = false + loadout_editor.visible = false + settings_editor.visible = false + character_editor.visible = true + _populate_character_list() + + +func _populate_character_list() -> void: + character_list.clear() + var active: String = SkinManager.active_skin_id + var selected := -1 + for id in SkinManager.get_skin_ids(): + var skin = SkinManager.get_skin(id) + if not skin.is_unlocked: + continue + var idx := character_list.add_item(skin.skin_name) + character_list.set_item_metadata(idx, id) + if id == active: + selected = idx + if selected >= 0: + character_list.select(selected) + _on_character_selected(selected) + elif character_list.item_count > 0: + character_list.select(0) + _on_character_selected(0) + + +## Selecting a character equips it immediately — there is no separate confirm. +## +## A picker that needs a second click to take effect makes you compare a preview +## against a memory of the last one. Applying on selection means the character +## behind the menu changes as you arrow through the list, which IS the +## comparison. Nothing here is destructive, so there is nothing to confirm. +func _on_character_selected(index: int) -> void: + if index < 0 or index >= character_list.item_count: + return + var id: String = character_list.get_item_metadata(index) + var skin = SkinManager.get_skin(id) + character_desc.text = skin.description if skin.description != "" else skin.skin_name + _load_preview(id, skin) + SkinManager.set_active_skin(id) + var player := _local_player() + if player and player.has_method("set_skin"): + player.set_skin(id) + + +## Build the turntable model for one skin. A colour-tint skin has no GLB, so the +## preview is emptied rather than left showing whoever was selected before it. +func _load_preview(id: String, skin) -> void: + if id == _preview_id: + return + _preview_id = id + if is_instance_valid(_preview_model): + _preview_model.queue_free() + _preview_model = null + # Every character gets shown from the front. Without this the turntable + # carries on from wherever the last one left it, so the third character you + # look at is side-on and the fourth has their back to you. + if is_instance_valid(_preview_pivot): + _preview_pivot.rotation_degrees.y = 180.0 + if skin == null or skin.model_path == "" \ + or not ResourceLoader.exists(skin.model_path): + return + var model := SkinnedPlayerModel.new() + model.model_path = skin.model_path + model.skin_id = id + # Not first-person and not shadows-only: this is the one place the local + # player is supposed to see their own character in full. + model.first_person_mode = false + model.shadows_only = false + model.process_mode = Node.PROCESS_MODE_ALWAYS + _preview_pivot.add_child(model) + _preview_model = model + + +func _process(delta: float) -> void: + # Turntable. Only while the picker is actually on screen — the rest of the + # time this node is an invisible autoload and has no business spending + # frames. + if character_editor and character_editor.visible and is_instance_valid(_preview_pivot): + _preview_pivot.rotate_y(delta * 0.45) + + +## The player this menu belongs to. Spawners differ between the built levels and +## the test harness, so both places are checked. +func _local_player() -> Node: + var pid := str(multiplayer.get_unique_id()) + var scene := get_tree().current_scene + if scene == null: + return null + var found := scene.get_node_or_null(pid) + if found == null: + var spawner := scene.get_node_or_null("PlayerSpawner") + if spawner: + found = spawner.get_node_or_null(pid) + return found + + func _build_vol_control(parent: Node, label_name: String) -> Array: var hbox = HBoxContainer.new() parent.add_child(hbox) @@ -725,6 +962,9 @@ func _create_label(text: String) -> Label: func _connect_signals() -> void: resume_btn.pressed.connect(_resume) respawn_btn.pressed.connect(_respawn) + character_btn.pressed.connect(_show_character) + character_back_btn.pressed.connect(_show_main_menu) + character_list.item_selected.connect(_on_character_selected) loadouts_btn.pressed.connect(_show_loadouts) settings_btn.pressed.connect(_show_settings) return_btn.pressed.connect(_show_return_dialog) @@ -753,10 +993,12 @@ func _show_main_menu() -> void: main_vbox.visible = true loadout_editor.visible = false settings_editor.visible = false + character_editor.visible = false func _show_loadouts() -> void: main_vbox.visible = false settings_editor.visible = false + character_editor.visible = false loadout_editor.visible = true edit_panel.visible = false _populate_loadout_list() @@ -790,6 +1032,7 @@ func _populate_audio_ui() -> void: func _show_settings() -> void: main_vbox.visible = false loadout_editor.visible = false + character_editor.visible = false settings_editor.visible = true _populate_keybindings() _populate_video_ui() diff --git a/ui/ui_theme.gd b/ui/ui_theme.gd index 97c155f..92726bc 100644 --- a/ui/ui_theme.gd +++ b/ui/ui_theme.gd @@ -1,39 +1,95 @@ extends Object class_name UITheme -## Shared comic/cel UI theme: Bangers display font, paper panels with thick -## ink borders and hard drop shadows, papaya accent. Built once, applied to -## the root Window so every Control in the game inherits it. +## The one UI theme: high-voltage anime. Built once, applied to the root Window, +## inherited by every Control in every scene. +## +## The look is a charged cel comic — near-black violet ink, hot papaya, and a +## lightning yellow that only ever appears at the moment something is pressed. +## Three rules hold it together: +## +## INK EVERYTHING every panel, chip and letter carries a heavy dark edge. +## It is what makes flat colour read as drawn rather than +## as flat, and it is why text stays legible over a 3D +## scene without a scrim behind it. +## LEAN chips are cut with two sharp corners and two round ones, +## on a diagonal. A uniformly rounded button reads calm; the +## same shape leaning reads fast, and it costs nothing. +## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt. +## Spend it anywhere else and the moment of input stops +## standing out. +## +## Everything here is static. Screens ask for `apply_global` once and then use +## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of +## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away +## from the rest of the game's look. const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf" -const INK := Color(0.09, 0.08, 0.12) -const PAPER := Color(0.98, 0.96, 0.9) -const PAPER_DIM := Color(0.92, 0.9, 0.84) -const PAPAYA := Color(1.0, 0.55, 0.15) -const PAPAYA_HOT := Color(1.0, 0.68, 0.25) -const TEAL := Color(0.16, 0.72, 0.68) +# ── The voltage palette ────────────────────────────────────────────────────── +const INK := Color(0.045, 0.040, 0.075) +const INK_SOFT := Color(0.105, 0.095, 0.165) +const PANEL := Color(0.075, 0.068, 0.125, 0.94) +const PANEL_DEEP := Color(0.045, 0.040, 0.085, 0.97) +const PAPER := Color(0.97, 0.96, 0.99) +const PAPER_DIM := Color(0.60, 0.58, 0.68) +const PAPAYA := Color(1.00, 0.47, 0.10) +const PAPAYA_HOT := Color(1.00, 0.64, 0.20) +## Reserved for the instant of input, and for the bolt. See VOLT MEANS NOW. +const VOLT := Color(1.00, 0.93, 0.22) +const CYAN := Color(0.22, 0.94, 1.00) +const MAGENTA := Color(1.00, 0.18, 0.52) +## Older screens name the accent `TEAL`; it is the electric cyan now. +const TEAL := CYAN + +## How far a chip leans. Applied as opposite corners round and the other two +## nearly square, which is as close to a skew as a StyleBoxFlat can get. +const LEAN := 18 static var _theme: Theme = null -## Comic panel stylebox: flat fill, thick ink border, hard offset shadow. +## A leaning comic chip: flat fill, heavy ink edge, hard offset shadow. +## +## Kept at its original name and argument order — main_menu and match_hud both +## call it — with the lean added on top. static func box(bg: Color, border: Color = INK, radius: int = 10, border_w: int = 3, shadow: bool = true) -> StyleBoxFlat: var sb := StyleBoxFlat.new() sb.bg_color = bg sb.border_color = border sb.set_border_width_all(border_w) - sb.set_corner_radius_all(radius) - sb.content_margin_left = 16 - sb.content_margin_right = 16 - sb.content_margin_top = 8 - sb.content_margin_bottom = 8 + sb.corner_radius_top_left = radius + LEAN + sb.corner_radius_bottom_right = radius + LEAN + sb.corner_radius_top_right = 3 + sb.corner_radius_bottom_left = 3 + sb.content_margin_left = 22 + sb.content_margin_right = 22 + sb.content_margin_top = 9 + sb.content_margin_bottom = 9 if shadow: - sb.shadow_color = Color(INK.r, INK.g, INK.b, 0.55) - sb.shadow_size = 0 - sb.shadow_offset = Vector2(4, 4) - sb.shadow_size = 2 + sb.shadow_color = Color(INK.r, INK.g, INK.b, 0.7) + sb.shadow_size = 3 + sb.shadow_offset = Vector2(5, 5) + return sb + + +## A panel: same ink edge, but square-shouldered so it reads as a surface to put +## things on rather than as a very large button. +static func panel(bg: Color = PANEL, border: Color = INK, + border_w: int = 4) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.border_color = border + sb.set_border_width_all(border_w) + sb.set_corner_radius_all(6) + sb.content_margin_left = 20 + sb.content_margin_right = 20 + sb.content_margin_top = 16 + sb.content_margin_bottom = 16 + sb.shadow_color = Color(INK.r, INK.g, INK.b, 0.55) + sb.shadow_size = 4 + sb.shadow_offset = Vector2(6, 6) return sb @@ -46,77 +102,179 @@ static func build() -> Theme: t.default_font = font t.default_font_size = 26 - # ── Buttons: paper card, papaya on hover, squash on press ──────────── - t.set_stylebox("normal", "Button", box(PAPER)) - t.set_stylebox("hover", "Button", box(PAPAYA_HOT)) - t.set_stylebox("pressed", "Button", box(PAPAYA, INK, 10, 3, false)) - t.set_stylebox("focus", "Button", box(PAPER, TEAL)) - t.set_stylebox("disabled", "Button", box(PAPER_DIM, Color(0.4, 0.4, 0.45))) - t.set_color("font_color", "Button", INK) - t.set_color("font_hover_color", "Button", INK) - t.set_color("font_pressed_color", "Button", PAPER) - t.set_color("font_focus_color", "Button", INK) - t.set_color("font_disabled_color", "Button", Color(0.45, 0.45, 0.5)) + # ── Buttons ────────────────────────────────────────────────────────── + # Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and + # flashes volt on press. The press state drops its shadow, so the chip + # visibly slams down into the page rather than just changing colour. + _button_look(t, "Button") + for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton", + "LinkButton"]: + # Godot theme types do not inherit styling from Button, so each one has + # to be told the same thing. + _button_look(t, cls) - # OptionButton / CheckBox get the same treatment explicitly (Godot theme - # types don't inherit styling from Button) - for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton"]: - t.set_stylebox("normal", cls, box(PAPER)) - t.set_stylebox("hover", cls, box(PAPAYA_HOT)) - t.set_stylebox("pressed", cls, box(PAPAYA, INK, 10, 3, false)) - t.set_stylebox("focus", cls, box(PAPER, TEAL)) - t.set_stylebox("disabled", cls, box(PAPER_DIM, Color(0.4, 0.4, 0.45))) - t.set_color("font_color", cls, INK) - t.set_color("font_hover_color", cls, INK) - t.set_color("font_pressed_color", cls, PAPER) - t.set_color("font_focus_color", cls, INK) - t.set_color("font_disabled_color", cls, Color(0.45, 0.45, 0.5)) - - # ── Labels: ink outline everywhere for that inked-cel readability ──── + # ── Labels: ink outline everywhere, for readability straight over 3D ── + t.set_color("font_color", "Label", PAPER) t.set_color("font_outline_color", "Label", INK) t.set_constant("outline_size", "Label", 8) - t.set_color("font_color", "Label", PAPER) - # RichTextLabel (killfeed) t.set_color("default_color", "RichTextLabel", PAPER) t.set_color("font_outline_color", "RichTextLabel", INK) t.set_constant("outline_size", "RichTextLabel", 6) # ── Inputs ─────────────────────────────────────────────────────────── - t.set_stylebox("normal", "LineEdit", box(PAPER, INK, 8, 3, false)) - t.set_stylebox("focus", "LineEdit", box(PAPER, TEAL, 8, 3, false)) - t.set_color("font_color", "LineEdit", INK) - t.set_color("caret_color", "LineEdit", INK) + t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false)) + t.set_stylebox("focus", "LineEdit", box(INK_SOFT, CYAN, 4, 3, false)) + t.set_color("font_color", "LineEdit", PAPER) + t.set_color("caret_color", "LineEdit", VOLT) + t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM) + + # ── Sliders: the filled part is the charged part ───────────────────── + t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false)) + t.set_stylebox("grabber_area", "HSlider", box(PAPAYA, INK, 2, 2, false)) + t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, INK, 2, 2, false)) # ── Panels / lists ─────────────────────────────────────────────────── - t.set_stylebox("panel", "PanelContainer", box(Color(0.13, 0.12, 0.18, 0.92), INK, 14, 4)) - t.set_stylebox("panel", "ItemList", box(Color(0.13, 0.12, 0.18, 0.92), INK, 10, 3, false)) + t.set_stylebox("panel", "PanelContainer", panel()) + t.set_stylebox("panel", "Panel", panel()) + t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3)) t.set_color("font_color", "ItemList", PAPER) + t.set_color("font_selected_color", "ItemList", INK) + t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false)) + t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false)) + t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false)) - # ── Popup menus (OptionButton dropdowns) ───────────────────────────── - t.set_stylebox("panel", "PopupMenu", box(PAPER, INK, 8, 3, false)) - t.set_color("font_color", "PopupMenu", INK) - t.set_color("font_hover_color", "PopupMenu", PAPAYA) + # ── Tabs ───────────────────────────────────────────────────────────── + t.set_stylebox("panel", "TabContainer", panel()) + t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false)) + t.set_stylebox("tab_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false)) + t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, INK, 6, 3, false)) + t.set_color("font_selected_color", "TabContainer", INK) + t.set_color("font_unselected_color", "TabContainer", PAPER_DIM) + t.set_color("font_hovered_color", "TabContainer", INK) + + # ── Popups (OptionButton dropdowns) ────────────────────────────────── + t.set_stylebox("panel", "PopupMenu", panel(PANEL_DEEP, PAPAYA, 3)) + t.set_stylebox("hover", "PopupMenu", box(PAPAYA, INK, 4, 0, false)) + t.set_color("font_color", "PopupMenu", PAPER) + t.set_color("font_hover_color", "PopupMenu", INK) + + # ── Dialogs ────────────────────────────────────────────────────────── + t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4)) + t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4)) + t.set_color("title_color", "Window", VOLT) _theme = t return t -## Apply the theme to the whole root window (inherited by every Control in -## every scene from then on). Idempotent. +static func _button_look(t: Theme, cls: String) -> void: + t.set_stylebox("normal", cls, box(INK_SOFT, PAPAYA)) + t.set_stylebox("hover", cls, box(PAPAYA, INK)) + t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false)) + t.set_stylebox("focus", cls, box(INK_SOFT, CYAN)) + t.set_stylebox("disabled", cls, box(Color(0.10, 0.10, 0.14, 0.85), Color(0.28, 0.27, 0.33))) + t.set_color("font_color", cls, PAPER) + t.set_color("font_hover_color", cls, INK) + t.set_color("font_pressed_color", cls, INK) + t.set_color("font_focus_color", cls, PAPER) + t.set_color("font_disabled_color", cls, Color(0.42, 0.41, 0.48)) + t.set_color("font_outline_color", cls, INK) + t.set_constant("outline_size", cls, 5) + + +## Apply the theme to the whole root window. Idempotent, and cheap enough that +## every screen should just call it in `_ready` rather than assuming some other +## screen already did — which is exactly what left the pause menu unstyled +## whenever it was opened before the HUD had loaded. static func apply_global(tree: SceneTree) -> void: if tree and tree.root and tree.root.theme != build(): tree.root.theme = build() -## Attach hover/click UI sounds to every BaseButton under `root` (recursive). +# ── Widgets ────────────────────────────────────────────────────────────────── + + +## The wordmark treatment: papaya on a heavy ink outline, tilted off true. +## +## The tilt is the cheapest energy in the whole theme. Two and a half degrees is +## enough for the eye to register that the type is not sitting square and not +## enough to read as broken. +static func title(text: String, size: int = 64, tilt: float = -2.5) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", PAPAYA) + l.add_theme_color_override("font_outline_color", INK) + l.add_theme_constant_override("outline_size", int(maxf(8.0, size * 0.16))) + l.rotation_degrees = tilt + l.pivot_offset = Vector2(0, size * 0.5) + return l + + +## A section heading — smaller, paper-coloured, no tilt. +static func heading(text: String, size: int = 32) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", PAPER) + l.add_theme_color_override("font_outline_color", INK) + l.add_theme_constant_override("outline_size", 8) + return l + + +## A bolt-struck rule, for separating sections. +static func divider(strike_at: float = 0.34) -> BoltRule: + var b := BoltRule.new() + b.line_color = PAPAYA + b.bolt_color = VOLT + b.ink = INK + b.strike_at = strike_at + return b + + +## A panel to put things on, already themed. +static func card() -> PanelContainer: + var p := PanelContainer.new() + p.add_theme_stylebox_override("panel", panel()) + return p + + +## Sounds and the hover kick for every button under `root` (recursive). +## +## The kick is a 4% scale-up on hover and a snap back on exit. It is small on +## purpose: the theme already changes the chip's fill colour, and a big scale on +## top of that reads as the button wobbling rather than as it responding. static func wire_sounds(root: Node) -> void: - var am = root.get_tree().root.get_node_or_null("AudioManager") - if am == null: + if root == null or not root.is_inside_tree(): return + var am = root.get_tree().root.get_node_or_null("AudioManager") for btn in root.find_children("*", "BaseButton", true, false): if btn.has_meta("ui_sfx_wired"): continue btn.set_meta("ui_sfx_wired", true) - btn.mouse_entered.connect(func(): am.play_ui("ui_hover")) - btn.pressed.connect(func(): am.play_ui("ui_click")) + if am: + btn.mouse_entered.connect(func(): am.play_ui("ui_hover")) + btn.pressed.connect(func(): am.play_ui("ui_click")) + _wire_kick(btn) + + +static func _wire_kick(btn: Control) -> void: + btn.mouse_entered.connect(func(): _kick(btn, 1.04)) + btn.mouse_exited.connect(func(): _kick(btn, 1.0)) + btn.focus_entered.connect(func(): _kick(btn, 1.04)) + btn.focus_exited.connect(func(): _kick(btn, 1.0)) + + +static func _kick(btn: Control, to: float) -> void: + if not is_instance_valid(btn) or not btn.is_inside_tree(): + return + # Scale about the chip's own centre, or it grows off to one side. The pivot + # is set every time because a container can resize the button after it was + # wired, and a stale pivot is what makes a hover look like a lurch. + btn.pivot_offset = btn.size * 0.5 + var tw := btn.create_tween() + # The menu runs while the tree is paused, so the tween has to as well. + tw.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS) + tw.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tw.tween_property(btn, "scale", Vector2(to, to), 0.12) diff --git a/weapons/base_hitscan_weapon.gd b/weapons/base_hitscan_weapon.gd index 0e5b3ae..5d9c7ef 100644 --- a/weapons/base_hitscan_weapon.gd +++ b/weapons/base_hitscan_weapon.gd @@ -122,12 +122,35 @@ func _fire() -> void: if current_ammo == 0: _start_reload() +## The muzzle the WORLD should see this shot come from. In first person that is +## the viewmodel's own muzzle. In third person the viewmodel is parked at the +## camera, so its muzzle sits inside the player's head — the flash has to come +## off the gun the character is actually holding instead. +func world_muzzle() -> Node3D: + if player and "third_person" in player and player.third_person \ + and player.has_method("get_visual_model"): + var vis = player.get_visual_model() + if vis and vis.has_method("get_muzzle_node"): + var m: Node3D = vis.get_muzzle_node() + if m: + return m + return muzzle_flash + + func _play_muzzle_flash() -> void: - if muzzle_flash: - ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position) + if not muzzle_flash: + return + var m := world_muzzle() + ExplosionVFX.muzzle_flash(m.get_parent(), m.position) + # Light whichever muzzle the world can see, so the flash actually throws + # light from the barrel rather than from behind the camera. + if m is OmniLight3D: + var lit: OmniLight3D = m + lit.light_energy = 8.0 + create_tween().tween_property(lit, "light_energy", 0.0, 0.05) + if m != muzzle_flash: muzzle_flash.light_energy = 8.0 - var tween = create_tween() - tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05) + create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.05) func _shoot_hitscan() -> void: if not camera: return @@ -186,9 +209,31 @@ func _shoot_hitscan() -> void: _spawn_tracer(origin, final_target) if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer): - player.server_play_fire_effects.rpc_id(1, origin, final_target, name, true) + # Everyone else sees our CHARACTER, never our viewmodel, so the tracer + # and the gunshot audio have to leave from the gun in its hands — the + # camera position we raycast from is inside our own head. + player.server_play_fire_effects.rpc_id(1, remote_muzzle_position(), + final_target, name, true) func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void: # Cel-styled cosmetic tracer from the barrel tip - var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position - HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target) + HitscanTracer.spawn_bolt(get_tree().current_scene, muzzle_world_position(), final_target) + + +## World position the shot visually leaves from. Falls back to the weapon node +## when a weapon has no muzzle marker at all (the AWP). +func muzzle_world_position() -> Vector3: + var m := world_muzzle() + return m.global_position if m else global_position + + +## Where OTHER clients should see this shot leave from: always the gun in the +## character's hand, whatever view mode we happen to be in locally. +func remote_muzzle_position() -> Vector3: + if player and player.has_method("get_visual_model"): + var vis = player.get_visual_model() + if vis and vis.has_method("get_muzzle_node"): + var m: Node3D = vis.get_muzzle_node() + if m: + return m.global_position + return muzzle_world_position() diff --git a/weapons/base_projectile_weapon.gd b/weapons/base_projectile_weapon.gd index f044c95..d325341 100644 --- a/weapons/base_projectile_weapon.gd +++ b/weapons/base_projectile_weapon.gd @@ -118,12 +118,44 @@ func _fire() -> void: if current_ammo == 0: _start_reload() +## See BaseHitscanWeapon.world_muzzle(): in third person the viewmodel's muzzle +## sits inside the player's head, so world effects must come off the gun the +## character is actually holding. +func world_muzzle() -> Node3D: + if player and "third_person" in player and player.third_person \ + and player.has_method("get_visual_model"): + var vis = player.get_visual_model() + if vis and vis.has_method("get_muzzle_node"): + var m: Node3D = vis.get_muzzle_node() + if m: + return m + return muzzle_flash + + +## Where OTHER clients should see this shot leave from — always the held gun. +func remote_muzzle_position() -> Vector3: + if player and player.has_method("get_visual_model"): + var vis = player.get_visual_model() + if vis and vis.has_method("get_muzzle_node"): + var m: Node3D = vis.get_muzzle_node() + if m: + return m.global_position + var own := world_muzzle() + return own.global_position if own else global_position + + func _play_muzzle_flash() -> void: - if muzzle_flash: - ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position) + if not muzzle_flash: + return + var m := world_muzzle() + ExplosionVFX.muzzle_flash(m.get_parent(), m.position) + if m is OmniLight3D: + var lit: OmniLight3D = m + lit.light_energy = 8.0 + create_tween().tween_property(lit, "light_energy", 0.0, 0.05) + if m != muzzle_flash: muzzle_flash.light_energy = 8.0 - var tween = create_tween() - tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05) + create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.05) func _shoot_projectile() -> void: if not camera: return @@ -142,7 +174,10 @@ func _shoot_projectile() -> void: _spawn_custom_projectile(origin, fire_dir) if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer): - player.server_play_fire_effects.rpc_id(1, origin, fire_dir, weapon_name, false) + # Remote viewers see our character, so the projectile must appear to + # leave the held gun rather than our camera. + player.server_play_fire_effects.rpc_id(1, remote_muzzle_position(), + fire_dir, weapon_name, false) func _spawn_custom_projectile(_origin: Vector3, _fire_dir: Vector3) -> void: # Virtual method for custom projectiles (e.g., bouncing, homing) diff --git a/weapons/double_barrel_shotgun.gd b/weapons/double_barrel_shotgun.gd index 3652102..c031a92 100644 --- a/weapons/double_barrel_shotgun.gd +++ b/weapons/double_barrel_shotgun.gd @@ -133,12 +133,33 @@ func _apply_impulse() -> void: else: player.apply_impulse(final_force) +## This one extends Node3D directly rather than BaseHitscanWeapon, so it needs +## its own copy. See BaseHitscanWeapon.world_muzzle(): in third person the +## viewmodel's muzzle sits inside the player's head, so world-visible effects +## have to come off the gun the character is actually holding. +func world_muzzle() -> Node3D: + if player and "third_person" in player and player.third_person \ + and player.has_method("get_visual_model"): + var vis = player.get_visual_model() + if vis and vis.has_method("get_muzzle_node"): + var m: Node3D = vis.get_muzzle_node() + if m: + return m + return muzzle_flash + + func _play_muzzle_flash() -> void: - if muzzle_flash: - ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position) + if not muzzle_flash: + return + var m := world_muzzle() + ExplosionVFX.muzzle_flash(m.get_parent(), m.position) + if m is OmniLight3D: + var lit: OmniLight3D = m + lit.light_energy = 8.0 + create_tween().tween_property(lit, "light_energy", 0.0, 0.1) + if m != muzzle_flash: muzzle_flash.light_energy = 8.0 - var tween = create_tween() - tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.1) + create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.1) func _shoot_hitscan() -> void: if not camera: return diff --git a/weapons/hitscan_tracer.gd b/weapons/hitscan_tracer.gd index d903f74..f30f67a 100644 --- a/weapons/hitscan_tracer.gd +++ b/weapons/hitscan_tracer.gd @@ -30,7 +30,7 @@ static func spawn_bolt(scene: Node, origin: Vector3, target: Vector3, core.mesh = core_mesh core.rotation.x = deg_to_rad(90) # orient capsule along Z core.position = Vector3(0, 0, -0.55) # tail sits at the origin - core.material_overlay = LevelMaterials.outline(0.012) + core.material_overlay = LevelMaterials.outline(0.004) tracer.add_child(core) tracer.position = origin diff --git a/weapons/weapon_grips.gd b/weapons/weapon_grips.gd new file mode 100644 index 0000000..d4045e3 --- /dev/null +++ b/weapons/weapon_grips.gd @@ -0,0 +1,21 @@ +extends Object +class_name WeaponGrips + +## Where the hands sit on a weapon, in the WEAPON's own local space. +## +## This is the authored ground truth for "where is this gun held". WeaponManager +## places the first-person viewmodel's arms at these points, and +## SkinnedPlayerModel reaches the third-person character's hands to the same +## ones, so both views agree on the grip. Every weapon model in the set is built +## with its origin at the pistol grip, which is why one pair of offsets covers +## all of them. +## +## Deliberately dependency-free (no autoloads, no weapon scripts) so both the +## weapon system and the character models can reference it without dragging the +## other's load order along. + +const GRIP := Vector3(0.04, -0.05, 0.05) # trigger hand +const SUPPORT := Vector3(-0.02, -0.02, -0.3) # handguard hand + +## Fallback barrel length (grip -> muzzle) for a weapon with no muzzle marker. +const DEFAULT_MUZZLE_DIST := 0.45 diff --git a/weapons/weapon_grips.gd.uid b/weapons/weapon_grips.gd.uid new file mode 100644 index 0000000..9f56918 --- /dev/null +++ b/weapons/weapon_grips.gd.uid @@ -0,0 +1 @@ +uid://b4aa52jn4s27o diff --git a/weapons/weapon_manager.gd b/weapons/weapon_manager.gd index 9cdb5a3..52cc5eb 100644 --- a/weapons/weapon_manager.gd +++ b/weapons/weapon_manager.gd @@ -274,9 +274,11 @@ func _set_layer_recursive(node: Node, layer_mask: int) -> void: func _add_procedural_arms(weapon: Node3D) -> void: # Attach to weapon instead of model_root so reload choreography can move # the gun (model_root) and each hand (named pivots) independently. - _build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05), "ArmR") + # Hand points come from WeaponGrips so the third-person model reaches for + # exactly the same spots this viewmodel uses. + _build_arm(weapon, Vector3(0.25, -0.3, 0.5), WeaponGrips.GRIP, "ArmR") if "weapon_name" in weapon and weapon.weapon_name != "Knife": - _build_arm(weapon, Vector3(-0.25, -0.3, 0.4), Vector3(-0.02, -0.02, -0.3), "ArmL") + _build_arm(weapon, Vector3(-0.25, -0.3, 0.4), WeaponGrips.SUPPORT, "ArmL") ## A first-person arm styled after the character skin: dark detached sleeve,