Compare commits
@@ -0,0 +1,326 @@
|
|||||||
|
---
|
||||||
|
name: character-pipeline
|
||||||
|
description: Import, rig, stylize and animate an anime-styled character into Papaya-Shooter as a selectable skin — keeping the model's own skeleton, artist weights and separate body/cloth/hair meshes, with cloth and hair driven by the spring solver. Use when adding a new playable character, re-importing an existing one, debugging skinning/cloth/hair/animation problems on a character, or changing the cel-shaded look. Triggers on "add a character", "import a skin", "new playable model", "skirt clipping", "hair flailing", "T-posing", "character looks squashed".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Character pipeline
|
||||||
|
|
||||||
|
Turns a source model into a playable, cel-shaded, cloth-simulated character skin.
|
||||||
|
|
||||||
|
The whole design follows one principle, which is also what the Hoyoverse-class
|
||||||
|
anime pipelines (Genshin / Star Rail / Zenless Zone Zero) are built on:
|
||||||
|
|
||||||
|
> **The character is not one object. It is a body, a set of garments, and hair —
|
||||||
|
> authored separately, rigged separately, and moved by different systems.**
|
||||||
|
> The body is skinned and animated. The garments and hair are bone chains that
|
||||||
|
> the animation never touches; physics moves them. Keeping those separate is
|
||||||
|
> what makes the result read as an anime character instead of a mannequin in a
|
||||||
|
> painted-on costume.
|
||||||
|
|
||||||
|
Everything below exists to protect that separation.
|
||||||
|
|
||||||
|
## The one rule
|
||||||
|
|
||||||
|
**If a model arrives with a skeleton, that skeleton ships.** Its bones, its
|
||||||
|
artist-painted weights, its per-part meshes and its skirt/hair chains all
|
||||||
|
survive. Only the ANIMATION is moved onto it.
|
||||||
|
|
||||||
|
The old route (`strip_rig.py` → `autorig.py` → `merge_animations.py`) solved a
|
||||||
|
bone-*naming* problem by destroying the asset — 18 meshes became 1, 21 skirt
|
||||||
|
bones and ~50 hair bones became 0, and 16% of vertices ended up pulled by both
|
||||||
|
legs. `tools/rig_map.py` solves naming properly now. **Never reach for
|
||||||
|
`strip_rig.py` or `--rebind`** unless the model genuinely has no skeleton at all.
|
||||||
|
|
||||||
|
## Doing it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From an already-rigged local model (the normal case)
|
||||||
|
python tools/pipeline.py --input assets/characters/incoming/<name>.glb --name <name> --rigged
|
||||||
|
|
||||||
|
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN)
|
||||||
|
python tools/pipeline.py --uid <uid> --name <name>
|
||||||
|
|
||||||
|
# From an unrigged mesh — auto-rigs, and accepts the quality loss
|
||||||
|
python tools/pipeline.py --input <mesh.glb> --name <name>
|
||||||
|
|
||||||
|
# Rigged (or auto-rigged) but with NO skirt/hair bones — grow them, or the
|
||||||
|
# costume is welded solid and the spring solver has nothing to simulate
|
||||||
|
python tools/pipeline.py --input <model.glb> --name <name> --rigged --grow-cloth
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, once, so Godot sees the new files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --headless --path . --import
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is `assets/characters/skins/<name>.glb` + `<name>.rig.json`, and a
|
||||||
|
registry entry in `skins.json` that `SkinManager` picks up with no code change.
|
||||||
|
|
||||||
|
Blender is required (`BLENDER_PATH`, or auto-found under
|
||||||
|
`C:\Program Files\Blender Foundation`). Godot lives at
|
||||||
|
`C:\Program Files\Godot\Godot_v4.7-stable_win64_console.exe`.
|
||||||
|
|
||||||
|
## The stages, and what each one protects
|
||||||
|
|
||||||
|
| Stage | Where | Protects |
|
||||||
|
|---|---|---|
|
||||||
|
| Fix unlit/emissive materials | `tools/gltf_fix.py` | Textures surviving import at all |
|
||||||
|
| Resolve bone ROLES, not names | `tools/rig_map.py` | The model's own skeleton |
|
||||||
|
| Rebuild parenting | `retarget.py::rebuild_hierarchy` | Limbs/cloth following the hips |
|
||||||
|
| Grow cloth chains (opt-in) | `tools/cloth_bones.py` | A costume that has no bones being able to move at all |
|
||||||
|
| Subdivide cloth panels | `retarget.py::subdivide_cloth_panels` | A skirt being able to bend at all |
|
||||||
|
| Retarget clips as rest-relative deltas | `retarget.py::retarget_clip` | Limbs not being twisted by foreign bone roll |
|
||||||
|
| Leave cosmetic bones unkeyed | `export_optimize_animation_keep_anim_armature=False` | Physics owning the cloth |
|
||||||
|
| Classify every SURFACE | `tools/surface_map.py` | The runtime never re-guessing what a surface is |
|
||||||
|
| Write the rig sidecar | `retarget.py::describe_rig` | The runtime never re-guessing anatomy |
|
||||||
|
| Cel look, per surface class | `LevelMaterials.apply_character_look` | Hair not reading as a solid dark cap |
|
||||||
|
| Cloth + hair | `characters/spring_bones.gd` | Clothes reading as clothes |
|
||||||
|
| Per-character judgement calls | `characters/tuning_store.gd` | Art direction not becoming another constant |
|
||||||
|
|
||||||
|
## The surface table
|
||||||
|
|
||||||
|
The sidecar carries a `surfaces` list saying what each mesh surface IS — `body`,
|
||||||
|
`cloth`, `hair`, `accessory`, or `linework` (the model's own ink shell, which is
|
||||||
|
not a surface of the character at all). It is keyed on the MATERIAL name, because
|
||||||
|
every character in this game arrives with its meshes called `Object_7` through
|
||||||
|
`Object_32` while material names survive the glTF round trip intact.
|
||||||
|
|
||||||
|
`SkinSurfaces` reads it and `apply_character_look` acts on it: hair takes a much
|
||||||
|
thinner outline than the body, cloth a heavier one and a crisper terminator,
|
||||||
|
accessories the heaviest. `SkinnedPlayerModel.surfaces_of(cls)` answers the
|
||||||
|
question for anything else that needs it.
|
||||||
|
|
||||||
|
Backfill a character that predates it, without re-importing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blender --background --python tools/surface_map.py -- \
|
||||||
|
assets/characters/skins/<name>.glb
|
||||||
|
```
|
||||||
|
|
||||||
|
New imports get it from `describe_rig`, built from the same chains the solver
|
||||||
|
uses, so the surface table and the cloth solver can never disagree about which
|
||||||
|
bones are a skirt.
|
||||||
|
|
||||||
|
## Per-character judgement
|
||||||
|
|
||||||
|
Anything derivable from the skeleton is derived. What is left is genuinely an
|
||||||
|
artist's call, and it lives in layered JSON rather than in a constant:
|
||||||
|
|
||||||
|
| File | Scope | Class |
|
||||||
|
|---|---|---|
|
||||||
|
| `assets/characters/weapon_holds.json` | character + weapon | `WeaponHoldTuning` |
|
||||||
|
| `assets/characters/rig_anchors.json` | character | `RigAnchors` |
|
||||||
|
|
||||||
|
Both layer `defaults` → `skins.<skin>._all` → `skins.<skin>.<subject>` through
|
||||||
|
`TuningStore`. An absent file means "use what the code derives", so nothing here
|
||||||
|
is required for the game to run. Adding a knob is adding a row to a `KNOBS` spec
|
||||||
|
table — the lab builds its whole UI from those.
|
||||||
|
|
||||||
|
`RigAnchors` is where "the grip sits here in the palm" lives. A hand bone's
|
||||||
|
origin is the WRIST; how far down the palm a grip belongs depends on the
|
||||||
|
character's hand and cannot be derived. It defaults to identity, and identity is
|
||||||
|
exactly the derived mount. Do **not** put a fixed rotation on the weapon mount
|
||||||
|
instead — a bone attachment is expressed in the BONE's axes, no two rigs agree on
|
||||||
|
those, and that constant is why the hand mount points were once wrong on every
|
||||||
|
character.
|
||||||
|
|
||||||
|
## The rig lab
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --path . res://debug/rig_lab.tscn
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD
|
||||||
|
(character + weapon) and the ANCHORS (character), and save.
|
||||||
|
|
||||||
|
**The HOLD is per pose.** The runtime blends between exactly two holds, on
|
||||||
|
`ads`, so the lab offers two: low ready and aiming. Selecting a pose rebuilds
|
||||||
|
the hold sliders to that pose's — you never see a control belonging to the pose
|
||||||
|
you are not adjusting. Running and Crouched use the low-ready hold, and the
|
||||||
|
heading says so rather than letting someone tune "Running" and wonder why
|
||||||
|
standing still changed. `pitch` exists at low ready only: down the sights the
|
||||||
|
muzzle follows the camera, so there is nothing there to tune, and a slider that
|
||||||
|
does nothing is worse than a missing one.
|
||||||
|
|
||||||
|
**The wrists turn the HAND, not the gun.** The weapon is a child of a
|
||||||
|
BoneAttachment3D on the trigger hand, so the two are welded by construction: a
|
||||||
|
wrist rotation swings the barrel off the aim line and takes every control that
|
||||||
|
could correct it along with it, which made the knob useless for aligning a hand
|
||||||
|
to a gun. `ShooterPoseModifier.wrist_comp_r` is the exact counter-rotation in
|
||||||
|
the hand's local frame, and `SkinnedPlayerModel._hold_weapon_still` applies it.
|
||||||
|
To rotate the GUN inside the hand instead, use the grip rotation in ANCHORS.
|
||||||
|
|
||||||
|
Knobs that describe the WEAPON and the hands on it — where each hand sits along
|
||||||
|
it and off its barrel line, the finger curls, the weapon size — are shared,
|
||||||
|
because shouldering a gun does not move the hand along it. Both wrists take
|
||||||
|
pitch, yaw and roll in the gun's own frame, per pose. Click a surface class
|
||||||
|
to isolate it — that is how the classifier gets checked: click `hair` and
|
||||||
|
anything else still standing was misclassified.
|
||||||
|
|
||||||
|
**Drag the coloured markers.** They ARE the anchor points the hands are solved
|
||||||
|
onto — red the trigger grip, green the support hand, blue the buttstock — and
|
||||||
|
the one under the mouse swells and draws through the body so it can be grabbed
|
||||||
|
where the hands would otherwise hide it.
|
||||||
|
|
||||||
|
Dragging an anchor is not the same as any slider:
|
||||||
|
|
||||||
|
| | moves |
|
||||||
|
|---|---|
|
||||||
|
| `grip_offset` (ANCHORS) | the GUN, inside the fist |
|
||||||
|
| `gun_fore` / `gun_stock` (HOLD) | the hands ALONG the weapon's own axis |
|
||||||
|
| dragging a marker | the anchor itself, in three dimensions |
|
||||||
|
|
||||||
|
That distinction was the gap. `gun_fore` and `gun_stock` are distances along the
|
||||||
|
barrel, so the two hand anchors could slide up and down the gun and nowhere
|
||||||
|
else — no use for a handguard below the bore, an angled foregrip, or a pistol
|
||||||
|
whose grip is nowhere near its barrel line. A drag writes `grip_shift` or
|
||||||
|
`fore_shift` in the gun's own across/up/along frame, so a sideways nudge stays
|
||||||
|
sideways as the weapon pitches; the buttstock marker writes the shoulder pocket
|
||||||
|
for whichever pose is showing.
|
||||||
|
|
||||||
|
The anchors sit on the shoulder, and the arm chasing them moves the shoulder, so
|
||||||
|
a drag settles at 0.77x-1.13x of the mouse. Small enough to ignore — you stop
|
||||||
|
when it looks right — and measured, not assumed.
|
||||||
|
|
||||||
|
## Read before you touch anything
|
||||||
|
|
||||||
|
Load the reference that matches what you are doing. They are short and each one
|
||||||
|
is a list of things that cost a debugging cycle to learn.
|
||||||
|
|
||||||
|
- **`references/failure-modes.md`** — **read this first.** Seven characters
|
||||||
|
shipped "All checks passed" and four were visibly broken. What each failure
|
||||||
|
was, why the suite missed it, and the rule that generalises it to any model.
|
||||||
|
- **`references/separation.md`** — body vs. garments vs. hair: what must stay
|
||||||
|
separate, how cloth chains are detected and classed, why cloth is never
|
||||||
|
skinned to a leg, and the ZZZ-convention mapping.
|
||||||
|
- **`references/growing-cloth-bones.md`** — what to do when the source has no
|
||||||
|
skirt or hair bones: how the chains are fitted to the geometry and re-weighted.
|
||||||
|
- **`references/rigging.md`** — role resolution, hierarchy rebuild, cloth panel
|
||||||
|
subdivision, twist bones, joint helpers, the retarget maths.
|
||||||
|
- **`references/cloth-and-hair.md`** — the position-based spring solver, its
|
||||||
|
colliders, per-class tuning, collision hulls, LOD and cost.
|
||||||
|
- **`references/stylization.md`** — cel shading, outlines, the imported
|
||||||
|
line-work trap, eyes, materials.
|
||||||
|
- **`references/verification.md`** — every measuring tool, what each one
|
||||||
|
actually measures, and the pose-reading trap that invalidated all of them
|
||||||
|
once. **Read this before trusting any measurement.**
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
|
||||||
|
1. **Never join meshes.** Per-part meshes are how body, cloth and hair stay
|
||||||
|
separable — for materials, for the outline pass, and for the cloth solver's
|
||||||
|
hull extraction.
|
||||||
|
2. **Never key cosmetic bones.** If a clip has tracks on skirt/hair bones, the
|
||||||
|
AnimationPlayer overwrites the solver every frame and the cloth goes rigid.
|
||||||
|
3. **Never skin cloth to a leg.** A vertex weighted 0.9 to a thigh cannot be
|
||||||
|
moved by its own cloth bone, so the solver loses the authority to push it out
|
||||||
|
of that thigh — and the leg still overtakes it. There was a
|
||||||
|
`bind_cloth_to_legs()`; it is deleted, and the note above its grave in
|
||||||
|
`retarget.py` says why.
|
||||||
|
4. **Never run `SkinLegRepair` on authored weights.** It snaps weights and
|
||||||
|
deletes triangles. It exists only to undo auto-rigging. It is gated on
|
||||||
|
`weights_authored`, which is MEASURED, not assumed.
|
||||||
|
5. **Measure from inside the modifier pass.** See `references/verification.md`.
|
||||||
|
6. **Never assume an axis.** Up, forward and scale are all measurable from the
|
||||||
|
skeleton. Assuming +Z is up scaled three characters 7x and left them on their
|
||||||
|
backs — and the height check passed on every one of them, because the number
|
||||||
|
being normalised always comes out right whether or not it was the right
|
||||||
|
number. See `references/failure-modes.md`.
|
||||||
|
7. **Never look a bone up by name.** `tools/rig_map.py` resolves roles and writes
|
||||||
|
them to the sidecar so nothing downstream has to guess. Any hardcoded spelling
|
||||||
|
— `_find_bone(["RightHand", ...])`, a `thigh`/`shin` substring test — is a rig
|
||||||
|
this project has not met yet. Four characters could not hold a gun because of
|
||||||
|
exactly one such lookup.
|
||||||
|
|
||||||
|
## After importing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks
|
||||||
|
godot --headless --path . -s res://debug/surface_class_check.gd # every surface classified
|
||||||
|
godot --headless --path . -s res://debug/character_picker_check.gd # the escape-menu roster
|
||||||
|
godot --headless --path . -s res://debug/rig_anchor_check.gd # anchors move the weapon
|
||||||
|
godot --headless --path . -s res://debug/cloth_clip_check.gd # leg-through-cloth
|
||||||
|
godot --headless --path . -s res://debug/cloth_settle_check.gd # idle stability
|
||||||
|
godot --headless --path . -s res://debug/cloth_perf_check.gd # ms per character
|
||||||
|
godot --headless --path . -s res://debug/travel_dir_check.gd # legs face travel
|
||||||
|
```
|
||||||
|
|
||||||
|
And LOOK at it, which is where four of the last five real defects were found:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --path . -s res://debug/roster_capture.gd -- <dir> # every character, one shot each
|
||||||
|
godot --path . -s res://debug/ui_capture.gd -- <dir> # every menu screen
|
||||||
|
godot --path . res://debug/rig_lab.tscn -- shot <png> <skin>
|
||||||
|
```
|
||||||
|
|
||||||
|
`surface_class_check` fails on any surface that falls through to the heuristic
|
||||||
|
rather than resolving from the table. That is deliberate: a model whose names
|
||||||
|
stopped matching still RENDERS, because the fallback catches it — it just
|
||||||
|
quietly loses its per-class art direction, which is exactly the kind of
|
||||||
|
regression nothing else would report.
|
||||||
|
|
||||||
|
What "good" looks like on Taila, for calibration:
|
||||||
|
|
||||||
|
| Measure | Good | Bad |
|
||||||
|
|---|---|---|
|
||||||
|
| Idle skirt movement | < 0.1 deg/frame | 0.5+, or never decaying |
|
||||||
|
| Leg inside cloth, idle/walk | < 25 mm | 100 mm |
|
||||||
|
| Leg inside cloth, run/slide/dash | ~95 mm *(current, unsolved)* | — |
|
||||||
|
| Solver cost | ~2.6 ms/character | 10 ms |
|
||||||
|
| Stride vs. travel direction | < 10° (except a capped sidestep) | 90° |
|
||||||
|
| Bind-pose AABB | tall on the hips→head axis, others < 2.5 m | tallest axis is depth |
|
||||||
|
|
||||||
|
## Characters currently shipping
|
||||||
|
|
||||||
|
The six in `skins.json`, with what `surface_class_check` reports:
|
||||||
|
|
||||||
|
| Skin | Source | Cloth chains | Surfaces |
|
||||||
|
|---|---|---|---|
|
||||||
|
| taila | rigged, Sketchfab CC-BY | 35 | 18 — body 6, cloth 7, hair 1, linework 4 |
|
||||||
|
| kiyoko | VRoid, CC-BY | 20 | 13 — body 8, cloth 3, hair 2 |
|
||||||
|
| aria | VRoid, CC-BY | 15 | 15 — body 9, cloth 4, hair 2 |
|
||||||
|
| momo | VRoid, CC-BY | 9 | 5 — body 2, cloth 1, hair 2 |
|
||||||
|
| miku | unrigged source, auto-rigged | 0 | 4 — body 3, hair 1 *(one mesh, four surfaces)* |
|
||||||
|
| mannequin | Quaternius CC0, from the animation library | 0 | 2 — body 2 |
|
||||||
|
|
||||||
|
## Known-unsolved
|
||||||
|
|
||||||
|
- **Peak cloth clipping** in a run, slide and dash sits at ~95 mm of thigh
|
||||||
|
inside the skirt. Idle, walk and fall are clean. The solver sees the contact
|
||||||
|
and pushes on it every iteration; the remaining gap is a standing fight
|
||||||
|
between the collision and the garment's own shape constraints.
|
||||||
|
- **No foot IK.** Feet do not plant on ground height, so stairs and uneven
|
||||||
|
ground read as sliding.
|
||||||
|
- **No strafe or backpedal clips.** Direction is conveyed by yawing the hips
|
||||||
|
(`SkinnedPlayerModel._update_travel`), which is capped, so a pure sidestep
|
||||||
|
still runs its legs ~40° off the direction of travel.
|
||||||
|
- **momo's idle pose is wrong** — arms overhead and a pinched waist. Every
|
||||||
|
assertion passes on her: she loads, animates, classifies and mounts a weapon.
|
||||||
|
It shows up only in `roster_capture`.
|
||||||
|
|
||||||
|
**Diagnosed, not fixed.** Her `driven_bones` contains `Root_001` through
|
||||||
|
`Root_007` — and those are her HAIR roots. The Godot surface dump shows
|
||||||
|
`Hair_A` dominated by `Root_001_001:3203`, `Root_007:3203`, `Root_005:2642`.
|
||||||
|
So the animation is keying bones that physics is supposed to own, which is
|
||||||
|
non-negotiable #2 being violated by the role resolver rather than by a clip.
|
||||||
|
The corroboration is in the surface table: her hair surfaces report only 1.8%
|
||||||
|
and 9.4% chain share, because most of their vertices belong to `Root_00N`,
|
||||||
|
which is in no chain at all.
|
||||||
|
|
||||||
|
`Root_00N` matches no COSMETIC stem, so `is_cosmetic` does not catch it and
|
||||||
|
nothing keeps it out of the driven set. Fixing it by adding "root" to the
|
||||||
|
stems would be wrong — a rig whose actual root is called `Root` would lose its
|
||||||
|
hips. The fix is structural: a bone whose geometry is dominated by a mesh
|
||||||
|
classified `hair` is a hair bone, whatever it is called. The surface table
|
||||||
|
now makes that answerable at build time, which it was not when this rig was
|
||||||
|
imported. Not attempted here — it needs a Blender re-run and re-verification
|
||||||
|
of all six characters.
|
||||||
|
|
||||||
|
Her `head` role is also wrong (`Unused_Noname_010`, when a real `Head` bone
|
||||||
|
exists and is in her spine chain), and her spine chain runs two junk bones
|
||||||
|
PAST the head. Probably the same import; worth fixing in the same pass.
|
||||||
|
- **A stray `Icosphere` ships inside every skin GLB** — 42 vertices, no parent,
|
||||||
|
no vertex groups. It rides in from the animation library. Harmless, and now
|
||||||
|
skipped by construction rather than by name in `surface_map`, but the export
|
||||||
|
should not be producing it.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Cloth and hair — `characters/spring_bones.gd`
|
||||||
|
|
||||||
|
A position-based (PBD) solver over the rig's own cosmetic bones, the same shape
|
||||||
|
as Magica Cloth 2's BoneCloth, which is the tool the Hoyoverse-style pipelines
|
||||||
|
are built around.
|
||||||
|
|
||||||
|
## Why cloth cannot be solved with weights
|
||||||
|
|
||||||
|
Weight a skirt to the thighs → trousers. Weight it to the hips → a rigid bell.
|
||||||
|
Neither is cloth. A skirt reads as cloth because it **lags** — it keeps going
|
||||||
|
when the hips stop, swings out through a turn, floats on the way up through a
|
||||||
|
jump. That is inertia, and inertia has to be integrated, not skinned.
|
||||||
|
|
||||||
|
## The solve
|
||||||
|
|
||||||
|
Installed as a `SkeletonModifier3D` **after** `ShooterPoseModifier`, so it reacts
|
||||||
|
to the final body pose (animation + lean/slide/aim layer).
|
||||||
|
|
||||||
|
1. **Every JOINT is a particle.** Bone `i` spans particle `i` to `i+1`, so a
|
||||||
|
bone's HEAD can move. This is the load-bearing choice: a contact with no
|
||||||
|
rotational leverage — a thigh against the top of a panel — is resolved by the
|
||||||
|
whole panel moving, which is what a real skirt does.
|
||||||
|
2. **Predict** with inertia, gravity and wind, in the chain ANCHOR's frame, so
|
||||||
|
travelling at a steady speed excites nothing.
|
||||||
|
3. **Relax everything together**, Gauss-Seidel: cross-panel links, then per
|
||||||
|
chain — bone length, bend limit, backstop, colliders.
|
||||||
|
4. **Convert to rotations once**, at the end, and feed back the poses the
|
||||||
|
skeleton ACTUALLY got.
|
||||||
|
|
||||||
|
Order matters: **links first, chains second**, so the last thing to touch any
|
||||||
|
particle is its collision. With the chains first, every iteration ended by
|
||||||
|
pulling neighbouring panels back toward their rest separation — straight into the
|
||||||
|
leg just cleared. Measured on a slide: 93 mm in, 95 mm out; with the links off
|
||||||
|
entirely the same frame solved to 27 mm.
|
||||||
|
|
||||||
|
Then a short tail of **collision-and-length-only** passes, because the bend limit
|
||||||
|
and the backstop are shape constraints and re-imposing them after each collision
|
||||||
|
makes the two argue rather than converge.
|
||||||
|
|
||||||
|
## What the previous version did wrong
|
||||||
|
|
||||||
|
One spring per bone plus FOUR repair passes behind it, each writing bone poses the
|
||||||
|
next read back and partly undid — and the last (an ancestor "lift") wrote poses
|
||||||
|
never fed back into the spring state, so every frame began pulling against a pose
|
||||||
|
the springs did not know about. That feedback was the skirt "glitching out".
|
||||||
|
Three bolt-on stages (a bodily chain push, an ancestor lift, a drape weight) all
|
||||||
|
existed because a rotation-only solver cannot clear a contact near the head it
|
||||||
|
rotates about.
|
||||||
|
|
||||||
|
## Per-class tuning (`TUNING`)
|
||||||
|
|
||||||
|
| | w | zeta | gravity | wind | stray | hinge | bend |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| hair | 15 | 0.45 | 1.6 | 0.30 | 0.45 | 26° | 52° |
|
||||||
|
| skirt | 12 | 0.48 | 2.0 | 0.18 | 1.25 | 78° | 55° |
|
||||||
|
| cloth | 13 | 0.55 | 1.9 | 0.18 | 1.10 | 62° | 52° |
|
||||||
|
|
||||||
|
- **`hinge` vs `bend`** are the same constraint meaning different things. Between
|
||||||
|
segments it is BENDING stiffness (how sharply cloth may crease) and belongs
|
||||||
|
tight. At the root it is a HINGE at the waistband — a knee coming up to hip
|
||||||
|
height puts a thigh horizontally through where the front panel hangs, and the
|
||||||
|
panel must ride onto it, which is most of a right angle. Magica's skirt guide
|
||||||
|
suggests 20° at the root, but that is for a chain whose first bone is a large
|
||||||
|
share of the skirt; Taila's first segment is 49 mm of a 288 mm panel, so 20°
|
||||||
|
there moves the panel below it by **17 mm**.
|
||||||
|
- **`stray`** is Magica's "Max Distance" — how far a particle may end from where
|
||||||
|
the animation alone would have put it. Tight on hair (it has nothing to get out
|
||||||
|
of the way of; this is what stops flailing), loose on cloth (a skirt has to lie
|
||||||
|
along a thigh that has gone horizontal in a slide).
|
||||||
|
- **`gravity` is small** because the authored rest pose already has the garment
|
||||||
|
hanging. A constant force offsets the resting particle by `g/w²`, so a large
|
||||||
|
value pulls the hem below where it was modelled — into the thigh it then has to
|
||||||
|
be pushed out of.
|
||||||
|
|
||||||
|
## Colliders
|
||||||
|
|
||||||
|
Five capsules, measured from the mesh by `retarget.py::_leg_colliders`:
|
||||||
|
|
||||||
|
- A **waist LID** (`lid: true`) across the pelvis. Magica's skirt guide is blunt
|
||||||
|
about this: one big sphere at the waist "acts as a lid that prevents particles
|
||||||
|
in the skirt from slipping into the body". Leg capsules alone only stop cloth
|
||||||
|
going through a thigh; nothing stops a panel swinging INWARD into the pelvis.
|
||||||
|
- **Tapered** thigh and shin capsules — separate head and tail radii. A limb is
|
||||||
|
not a cylinder: Taila's thigh is ~0.11 m at the hip and ~0.06 m above the knee.
|
||||||
|
Fitted as a least-squares line through ten bands, dropping the contaminated end
|
||||||
|
bands, with twist children folded in.
|
||||||
|
- `from: 0.10` — the capsule starts BELOW the hip joint. The top of a thigh is
|
||||||
|
hip, buried inside the body the skirt hangs from.
|
||||||
|
|
||||||
|
**Per-point rest clearance.** Each (bone, collider) point's radius is capped to
|
||||||
|
just inside where that point rests, so the authored rest pose is a valid state.
|
||||||
|
Without it, cloth hanging against a thigh is shoved out and pulled back every
|
||||||
|
frame forever. The cap is PER POINT, not per bone — scaling a whole bone by its
|
||||||
|
worst point switches collision off for every panel whose top hangs against the
|
||||||
|
thigh, which is all the ones that matter.
|
||||||
|
|
||||||
|
## Collision hulls come from the MESH
|
||||||
|
|
||||||
|
`SkinnedPlayerModel._cloth_hulls`, at load time: every vertex a cloth bone
|
||||||
|
dominates, binned into a ~20 mm grid, outermost cells kept, capped at 14 points.
|
||||||
|
|
||||||
|
The sidecar's ten farthest-point samples describe a panel's corners and hem and
|
||||||
|
leave its MIDDLE unsampled — exactly where a thigh comes through. The solver
|
||||||
|
reported every contact resolved while 158 vertices sat 95 mm inside a leg.
|
||||||
|
|
||||||
|
## There is no drape term
|
||||||
|
|
||||||
|
"Cloth takes a share of the leg's motion before the solver runs" is a real
|
||||||
|
technique (Hoyoverse rigs carry a partial constraint from the leg onto the upper
|
||||||
|
skirt bones). It was here to move panels the old rotation-only solver could not.
|
||||||
|
With it against without, over the movement sweep:
|
||||||
|
|
||||||
|
```
|
||||||
|
run 101 -> 92 mm fall 82 -> 49 mm dash 136 -> 95 mm
|
||||||
|
idle after a dash 103 -> 20 mm
|
||||||
|
```
|
||||||
|
|
||||||
|
Worse in every state but a walk, and 20× worse in stability (0.48 vs 0.05
|
||||||
|
deg/frame at a dead idle) because its target sat inside the leg the collision was
|
||||||
|
pushing out of. **If you reintroduce it, the target must be collision-free
|
||||||
|
first.** A naive "seat the reference on the limb" pass was tried and destabilised
|
||||||
|
the reference chain, because a parent's seat rotation cascades into every child.
|
||||||
|
|
||||||
|
## Cost and LOD
|
||||||
|
|
||||||
|
~2.6 ms per character per frame at full quality, three quarters of it collision.
|
||||||
|
It was 10.9 ms before the inner loop stopped rebuilding every capsule and
|
||||||
|
reallocating the hull array for every (bone, collider, pass).
|
||||||
|
|
||||||
|
`SpringBones.lod` 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.
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# How imports fail, and why the checks did not catch it
|
||||||
|
|
||||||
|
Seven characters shipped with "All checks passed" and four of them were visibly
|
||||||
|
broken in game — lying on their backs, seven times too big, facing backwards,
|
||||||
|
holding a gun that floated near their chest. Nothing in the verification suite
|
||||||
|
was wrong. It just never asked the questions that mattered.
|
||||||
|
|
||||||
|
**The generalised lesson, which is the whole of this page:**
|
||||||
|
|
||||||
|
> The suite verified that the character was *well-formed* — skeleton attached,
|
||||||
|
> weights authored, clips non-frozen, cloth unkeyed. It never verified that the
|
||||||
|
> character was *correct*: the right size, the right way up, the right way round,
|
||||||
|
> and reachable by the runtime. Structural validity and usable output are
|
||||||
|
> different properties, and a pipeline that only checks the first will ship the
|
||||||
|
> second broken every time a source deviates from the one it was written against.
|
||||||
|
|
||||||
|
Every check below is cheap. None of them existed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Up is not always +Z — measure it, never assume it
|
||||||
|
|
||||||
|
**Symptom:** the character is enormous and lying on their back.
|
||||||
|
**Hit:** aria, momo, hikari. **Confidence: certain** — measured, not inferred.
|
||||||
|
|
||||||
|
`flatten_and_scale()` sets the character's real-world size with
|
||||||
|
|
||||||
|
```python
|
||||||
|
height = hi.z - lo.z # Blender Z is up
|
||||||
|
s = target_height / height
|
||||||
|
```
|
||||||
|
|
||||||
|
which is right for a model that arrives Z-up in Blender, and catastrophic for one
|
||||||
|
that does not. If the character is actually lying along Blender's Y, `hi.z - lo.z`
|
||||||
|
measures their **thickness** — about 0.25 m — so `s = 1.75 / 0.25 ≈ 7`. The model
|
||||||
|
is scaled seven-fold *and* left on its back. One wrong assumption, both symptoms.
|
||||||
|
|
||||||
|
The bind-pose bounding boxes say it plainly. A correct character is tall on Y and
|
||||||
|
narrow on X and Z:
|
||||||
|
|
||||||
|
| | X | Y | Z | |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| taila | 1.24 | **1.75** | 0.69 | correct |
|
||||||
|
| kiyoko | 1.50 | **1.75** | 0.32 | correct |
|
||||||
|
| mannequin | 1.86 | **1.75** | 0.35 | correct |
|
||||||
|
| aria | 6.12 | 1.75 | **7.48** | tall axis is Z — lying down, ~7x too big |
|
||||||
|
| momo | 6.89 | 1.75 | **7.95** | same |
|
||||||
|
| hikari | 6.23 | 1.75 | **13.01** | same, and worse |
|
||||||
|
|
||||||
|
The 1.75 lands on Y for everyone because the exporter maps Blender Z to glTF Y.
|
||||||
|
That is exactly what makes the bug invisible: **the number you normalised always
|
||||||
|
comes out right, whether or not it was the right number.** A check on "is the
|
||||||
|
height 1.75" passes on all seven of these.
|
||||||
|
|
||||||
|
**Rule:** derive the up axis from the SKELETON, and rotate the model upright
|
||||||
|
before scaling anything. `flatten_and_scale()` now does this.
|
||||||
|
|
||||||
|
**Measure it from the FEET to the HIPS, not from the hips to the head.** The head
|
||||||
|
is not a reliable landmark: the spine walk ends on whatever the last non-cosmetic
|
||||||
|
bone in the chain is, and on a rig with a facial skeleton that can be a bone
|
||||||
|
sitting BELOW the hips. Momo's did, so the first version of this fix stood her
|
||||||
|
neatly on her head — correct size, correct proportions, upside down. Feet cannot
|
||||||
|
be mistaken; they are the bottom of a standing character on every rig, and
|
||||||
|
`foot.L/R` have resolved on every source met so far.
|
||||||
|
|
||||||
|
**And compare it to WORLD up, not to the model's own proportions.** The obvious
|
||||||
|
test — "is the spine the longest axis of the bounding box?" — catches nothing
|
||||||
|
here, because a model rotated as a whole is internally consistent: aria's spine
|
||||||
|
*is* her longest axis, she is just lying down. She passes that test comfortably.
|
||||||
|
The question is whether the character stands up in the world the game runs in,
|
||||||
|
which means asserting the spine runs along Blender +Z, full stop.
|
||||||
|
|
||||||
|
Two more numbers are worth asserting for free: the other two extents should be
|
||||||
|
under about 2.5 m, and the height itself should land in a human range. Those
|
||||||
|
three together are what separated the four good characters from the three broken
|
||||||
|
ones on the first run.
|
||||||
|
|
||||||
|
**Where this comes from:** all three casualties have bone names like `Hips`,
|
||||||
|
`Left leg`, `Upper Chest`, `Breast_L` — a VRM that someone imported into Blender,
|
||||||
|
renamed, and re-exported. Kiyoko kept raw VRoid `J_Bip_*` names and was fine. A
|
||||||
|
**Blender round-trip can bake an axis rotation into the export**, and that family
|
||||||
|
of files is common on Sketchfab. Treat "the bone names have been humanised" as a
|
||||||
|
signal to check the axes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The runtime must look bones up by ROLE, not by name
|
||||||
|
|
||||||
|
**Symptom:** the gun is not in the hands — it floats near the chest, and can
|
||||||
|
point backwards. **Hit:** aria, momo, kiyoko, hikari.
|
||||||
|
**Confidence: certain** — measured.
|
||||||
|
|
||||||
|
`SkinnedPlayerModel.set_weapon()` finds the hand with
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Three hardcoded spellings. Against the shipped roster:
|
||||||
|
|
||||||
|
| Skin | `hand.R` resolved in the sidecar | matched by `_find_bone` |
|
||||||
|
|---|---|---|
|
||||||
|
| taila, miku, mannequin | `DEF-hand.R` | yes |
|
||||||
|
| kiyoko | `J_Bip_R_Hand` | **no** |
|
||||||
|
| aria, momo, hikari | `Right wrist` | **no** |
|
||||||
|
|
||||||
|
When it misses, `set_weapon` falls back to parenting the weapon to the model root
|
||||||
|
at a fixed chest-height offset. The gun is then not attached to the character at
|
||||||
|
all; it hangs in space near the torso and inherits none of the arm's motion.
|
||||||
|
|
||||||
|
This is the same class of bug as `verify_character.py` looking for legs by the
|
||||||
|
substrings `thigh`/`shin`. **`tools/rig_map.py` exists precisely so that nothing
|
||||||
|
downstream has to guess a bone name, and the resolved roles are written to
|
||||||
|
`<model>.rig.json` for exactly this purpose — but only some consumers read them.**
|
||||||
|
|
||||||
|
**Rule:** every bone lookup anywhere in the runtime or the tools goes through the
|
||||||
|
sidecar roles, with a name heuristic only as a last-resort fallback. Grep for
|
||||||
|
`find_bone`, `findn(`, and any tuple of bone-name spellings; each one is a rig
|
||||||
|
this project has not met yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Facing is inferred and never verified
|
||||||
|
|
||||||
|
**Symptom:** the character runs backwards. **Hit:** kiyoko.
|
||||||
|
**Confidence: probable** — the mechanism is understood, the specific cause is not
|
||||||
|
yet isolated.
|
||||||
|
|
||||||
|
Two independent things decide which way a character ends up pointing:
|
||||||
|
`retarget.py::facing_correction()` computes a yaw to align the character's rest
|
||||||
|
pose with the library's, and `SkinnedPlayerModel.facing_flip` then applies a
|
||||||
|
blanket 180° because "glTF forward is +Z; players face -Z". If the source already
|
||||||
|
faces the other way, the two compose to a character running backwards — and
|
||||||
|
nothing anywhere measures the finished result.
|
||||||
|
|
||||||
|
**Rule:** facing is measurable from the skeleton — the toes are forward of the
|
||||||
|
ankles. `flatten_and_scale()` now snaps that to Blender -Y, the convention the
|
||||||
|
runtime flip is built around, so every character leaves the pipeline pointing the
|
||||||
|
same way whatever the source did. Kiyoko was 180 degrees off and is now correct.
|
||||||
|
|
||||||
|
Snap to the nearest QUARTER TURN, not to the measured angle: a rest pose with the
|
||||||
|
feet slightly splayed is not a character who is 7 degrees turned, and correcting
|
||||||
|
it as one puts a permanent yaw on the whole skeleton.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Generated cloth chains must be validated against the geometry they drive
|
||||||
|
|
||||||
|
**Symptom:** hair stretches wildly during animation.
|
||||||
|
**Hit:** miku. **Confidence: probable.**
|
||||||
|
|
||||||
|
`tools/cloth_bones.py` grows chains for a costume that has none, then **clears
|
||||||
|
each vertex's existing body weights** and re-assigns it to the fitted chain,
|
||||||
|
keeping the original only over the first 22%. That is correct when the polyline
|
||||||
|
actually follows the clump. When it does not — a large or forked island, a
|
||||||
|
mis-picked root end — vertices land on a bone travelling somewhere else entirely,
|
||||||
|
and linear-blend skinning turns that into stretching.
|
||||||
|
|
||||||
|
The tool reports how many chains it grew. It never checks whether they *work*.
|
||||||
|
|
||||||
|
**Rule:** after growing chains, verify per vertex that its assigned bone stays
|
||||||
|
near it — pose the chain a few degrees and assert the vertex moves with its bone
|
||||||
|
rather than away from it. And never destroy the original weights without a
|
||||||
|
fallback: a generated chain should blend against the body weight it replaced, so
|
||||||
|
a bad fit degrades to "stiff" rather than to "torn".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. A weapon has to be scaled to the arm that holds it
|
||||||
|
|
||||||
|
**Symptom:** hands flat and open, both fists bunched together at the grip, the
|
||||||
|
stock nowhere near the shoulder. **Hit:** every model.
|
||||||
|
**Confidence: certain** — measured and fixed.
|
||||||
|
|
||||||
|
Three separate causes, all of them "a constant where a measurement belonged".
|
||||||
|
|
||||||
|
**The gun was mounted with a constant rotation.** `set_weapon` used
|
||||||
|
`rotation_degrees = (0, 90, -90)`. A bone attachment is expressed in the BONE's
|
||||||
|
axes and no two rigs agree on those, so one constant mounts the weapon
|
||||||
|
differently on every character. It never needed to be right: the pose layer aims
|
||||||
|
the gun by rotating the WRIST until the weapon's forward lies on the aim line, so
|
||||||
|
handing it the IDENTITY means "forward is the hand bone's -Z" — true by
|
||||||
|
construction on any rig — and the wrist absorbs the roll.
|
||||||
|
|
||||||
|
**The gun was full size on a stylised character.** The set is modelled at
|
||||||
|
real-world scale (an M4 is 0.84 m butt to muzzle); these characters have 0.47 m
|
||||||
|
arms against an adult 0.52. That puts the handguard 0.66 m from the support
|
||||||
|
shoulder — 0.2 m beyond reach — so a loop slid the support hand back down the
|
||||||
|
weapon until it fitted. On Taila a support offset authored at 0.35 m collapsed to
|
||||||
|
**0.083 m**: two fists together at the grip, which reads as a two-handed pistol
|
||||||
|
grip, not a rifle.
|
||||||
|
|
||||||
|
The fix is not a fixed scale factor. The binding constraint is the SUPPORT arm:
|
||||||
|
its hand must reach `stock + fore` in front of the pocket, from a shoulder half a
|
||||||
|
shoulder-width off the weapon axis. Solve that triangle for the largest gun whose
|
||||||
|
handguard still lands inside the arm's reach. Taila and Kiyoko come out at
|
||||||
|
different scales from the same code, both with the support hand at its full
|
||||||
|
authored handguard distance and no sliding at all.
|
||||||
|
|
||||||
|
Also give the slide-back loop a FLOOR. A slightly straight support arm looks far
|
||||||
|
better than no handguard hold.
|
||||||
|
|
||||||
|
**Nothing posed the fingers.** Every hand was flat and open — the single loudest
|
||||||
|
tell that a character is not really holding anything. Fingers are now closed by
|
||||||
|
the pose layer, using an axis derived from each hand's OWN anatomy in the rest
|
||||||
|
pose, because no two rigs agree on finger bone orientation:
|
||||||
|
|
||||||
|
```
|
||||||
|
along wrist -> middle knuckle the length of the hand
|
||||||
|
palm middle knuckle -> thumb tip across it; the thumb OPPOSES the
|
||||||
|
fingers, so it is on the palm side by
|
||||||
|
construction — a fact about hands, not
|
||||||
|
a rig convention
|
||||||
|
curl along x palm turning about this swings the fingers
|
||||||
|
into the palm, not sideways
|
||||||
|
```
|
||||||
|
|
||||||
|
The trigger hand's index finger gets a much shallower curl than the rest — it
|
||||||
|
lies along the trigger. Curling it with the others is what makes a character look
|
||||||
|
like they are squeezing a bar of soap.
|
||||||
|
|
||||||
|
Finger bones now resolve by role too (`rig_map.DIGITS`), across all three naming
|
||||||
|
families met so far: Rigify `DEF-f_index.01.L`, VRoid `J_Bip_L_Index1`, and
|
||||||
|
Blender-export `IndexFinger1_L`. Segments are ordered by DEPTH BELOW THE HAND,
|
||||||
|
not by the number in the name — the numbering is not consistent between families,
|
||||||
|
but the hierarchy always runs knuckle to fingertip.
|
||||||
|
|
||||||
|
**The support hand came out upside down**, because its orientation was built as
|
||||||
|
a shortest arc plus a constant twist: align the hand's forearm line to the barrel
|
||||||
|
(`Quaternion(fa_rest_dir, aim_dir)`), then add 0.5 rad of roll. A shortest arc
|
||||||
|
says NOTHING about roll — it is the minimal rotation between two directions — so
|
||||||
|
the entire roll came from that constant, and a constant is right only for the rig
|
||||||
|
it was tuned on.
|
||||||
|
|
||||||
|
Orienting a hand onto something it grips is a FRAME-TO-FRAME problem, and framing
|
||||||
|
it that way leaves nothing free to guess:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl axis must lie along the object's axis, or the fingers close ACROSS the
|
||||||
|
handguard instead of around it
|
||||||
|
palm must face the object — up, for a hand supporting from underneath
|
||||||
|
along falls out of the other two (palm x curl)
|
||||||
|
```
|
||||||
|
|
||||||
|
Map the hand's rest anatomical frame onto that target and the roll is determined,
|
||||||
|
not chosen. Verified on both the Rigify-named mannequin and the VRoid-named
|
||||||
|
Kiyoko: fingers wrap the handguard from below, over the top.
|
||||||
|
|
||||||
|
**Rule:** anything expressed as a constant in a rig's local frame — a mount
|
||||||
|
rotation, a grip offset, a curl axis, a weapon size, a wrist twist — is a guess
|
||||||
|
about one skeleton. Derive it from the skeleton, or hand it to a solver that
|
||||||
|
already knows the answer. And when a rotation needs a specific ROLL, never build
|
||||||
|
it from a shortest arc: that operator has no opinion about roll, so whatever you
|
||||||
|
add afterwards is doing all the work.
|
||||||
|
|
||||||
|
## 6. Some sources are not salvageable, and the gate should say so
|
||||||
|
|
||||||
|
**Hit:** hikari. She failed every way at once — stretched and warped, tiny, far
|
||||||
|
away, gun backwards. Her rig has been through at least two toolchains: her
|
||||||
|
cosmetic bones are zero-length terminators, she carried a second armature with
|
||||||
|
its own clips, and her feet and her spine disagree about which way is up, so the
|
||||||
|
stand-up correction cannot resolve her either. She is now REJECTED by the gate
|
||||||
|
and removed from the roster rather than shipped broken.
|
||||||
|
|
||||||
|
**Rule:** a source that fails several unrelated checks is not a tuning problem,
|
||||||
|
it is a bad file. Spend the effort on finding a cleaner source, not on repairing
|
||||||
|
this one — and make sure the gate blocks it, because the failure mode before this
|
||||||
|
work was that everything passed and the breakage was only visible in game.
|
||||||
|
|
||||||
|
The vetting snippet in `separation.md` catches most of these before download:
|
||||||
|
several meshes, 50+ joints, cosmetic bones present. Add a look for duplicate
|
||||||
|
armatures and for bone chains whose bones are all at the same position.
|
||||||
|
|
||||||
|
## 7. Known-unsolved, and honestly so
|
||||||
|
|
||||||
|
Taila's legs still clip through the front of her skirt in a run, slide and dash
|
||||||
|
(~95 mm). See `cloth-and-hair.md`. The solver sees the contact and pushes on it
|
||||||
|
every iteration; what remains is a standing fight between the collision and the
|
||||||
|
garment's shape constraints, not a missing check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The check that would have caught most of this
|
||||||
|
|
||||||
|
One pass over the finished GLB, before it is ever registered:
|
||||||
|
|
||||||
|
```
|
||||||
|
POSTURE tallest axis of the bind-pose AABB == the hips->head axis,
|
||||||
|
and the other two are under ~2.5 m (catches #1)
|
||||||
|
SCALE height within a few percent of --height (catches #1)
|
||||||
|
FACING toes forward of ankles, along the world forward the game
|
||||||
|
expects, AFTER facing_flip (catches #3)
|
||||||
|
REACHABLE every role the runtime looks up — hands, head, spine — resolves
|
||||||
|
through the sidecar and not by name (catches #2)
|
||||||
|
CLOTH every chain has measurable extent, and its vertices track it (#4)
|
||||||
|
```
|
||||||
|
|
||||||
|
None of these needs Blender or the engine; the bind-pose AABB and the inverse
|
||||||
|
bind matrices in the GLB are enough for the first four.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Growing cloth bones on a model that has none
|
||||||
|
|
||||||
|
`tools/cloth_bones.py`, opt-in via `pipeline.py --grow-cloth`.
|
||||||
|
|
||||||
|
## When you need it
|
||||||
|
|
||||||
|
The spring solver simulates cloth **bones**. A garment with none is welded to
|
||||||
|
whatever body bone it was weighted to, and no runtime setting changes that. Every
|
||||||
|
auto-rigged model is in this state, and so is many a "rigged" download whose
|
||||||
|
skeleton is body-only.
|
||||||
|
|
||||||
|
Check before importing (no Blender needed — see `separation.md`): if the joint
|
||||||
|
list has nothing matching `hair|skirt|tail|ribbon`, the costume will not move.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Runs on the **rigged** model, before the retarget.
|
||||||
|
|
||||||
|
1. **Finds the geometry by material slot.** A slot called `hair` is hair. The
|
||||||
|
artist already answered the question, and on a joined mesh — which is what the
|
||||||
|
auto-rig leaves behind — the material slot is the only separation left.
|
||||||
|
2. **Splits it into clumps.**
|
||||||
|
- *Hair*: connected islands over the mesh's own edges. A strand is a connected
|
||||||
|
piece of surface; clustering by position would merge two ponytails passing
|
||||||
|
near each other and split one that bends.
|
||||||
|
- *Skirt*: radial wedges around the body's up axis. A skirt is ONE connected
|
||||||
|
surface, so islands would return the whole thing as a single piece — which
|
||||||
|
is the bell-shaped failure. Wedges are the ZZZ-convention panel grid.
|
||||||
|
3. **Fits a polyline down each clump** by binning vertices by distance from the
|
||||||
|
anchored end and taking each bin's centroid, so the chain follows the piece's
|
||||||
|
own curve. A straight root-to-tip line cuts the corner on a bent ponytail and
|
||||||
|
every vertex on the outside of that bend ends up on a bone travelling the
|
||||||
|
wrong way.
|
||||||
|
4. **Builds a bone chain along the polyline**, parented to the body bone that was
|
||||||
|
already holding that geometry.
|
||||||
|
5. **Re-weights** each vertex onto the two bones either side of where it projects,
|
||||||
|
blended by how far between them it lands — while keeping the ORIGINAL body
|
||||||
|
weight over the first `ROOT_BLEND` (22%) of the chain, so the scalp stays on
|
||||||
|
the skull and the waistband stays on the hips.
|
||||||
|
|
||||||
|
Then `retarget.py::describe_rig` finds the chains by name exactly as it would an
|
||||||
|
artist's, and writes tips, hulls and neighbours to the sidecar.
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
```
|
||||||
|
--hair-segments 3 bones per hair strand
|
||||||
|
--skirt-segments 4 bones per skirt panel
|
||||||
|
--skirt-panels 12 radial panels; more = opens around a leg more smoothly
|
||||||
|
--classes hair,skirt which material names to look for
|
||||||
|
```
|
||||||
|
|
||||||
|
`MIN_STRAND_LENGTH` (60 mm) and `MIN_STRAND_VERTS` (12) drop fringe and
|
||||||
|
ornaments. Simulating those costs the same as a ponytail and only ever produces
|
||||||
|
jitter around the face.
|
||||||
|
|
||||||
|
## Worked result: Miku
|
||||||
|
|
||||||
|
```
|
||||||
|
19 chains, 57 bones grown from one `hair` material slot
|
||||||
|
sidecar: 0 cloth chains -> 19
|
||||||
|
idle stability: 0.007-0.018 deg/frame (quiet)
|
||||||
|
mesh intact, no tearing (debug/character_look_capture.gd)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- **It cannot find a garment that shares a material with the body.** Miku's skirt
|
||||||
|
is on her `body` slot, so she got hair chains and no skirt. Splitting by
|
||||||
|
geometry rather than by material would be the next step.
|
||||||
|
- **It does not set `weights_authored`.** An auto-rigged model still gets the
|
||||||
|
destructive load-time `SkinLegRepair`. That repair only touches cross-leg
|
||||||
|
vertices, so it leaves hair alone, but it is worth knowing.
|
||||||
|
- **Grown chains are a fallback, not a substitute for a rigged source.** They
|
||||||
|
follow the geometry, but an artist's chains carry intent — where a panel should
|
||||||
|
split, which strands move together — that no fit recovers.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Rigging and retargeting
|
||||||
|
|
||||||
|
## Roles, not names
|
||||||
|
|
||||||
|
`tools/rig_map.py` resolves a skeleton to ROLES — `hips`, `spine[]`, `neck`,
|
||||||
|
`head`, and `limb[(role, side)]` for `thigh/shin/foot/toe/shoulder/upper_arm/
|
||||||
|
forearm/hand`. Matching is by whole tokens plus anatomy (chain length, position,
|
||||||
|
which bone is a child of which), so a Rigify `DEF-thigh.L`, a Mixamo
|
||||||
|
`mixamorig:LeftUpLeg` and a bespoke `Bip01_L_Thigh` all land on the same role.
|
||||||
|
|
||||||
|
This is what removed the need to destroy foreign skeletons. `roles.missing_core()`
|
||||||
|
is the gate: if the core roles cannot be found the pipeline stops rather than
|
||||||
|
guessing.
|
||||||
|
|
||||||
|
The resolved roles are written to `<model>.rig.json` and read at runtime by
|
||||||
|
`ShooterPoseModifier._resolve`, which aliases its library-flavoured names
|
||||||
|
(`DEF-hips`, `DEF-spine.001`…) onto whatever this rig calls them. Taila's hips
|
||||||
|
are `DEF-spine`, her head is `DEF-spine.006`, and she has **no bone with "neck"
|
||||||
|
in its name at all** — unresolved, every lean, aim pitch and slide head-lift
|
||||||
|
silently did nothing.
|
||||||
|
|
||||||
|
### Names lie. Anatomy does not.
|
||||||
|
|
||||||
|
Four sources that were not authored against the library's spelling each broke
|
||||||
|
role resolution in a different way. All four fixes are in; the lesson is that
|
||||||
|
**anything guessing anatomy from a name needs a structural fallback.**
|
||||||
|
|
||||||
|
- A bare `leg` is the SHIN on Mixamo (`LeftUpLeg` is the thigh) and the THIGH on
|
||||||
|
a rig whose shin is called `knee`. Same token, opposite bones, both common. So
|
||||||
|
`RigRoles` walks the leg upward from the foot and fills in whatever the names
|
||||||
|
could not, stepping over twist bones.
|
||||||
|
- Claims are granted **longest-stem first**. Taking the first role in
|
||||||
|
`LIMB_ORDER` that matched at all let shin's catch-all `"leg"` beat thigh's
|
||||||
|
exact `"upperleg"`, and the outcome depended on bone iteration order.
|
||||||
|
- Cosmetic and spring classes accept a **two-character positional suffix**:
|
||||||
|
`HairFL`, `HairFR`, `HairF_Top` tokenise to `hairfl` and matched nothing, so a
|
||||||
|
character imported with no hair chains at all. Two characters is short enough
|
||||||
|
that `forearm` and `earring` are still not swept in.
|
||||||
|
- VRoid spells legs `UpperLeg`/`LowerLeg`. Any CHECK that name-matches
|
||||||
|
`thigh`/`shin` will silently pass or silently fail on it — see
|
||||||
|
`verification.md`.
|
||||||
|
|
||||||
|
If a new source fails with `could not identify these bones`, dump the joint names
|
||||||
|
first (`separation.md` has a no-Blender snippet) and decide whether it is a
|
||||||
|
missing stem or a case only anatomy can settle.
|
||||||
|
|
||||||
|
## Rebuilding the hierarchy
|
||||||
|
|
||||||
|
A Rigify DEF-rig exports its chain roots parented straight to the armature root,
|
||||||
|
because Rigify drives them by constraint rather than by hierarchy. Left that way,
|
||||||
|
rotating the hips leaves the legs, skirt and hair floating in place.
|
||||||
|
|
||||||
|
`rebuild_hierarchy` re-attaches orphans: by anatomy where the role is known, and
|
||||||
|
by rest geometry (nearest plausible parent) otherwise. **Cloth may only attach to
|
||||||
|
the trunk.**
|
||||||
|
|
||||||
|
## Subdividing cloth panels
|
||||||
|
|
||||||
|
`subdivide_cloth_panels(arm, meshes, roles, segments=4)`.
|
||||||
|
|
||||||
|
A skirt panel that is a single bone from the waist is a rigid flap: it can only
|
||||||
|
rotate about its own head, and a contact near that head is unreachable at any
|
||||||
|
angle. Splitting each panel into a chain is what lets it bend, and it is why the
|
||||||
|
ZZZ-convention skirt is a grid rather than a fan.
|
||||||
|
|
||||||
|
On Taila this turns 21 panel bones into 21 chains of 4. The segment lengths come
|
||||||
|
out uneven (49/49/49/141 mm) because the last segment runs on to the hem.
|
||||||
|
|
||||||
|
Weights are redistributed along the panel as it is split, so the mesh follows the
|
||||||
|
new chain.
|
||||||
|
|
||||||
|
## Twist bones
|
||||||
|
|
||||||
|
A forearm or thigh twist bone takes half the roll of its parent so the skin does
|
||||||
|
not candy-wrap. They are detected (`is_segment_of`) and recorded in the sidecar's
|
||||||
|
`twist` list. They are also folded into the limb when measuring collider radii:
|
||||||
|
most of a thigh's surface belongs to `DEF-thigh.L.001`, and what is left
|
||||||
|
dominated by `DEF-thigh.L` is mostly hip flare, which fitted a 0.154 m radius —
|
||||||
|
a 30 cm thigh.
|
||||||
|
|
||||||
|
## Joint helpers
|
||||||
|
|
||||||
|
`SkinJointHelper.install` runs for EVERY model however it was rigged. Linear-blend
|
||||||
|
skinning collapses any joint by cos(angle/2) no matter how good the weights are;
|
||||||
|
measured at the knee, 0.77 without helpers against 0.99 with. They are updated
|
||||||
|
LAST, inside the modification pass, so each helper tracks whatever final rotation
|
||||||
|
its child bone ended up with.
|
||||||
|
|
||||||
|
## The retarget maths
|
||||||
|
|
||||||
|
Bake each clip as a **rest-relative delta**:
|
||||||
|
|
||||||
|
```
|
||||||
|
R_world = src_pose_rot * src_rest_rot⁻¹ what the clip does
|
||||||
|
tgt_rot = R_world * tgt_rest_rot done to THIS rig
|
||||||
|
```
|
||||||
|
|
||||||
|
Copying absolute world orientation instead — which is what a constraint bake does
|
||||||
|
— forces the library's bone ROLL onto a mesh bound with a different one, and
|
||||||
|
twists every limb by a constant offset.
|
||||||
|
|
||||||
|
Also handled: a facing correction (`facing_correction`) when the library and the
|
||||||
|
character face different ways, and a hips-height scale so a short character does
|
||||||
|
not float.
|
||||||
|
|
||||||
|
## Export flags that matter
|
||||||
|
|
||||||
|
```python
|
||||||
|
export_bake_animation=False,
|
||||||
|
export_optimize_animation_keep_anim_armature=False,
|
||||||
|
```
|
||||||
|
|
||||||
|
`keep_anim_armature` forces a track onto every bone whether or not the clip
|
||||||
|
touches it. Off, the skirt and hair export with **no tracks at all** and belong
|
||||||
|
entirely to the spring solver. This one flag is the animation/physics split.
|
||||||
|
|
||||||
|
## Height normalisation
|
||||||
|
|
||||||
|
`flatten_and_scale(arm, meshes, TARGET_HEIGHT)` — default 1.75 m. Applied before
|
||||||
|
the retarget so the library's stride matches the character's legs.
|
||||||
|
|
||||||
|
## When a model has no skeleton
|
||||||
|
|
||||||
|
`tools/autorig.py` will fit one, and the pipeline accepts the quality loss:
|
||||||
|
nearest-bone weights, cross-leg bleed, no cloth chains. `weights_authored` comes
|
||||||
|
out false, `SkinLegRepair` runs at load to snap the worst of it, and the
|
||||||
|
character will have no secondary motion. Prefer finding a rigged source.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Body, garments, hair — what must stay separate
|
||||||
|
|
||||||
|
The single structural idea behind an anime-styled character rig, and the thing
|
||||||
|
every failure in this project traced back to.
|
||||||
|
|
||||||
|
## The convention this pipeline follows
|
||||||
|
|
||||||
|
Hoyoverse-class character rigs (Genshin, Star Rail, Zenless Zone Zero) are built
|
||||||
|
the same way, and the parts that matter are visible in any of their exported
|
||||||
|
assets and in the toolchains built around them (Magica Cloth 2, UnityChan
|
||||||
|
SpringBone, VRM's spring-bone spec — all of which exist because this shape is
|
||||||
|
the convention):
|
||||||
|
|
||||||
|
| Convention | What this repo does |
|
||||||
|
|---|---|
|
||||||
|
| Body, face, hair and each garment are SEPARATE meshes with separate materials | Never join meshes; 18 meshes on Taila are all kept |
|
||||||
|
| Skirts get a radial grid of bone chains — many panels, several segments each | 21 panels × 4 segments, subdivided at build time |
|
||||||
|
| Hair is chains of 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 `<model>.rig.json` exists.
|
||||||
|
|
||||||
|
Where there IS an artist in the loop, there is now somewhere to put the answer:
|
||||||
|
`debug/rig_lab.tscn` and the layered files behind it (see the SKILL). Measuring
|
||||||
|
is the default and hand-authoring is the override, rather than the other way
|
||||||
|
round.
|
||||||
|
|
||||||
|
## Separation is only half of it — the parts have to be NAMED
|
||||||
|
|
||||||
|
Keeping the meshes apart is structural. Knowing which is which is what lets
|
||||||
|
anything act on the difference, and until the surface table existed nothing did:
|
||||||
|
every surface of every character took one set of shading numbers, calibrated on
|
||||||
|
skin, because there was no way to ask whether a surface was hair.
|
||||||
|
|
||||||
|
The table lives in the sidecar as `surfaces`, keyed on the MATERIAL name — mesh
|
||||||
|
node names are `Object_7` through `Object_32` on every character in this game and
|
||||||
|
carry no meaning, while material names survive the glTF round trip intact and are
|
||||||
|
what the artist actually chose. It is decided three ways, in descending order of
|
||||||
|
how much it trusts them:
|
||||||
|
|
||||||
|
1. **the material name.** On VRoid exports it is formal —
|
||||||
|
`N00_000_00_Body_00_SKIN_Instance` carries its own class infix, and every
|
||||||
|
VRoid character here uses SKIN / FACE / EYE / HAIR / CLOTH.
|
||||||
|
2. **the weights.** Decisive when the name says nothing: a surface pulled by the
|
||||||
|
skirt chain is a skirt whatever it is called. The threshold is deliberately
|
||||||
|
low (5%), because VRoid welds the whole cap of the hair to the head bone and
|
||||||
|
springs only the strands — kiyoko's hair mesh is 85% head, and a majority rule
|
||||||
|
would call it skin.
|
||||||
|
3. **the material flags.** These catch line-work, which is the one class that is
|
||||||
|
not a surface of the character at all.
|
||||||
|
|
||||||
|
It is built from the same chains the spring solver uses, so the two can never
|
||||||
|
disagree about which bones are a skirt.
|
||||||
|
|
||||||
|
## Why the separation is load-bearing
|
||||||
|
|
||||||
|
**Materials.** The body wants skin shading, hair wants an anisotropic-ish ramp
|
||||||
|
and its own outline weight, cloth wants flat banding. One merged mesh gets one
|
||||||
|
treatment and everything reads as plastic.
|
||||||
|
|
||||||
|
**The cloth solver.** `SkinnedPlayerModel._cloth_hulls` extracts, per cloth bone,
|
||||||
|
the vertices that bone dominates — that is only meaningful while the garment is
|
||||||
|
its own mesh with its own weights. Merge the meshes and the solver has no way to
|
||||||
|
know which vertices are skirt.
|
||||||
|
|
||||||
|
**Weights.** A joined mesh rebound by nearest-bone weighting produced 2817
|
||||||
|
vertices pulled by BOTH legs on Taila (16% of the model, worst a dead 50/50).
|
||||||
|
Such a vertex sits between the legs and stays there while they separate,
|
||||||
|
stretching every triangle around it. That is the "squashing on jump" and the
|
||||||
|
"elongated boot".
|
||||||
|
|
||||||
|
## How cloth is detected and classed
|
||||||
|
|
||||||
|
`tools/rig_map.py::is_cosmetic` matches WHOLE TOKENS in a bone name against:
|
||||||
|
|
||||||
|
```
|
||||||
|
hair skirt cloth ribbon tail cape coat scarf sleeve breast bust
|
||||||
|
feather strap antenna wing (+ face/eye classes that must never swing)
|
||||||
|
```
|
||||||
|
|
||||||
|
Whole-token only — `shoulder` must not match `should`, and a bone called
|
||||||
|
`hair_root` is hair while `chairbone` is not.
|
||||||
|
|
||||||
|
`retarget.py::SPRING_CLASSES` is a NARROWER set: the classes that actually get
|
||||||
|
secondary motion. A face-shape or eye chain is cosmetic but must never swing.
|
||||||
|
|
||||||
|
Each chain lands in `<model>.rig.json` as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "class": "skirt",
|
||||||
|
"root_parent": "DEF-spine.001",
|
||||||
|
"bones": ["DEF-skirt", "DEF-skirt.seg1", "DEF-skirt.seg2", "DEF-skirt.seg3"],
|
||||||
|
"tips": [[x,y,z], ...], // where each bone points, in its own space
|
||||||
|
"hulls": [[[x,y,z], ...], ...], // sample of the geometry it drives
|
||||||
|
"neighbours": [{"DEF-skirt.L": 10.7, ...}] // shared-vertex weight
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tips` exists because **a glTF skeleton carries no bone tails at all**, and
|
||||||
|
Taila's skirt panel bones have no children either, so nothing in the skeleton
|
||||||
|
says which way a panel hangs. It is measured from the geometry the bone drives.
|
||||||
|
|
||||||
|
`neighbours` means SHARED VERTICES — the artist's own answer to which pieces of
|
||||||
|
cloth are sewn together. Adjacency by name or by rest distance would both be
|
||||||
|
guesses.
|
||||||
|
|
||||||
|
## The three rules that keep it intact
|
||||||
|
|
||||||
|
1. **Cloth may only ever parent to the trunk, never to a limb.**
|
||||||
|
`rebuild_hierarchy` enforces this. A skirt parented to a thigh becomes
|
||||||
|
trousers.
|
||||||
|
|
||||||
|
2. **Cloth is never SKINNED to a leg.** There was a `bind_cloth_to_legs()` that
|
||||||
|
gave cloth vertices near a thigh a share of that thigh, so the skirt would
|
||||||
|
ride the leg the way a real one does. It is deleted. A vertex weighted 0.9 to
|
||||||
|
a thigh cannot be moved by its own cloth bone, so the solver loses the
|
||||||
|
authority to push it out of that leg — and 0.9 of a rotation always lags the
|
||||||
|
surface doing 1.0 of it, so the leg overtakes it anyway. It also poisoned the
|
||||||
|
collider measurement: 2258 skirt vertices counted as thigh geometry and fitted
|
||||||
|
a 0.28 m thigh.
|
||||||
|
|
||||||
|
3. **Cloth bones carry no animation tracks.** If the exporter bakes rest-pose
|
||||||
|
tracks onto them (`keep_anim_armature`), the AnimationPlayer overwrites the
|
||||||
|
spring solver every frame.
|
||||||
|
|
||||||
|
## Worked example: why the two shipped characters differ so much
|
||||||
|
|
||||||
|
Both are in `assets/characters/skins/`. Compare their sidecars:
|
||||||
|
|
||||||
|
| | Taila | Miku |
|
||||||
|
|---|---|---|
|
||||||
|
| source had a skeleton | yes | **no — 5 meshes, 0 joints** |
|
||||||
|
| `weights_authored` | true | **false** |
|
||||||
|
| cloth chains | 35 (127 bones) | **0** |
|
||||||
|
| twist bones | 8 | **0** |
|
||||||
|
| meshes shipped | 18 | **1** |
|
||||||
|
|
||||||
|
Miku's source (`assets/characters/incoming/miku_test.glb`) is an unrigged mesh,
|
||||||
|
so she went through `autorig.py`: joined to one mesh, rebound by nearest-bone
|
||||||
|
weighting, no cloth chains. Her twin tails and skirt are dead geometry that
|
||||||
|
cannot move, and `SkinLegRepair` runs destructively on her every spawn.
|
||||||
|
|
||||||
|
Nothing downstream can recover this. **The single highest-leverage decision in
|
||||||
|
this whole pipeline is choosing a source model that already has a skeleton with
|
||||||
|
skirt and hair bones.** Everything else is recoverable; this is not.
|
||||||
|
|
||||||
|
A quick check on any candidate, without Blender:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json, struct
|
||||||
|
with open(path,'rb') as f:
|
||||||
|
f.read(12); clen,_=struct.unpack('<II',f.read(8))
|
||||||
|
j=json.loads(f.read(clen))
|
||||||
|
nodes=[n.get('name','') for n in j['nodes']]
|
||||||
|
joints=[nodes[i] for s in j.get('skins',[]) for i in s['joints']]
|
||||||
|
print(len(j['meshes']), 'meshes', len(joints), 'joints')
|
||||||
|
print([n for n in joints if any(t in n.lower() for t in ('hair','skirt','tail','ribbon'))])
|
||||||
|
```
|
||||||
|
|
||||||
|
Several meshes, 50+ joints, and a non-empty cosmetic list means a good source.
|
||||||
|
|
||||||
|
## Checking a source model before importing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/verify_character.py <model.glb>
|
||||||
|
```
|
||||||
|
|
||||||
|
What you want to see: several meshes, bone names containing `skirt`/`hair`,
|
||||||
|
twist bones (`thigh.L.001`), and weights that are NOT all at 4 influences.
|
||||||
|
`weights_authored` in the sidecar is measured from exactly this and decides
|
||||||
|
whether the destructive load-time repair runs.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Stylization — the cel-shaded look
|
||||||
|
|
||||||
|
Two passes, applied at load in `SkinnedPlayerModel.load_model`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
LevelMaterials.apply_toon_recursive(scene) # world-wide toon shading
|
||||||
|
LevelMaterials.apply_character_look(scene) # character-only corrections
|
||||||
|
```
|
||||||
|
|
||||||
|
## The trap: imported models bring their own line-work
|
||||||
|
|
||||||
|
Anime models exported from MMD/VRoid/Blender toon setups very often ship the
|
||||||
|
outline **as geometry** — an inverted-hull shell of the mesh with a flat black,
|
||||||
|
UNTEXTURED material, plus separate flat cards for the eye whites, irises and the
|
||||||
|
pupil highlight. The mesh you import is not just the character; part of it is
|
||||||
|
already the drawing.
|
||||||
|
|
||||||
|
Toon-lighting that shell is what put a **white rim on every hair strand**. It is
|
||||||
|
an inverted hull whose normals face away from you; a lighting model that adds a
|
||||||
|
rim term lights it brightly exactly where it is supposed to read as ink.
|
||||||
|
|
||||||
|
`apply_character_look` therefore looks for the model's own line-work and handles
|
||||||
|
it flat and unshaded. "Untextured" alone is NOT the test — that made every
|
||||||
|
flat-coloured model render as a black silhouette, because Quaternius' mannequin
|
||||||
|
has two untextured materials (a yellow body, lilac joints) and both were hidden
|
||||||
|
as though they were an outline shell. The test asks three things instead: is it
|
||||||
|
named `eyes*`, is it drawn front-face-culled (the classic inverted-hull setup),
|
||||||
|
or is its albedo near-black. An ink shell is black; a flat-coloured character is
|
||||||
|
any colour at all.
|
||||||
|
|
||||||
|
That test now runs at BUILD time (`tools/surface_map.classify_linework`) and its
|
||||||
|
answer lives in the sidecar. `SkinSurfaces.guess()` is the same rule kept as the
|
||||||
|
runtime fallback, for a model with no surface table — and the cull-mode half of
|
||||||
|
it is re-run at runtime even when the table exists, because glTF has no way to
|
||||||
|
say "draw only the backfaces" and an inverted hull cannot survive the round trip
|
||||||
|
as a cull mode. Blender genuinely cannot see it; Godot can.
|
||||||
|
|
||||||
|
What it then does:
|
||||||
|
|
||||||
|
- **Outline hull** → made fully transparent rather than deleted. Deleting a
|
||||||
|
surface would renumber the rest and break the mesh's own skin bindings. The
|
||||||
|
game draws its own outline.
|
||||||
|
- **Eye cards** (`resource_name` starts with `eyes`) → flat ink, except anything
|
||||||
|
with `HL` in the name, which is the glint in the pupil and really is white.
|
||||||
|
|
||||||
|
If a newly imported character comes out with a white halo, a black silhouette,
|
||||||
|
or black eyes that should have irises, the line-work test and the name-matching
|
||||||
|
below it are where to look — now in `tools/surface_map.py`, mirrored by
|
||||||
|
`SkinSurfaces.guess()`. **Change both or neither**: a model with a surface table
|
||||||
|
would start rendering differently from one without.
|
||||||
|
|
||||||
|
## Per-class art direction
|
||||||
|
|
||||||
|
Because each surface says what it is, each class takes its own numbers
|
||||||
|
(`LevelMaterials.CHARACTER_LOOK`). `body` is deliberately identical to what every
|
||||||
|
surface used to get, so the calibration this was all built on does not move. The
|
||||||
|
others are departures, each for a reason:
|
||||||
|
|
||||||
|
| Class | Outline | Band | Why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| body | 5.0 mm | 0.16 | unchanged — the baseline |
|
||||||
|
| cloth | 5.8 mm | 0.13 | a garment's silhouette is most of what separates a character from the background at range; folds need a defined terminator to read as fabric |
|
||||||
|
| hair | 3.4 mm | 0.20 | **the one that matters.** A hair mesh is dozens of near-parallel strands millimetres apart; at the body's 5 mm each strand's hull swallows its neighbour and the head reads as one solid dark cap |
|
||||||
|
| accessory | 6.8 mm | 0.10 | small, rigid, usually the most saturated thing on the character — meant to pop |
|
||||||
|
|
||||||
|
This required moving the outline from `material_overlay` on the INSTANCE to
|
||||||
|
`next_pass` on each surface's material. Miku's body, face and hair are three
|
||||||
|
surfaces of one mesh, so an instance-wide overlay can only ever give all three
|
||||||
|
the same weight.
|
||||||
|
|
||||||
|
Taila's eyes still render as black cards rather than amber irises. Her eye
|
||||||
|
surfaces are untextured, and the glTF import hands every untextured surface a
|
||||||
|
default near-white albedo, so colour cannot tell an iris card from a lash card
|
||||||
|
on her — the name is all there is, and `eyes*` currently means "ink". Unfixed.
|
||||||
|
|
||||||
|
## Materials on import: the unlit problem
|
||||||
|
|
||||||
|
Anime glTFs are very often exported "unlit": `KHR_materials_unlit`, a **black**
|
||||||
|
`baseColorFactor`, and the real texture wired to `emissiveTexture`. Renderers
|
||||||
|
honouring the unlit extension use base colour and ignore emission — so Blender
|
||||||
|
reads black, never references the images, and imports with `bpy.data.images`
|
||||||
|
**empty**. The character comes out a silhouette, and there is no node graph left
|
||||||
|
to patch afterwards.
|
||||||
|
|
||||||
|
`tools/gltf_fix.py` rewrites the container **before** import: emissive becomes
|
||||||
|
base colour, the unlit flag is dropped. It must run first — this is the first
|
||||||
|
thing `retarget.py::main` does, before `import_any`.
|
||||||
|
|
||||||
|
`fix_unlit_materials(meshes)` then repairs anything left inside Blender.
|
||||||
|
|
||||||
|
## What the toon pass does
|
||||||
|
|
||||||
|
`apply_toon_recursive` gives everything the game's banded ramp. `apply_character_look`
|
||||||
|
then softens the banding on characters, because re-banding an already-shaded
|
||||||
|
anime texture reads as gloss — the texture already contains its own shading and
|
||||||
|
the second pass fights it.
|
||||||
|
|
||||||
|
## Convention alignment
|
||||||
|
|
||||||
|
The Hoyoverse-class look is, broadly: a ramp texture indexed by NdotL for the
|
||||||
|
body, a separate ramp and often a dedicated shader for the face, an inverted-hull
|
||||||
|
outline whose width is vertex-colour-modulated, and specific handling for eyes
|
||||||
|
and hair highlights. This project does the simplified version — one banded ramp
|
||||||
|
plus a screen-space-ish ink treatment, and the model's own outline shell hidden
|
||||||
|
in favour of the game's. The face is NOT specially shaded here; if a character
|
||||||
|
comes out with harsh shadow shapes across the nose, that is the missing piece.
|
||||||
|
|
||||||
|
## Outline thickness
|
||||||
|
|
||||||
|
Lives with the toon material in `scenes/maps/level_materials.gd`
|
||||||
|
(`CHARACTER_INK` and the outline settings). This is the branch it was last
|
||||||
|
touched on — `feat/outline-thickness-and-tp-weapon-hold`.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Verification — and the trap that invalidated all of it
|
||||||
|
|
||||||
|
## READ THIS FIRST
|
||||||
|
|
||||||
|
**Godot restores every bone's local pose after the `SkeletonModifier3D` pass.**
|
||||||
|
|
||||||
|
So calling `force_update_all_bone_transforms()` and reading
|
||||||
|
`get_bone_global_pose()` from a `SceneTree` script, from `_process`, or anywhere
|
||||||
|
outside that pass recomputes the globals from the **animation alone**. The
|
||||||
|
shooter pose layer and the cloth solver are simply not in what you measure.
|
||||||
|
|
||||||
|
`debug/cloth_clip_check.gd` did exactly this. It reported the same ~95 mm of
|
||||||
|
leg-inside-skirt with collision fully enabled **and with the collision call
|
||||||
|
commented out**. Every number ever taken from that tool before 2026-07-26 is
|
||||||
|
void, and several rounds of "tuning did nothing" in the history were reading a
|
||||||
|
pose the solver never touched.
|
||||||
|
|
||||||
|
**To measure a pose layer, add your own `SkeletonModifier3D` as a child of the
|
||||||
|
`Skeleton3D` AFTER the one you care about, and snapshot inside its
|
||||||
|
`_process_modification()`.** The `PoseProbe` class in `cloth_clip_check.gd` and
|
||||||
|
`travel_dir_check.gd` is the pattern.
|
||||||
|
|
||||||
|
Two related traps:
|
||||||
|
|
||||||
|
- **Headless runs uncapped**, so the engine delta is sub-millisecond and anything
|
||||||
|
integrated barely moves. Set `SpringBones.fixed_delta = 1.0/60.0`.
|
||||||
|
- **A single frame of a locomotion clip measures the clip.** A run cycle twists
|
||||||
|
the torso against the hips by tens of degrees twice per stride, swamping
|
||||||
|
anything a pose layer does. Average over a stride.
|
||||||
|
|
||||||
|
## The tools
|
||||||
|
|
||||||
|
| Tool | Measures | Good |
|
||||||
|
|---|---|---|
|
||||||
|
| `spawn_smoke_test.gd` | spawn, skins, anim tree, camera, state cycling | 29 OK, 0 failures |
|
||||||
|
| `cloth_clip_check.gd` | leg-inside-cloth per movement state, per vertex | idle < 25 mm |
|
||||||
|
| `cloth_settle_check.gd` | deg/frame at a dead idle, contacts/frame | skirt < 0.1, hair < 0.01 |
|
||||||
|
| `cloth_perf_check.gd` | ms per character per frame | ~2.6 ms |
|
||||||
|
| `cloth_allow_check.gd` | how much of each limb the rest-clearance cap makes the solver blind to | 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/<tool>.gd
|
||||||
|
godot --headless --path . -s res://debug/<tool>.gd -- res://assets/characters/skins/<name>.glb
|
||||||
|
```
|
||||||
|
|
||||||
|
Scripts run with `-s` MUST extend `SceneTree`. A `Node` script never quits and
|
||||||
|
hangs forever.
|
||||||
|
|
||||||
|
## Measure the right quantity
|
||||||
|
|
||||||
|
`cloth_clip_check.gd` used to report "how much CLOSER the leg got than the artist
|
||||||
|
modelled it". A hem 200 mm clear of a shin legitimately comes 180 mm closer when
|
||||||
|
the leg kicks out in a slide, and counting that as a failure buried the real
|
||||||
|
clipping under motion the character is supposed to have. It now reports how far
|
||||||
|
INSIDE a capsule a cloth vertex is, over and above however far inside it was
|
||||||
|
modelled — only cloth actually within the capsule can be showing a leg through.
|
||||||
|
|
||||||
|
It also applies the collider's `from` offset, so it tests the same band of thigh
|
||||||
|
the solver is defending. Measuring the full bone tests the hip cap the solver
|
||||||
|
deliberately excludes and reports it as clipping no tuning can fix.
|
||||||
|
|
||||||
|
## What the suite still does not check
|
||||||
|
|
||||||
|
It verifies that a character is WELL-FORMED, not that it is CORRECT. Those are
|
||||||
|
different properties, and only the first was ever asserted — which is how four
|
||||||
|
characters shipped "All checks passed" while lying on their backs, seven times
|
||||||
|
too large, facing backwards, or unable to hold a gun. See `failure-modes.md`.
|
||||||
|
|
||||||
|
`posture` and `bone roles reachable at runtime` are now hard checks. Still
|
||||||
|
missing, and worth adding when a source next exposes them: facing measured on the
|
||||||
|
OUTPUT, and per-vertex validation that a generated cloth chain actually tracks
|
||||||
|
the geometry it was given.
|
||||||
|
|
||||||
|
## Diagnosing "the solver isn't working"
|
||||||
|
|
||||||
|
In order:
|
||||||
|
|
||||||
|
1. **Is the measurement inside the modifier pass?** (Above. Do this first.)
|
||||||
|
2. **Does the solver SEE the contact?** `debug_hit_report()` — bone → deepest
|
||||||
|
overlap it found. If ~0 while the mesh is deep inside a leg, the collision
|
||||||
|
hull does not cover the geometry that is clipping.
|
||||||
|
3. **Does it CONVERGE?** `debug_residual_report()` — overlap left after the
|
||||||
|
relaxation. Seen 93 mm, left 95 mm is a standing fight, not slow convergence;
|
||||||
|
quadrupling the iterations will buy nothing. Find what is pulling back.
|
||||||
|
4. **Only then, tune.**
|
||||||
|
|
||||||
|
That order was learned the hard way: the drape, the bend limits, the backstop,
|
||||||
|
the iteration count and the hull sampling were each suspected and tested, and
|
||||||
|
the answer was in step 1.
|
||||||
|
|
||||||
|
## Also run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --headless --path . -s res://movement/tests/run_fsm_tests.gd # 11 tests
|
||||||
|
godot --headless --path . --check-only --script res://<file>.gd # syntax
|
||||||
|
```
|
||||||
|
|
||||||
|
Autoload identifiers report false "not found" errors under `--check-only` —
|
||||||
|
ignore those.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 637 B |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 259 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 896 B |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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."
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,45 +10,118 @@
|
|||||||
"DEF-neck",
|
"DEF-neck",
|
||||||
"DEF-head"
|
"DEF-head"
|
||||||
],
|
],
|
||||||
"forearm.R": "DEF-forearm.R",
|
"shoulder.L": "DEF-shoulder.L",
|
||||||
"toe.R": "DEF-toe.R",
|
|
||||||
"upper_arm.R": "DEF-upper_arm.R",
|
|
||||||
"shin.L": "DEF-shin.L",
|
|
||||||
"shin.R": "DEF-shin.R",
|
|
||||||
"toe.L": "DEF-toe.L",
|
|
||||||
"hand.R": "DEF-hand.R",
|
|
||||||
"forearm.L": "DEF-forearm.L",
|
|
||||||
"foot.R": "DEF-foot.R",
|
|
||||||
"upper_arm.L": "DEF-upper_arm.L",
|
|
||||||
"thigh.R": "DEF-thigh.R",
|
|
||||||
"hand.L": "DEF-hand.L",
|
|
||||||
"shoulder.R": "DEF-shoulder.R",
|
"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.L": "DEF-thigh.L",
|
||||||
|
"thigh.R": "DEF-thigh.R",
|
||||||
|
"foot.R": "DEF-foot.R",
|
||||||
"foot.L": "DEF-foot.L",
|
"foot.L": "DEF-foot.L",
|
||||||
"shoulder.L": "DEF-shoulder.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": [],
|
"chains": [],
|
||||||
"twist": [],
|
"twist": [],
|
||||||
"colliders": [
|
"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",
|
"bone": "DEF-thigh.L",
|
||||||
"child": "DEF-shin.L",
|
"child": "DEF-shin.L",
|
||||||
"radius": 0.1245
|
"from": 0.1,
|
||||||
|
"radius_head": 0.1435,
|
||||||
|
"radius_tail": 0.1304,
|
||||||
|
"radius": 0.1304
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bone": "DEF-thigh.R",
|
"bone": "DEF-thigh.R",
|
||||||
"child": "DEF-shin.R",
|
"child": "DEF-shin.R",
|
||||||
"radius": 0.1079
|
"from": 0.1,
|
||||||
|
"radius_head": 0.1084,
|
||||||
|
"radius_tail": 0.1084,
|
||||||
|
"radius": 0.1084
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bone": "DEF-shin.L",
|
"bone": "DEF-shin.L",
|
||||||
"child": "DEF-foot.L",
|
"child": "DEF-foot.L",
|
||||||
"radius": 0.0762
|
"from": 0.1,
|
||||||
|
"radius_head": 0.0862,
|
||||||
|
"radius_tail": 0.0728,
|
||||||
|
"radius": 0.0728
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bone": "DEF-shin.R",
|
"bone": "DEF-shin.R",
|
||||||
"child": "DEF-foot.R",
|
"child": "DEF-foot.R",
|
||||||
"radius": 0.0762
|
"from": 0.1,
|
||||||
|
"radius_head": 0.0862,
|
||||||
|
"radius_tail": 0.0728,
|
||||||
|
"radius": 0.0728
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"weights_authored": false,
|
"weights_authored": false,
|
||||||
@@ -106,5 +179,51 @@
|
|||||||
"DEF-upper_arm.L",
|
"DEF-upper_arm.L",
|
||||||
"DEF-upper_arm.R",
|
"DEF-upper_arm.R",
|
||||||
"root"
|
"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": {}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 562 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -13,6 +13,34 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"model": "res://assets/characters/skins/taila.glb",
|
"model": "res://assets/characters/skins/taila.glb",
|
||||||
"unlocked": true
|
"unlocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mannequin",
|
||||||
|
"name": "Mannequin",
|
||||||
|
"description": "Quaternius reference mannequin (CC0)",
|
||||||
|
"model": "res://assets/characters/skins/mannequin.glb",
|
||||||
|
"unlocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kiyoko",
|
||||||
|
"name": "Kiyoko",
|
||||||
|
"description": "Kiyoko School Girl \u2014 VRoid (CC-BY, Kasujin)",
|
||||||
|
"model": "res://assets/characters/skins/kiyoko.glb",
|
||||||
|
"unlocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aria",
|
||||||
|
"name": "Aria",
|
||||||
|
"description": "Anime Girl Rigged \u2014 VRoid (CC-BY, dequeijospizza)",
|
||||||
|
"model": "res://assets/characters/skins/aria.glb",
|
||||||
|
"unlocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "momo",
|
||||||
|
"name": "Momo",
|
||||||
|
"description": "Momo Ayase, DANDADAN (CC-BY, HiGuys920)",
|
||||||
|
"model": "res://assets/characters/skins/momo.glb",
|
||||||
|
"unlocked": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ck8e6odry037
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
extends RefCounted
|
||||||
|
class_name SkinSurfaces
|
||||||
|
|
||||||
|
## What every surface of a character IS — body, cloth, hair or accessory.
|
||||||
|
##
|
||||||
|
## Written at build time by tools/surface_map.py into `<skin>.rig.json`, read
|
||||||
|
## here. Nothing at runtime re-derives it, which is the point: the question has
|
||||||
|
## one right answer per model and it is knowable in Blender, where the mesh, the
|
||||||
|
## weights and the skeleton are all in hand. Asking it again from a material at
|
||||||
|
## load time is how the mannequin's flat yellow body came to be rendered as a
|
||||||
|
## black silhouette — "untextured" is not the same question as "is ink".
|
||||||
|
##
|
||||||
|
## The heuristic that mistake came from is still here, as `guess()`, and still
|
||||||
|
## earns its place: it is the fallback for a model imported before the surface
|
||||||
|
## table existed, or one whose material genuinely says nothing. But it is now
|
||||||
|
## the last resort rather than the only source.
|
||||||
|
|
||||||
|
const BODY := "body"
|
||||||
|
const CLOTH := "cloth"
|
||||||
|
const HAIR := "hair"
|
||||||
|
const ACCESSORY := "accessory"
|
||||||
|
const LINEWORK := "linework"
|
||||||
|
|
||||||
|
## Same threshold as tools/surface_map.INK_LEVEL. The two must agree, or a model
|
||||||
|
## with a surface table would render differently from one without.
|
||||||
|
const INK_LEVEL := 0.18
|
||||||
|
|
||||||
|
var _by_slot: Dictionary = {} # "mesh|index" -> record
|
||||||
|
var _by_material: Dictionary = {} # material name -> record
|
||||||
|
## Materials used by two surfaces the classifier disagreed about. Taila reuses
|
||||||
|
## `ClothA` on three meshes and `FullBlack` on three more; those agree, so they
|
||||||
|
## stay usable. One that did not would silently give whichever surface was read
|
||||||
|
## first, so it is dropped from the material index instead and falls back to the
|
||||||
|
## slot key.
|
||||||
|
var _material_conflict: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
static func from_rig_info(rig_info: Dictionary) -> SkinSurfaces:
|
||||||
|
var out := SkinSurfaces.new()
|
||||||
|
for record in rig_info.get("surfaces", []):
|
||||||
|
if not record is Dictionary:
|
||||||
|
continue
|
||||||
|
out._by_slot["%s|%d" % [record.get("mesh", ""),
|
||||||
|
int(record.get("surface", 0))]] = record
|
||||||
|
var mat: String = record.get("material", "")
|
||||||
|
if mat == "":
|
||||||
|
continue
|
||||||
|
var seen = out._by_material.get(mat)
|
||||||
|
if seen != null and seen.get("class", "") != record.get("class", ""):
|
||||||
|
out._material_conflict[mat] = true
|
||||||
|
else:
|
||||||
|
out._by_material[mat] = record
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func is_empty() -> bool:
|
||||||
|
return _by_slot.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
## The build-time record for one surface, or an empty Dictionary.
|
||||||
|
##
|
||||||
|
## The mesh node name and surface index are tried first because they name
|
||||||
|
## exactly one surface. The material name is the fallback because it is what
|
||||||
|
## survives best — every character in this game arrives with its meshes called
|
||||||
|
## `Object_7` through `Object_32`, and a renamed node would take the slot key
|
||||||
|
## with it while `ClothB` stays `ClothB`.
|
||||||
|
func lookup(mesh_name: String, surface_index: int, material_name: String) -> Dictionary:
|
||||||
|
var by_slot = _by_slot.get("%s|%d" % [mesh_name, surface_index])
|
||||||
|
if by_slot != null:
|
||||||
|
return by_slot
|
||||||
|
if material_name != "" and not _material_conflict.has(material_name):
|
||||||
|
var by_mat = _by_material.get(material_name)
|
||||||
|
if by_mat != null:
|
||||||
|
return by_mat
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
## (class, detail) for a surface with no build-time record.
|
||||||
|
##
|
||||||
|
## This is the pre-surface-table heuristic, kept verbatim for the one job it is
|
||||||
|
## still right for. It answers a narrow question — is this untextured surface
|
||||||
|
## part of the model's own DRAWING? — and it answers it from the three things
|
||||||
|
## that actually distinguish line-work: being black, being drawn inside-out, or
|
||||||
|
## saying outright that it is an eye card. Anything else is treated as body,
|
||||||
|
## which is the safe answer because body is ordinary character shading.
|
||||||
|
static func guess(mat: BaseMaterial3D) -> Array:
|
||||||
|
if mat == null:
|
||||||
|
return [BODY, "skin"]
|
||||||
|
var name := mat.resource_name.to_lower()
|
||||||
|
if mat.albedo_texture == null:
|
||||||
|
if name.begins_with("eyes"):
|
||||||
|
return [BODY, "eyes_highlight" if name.contains("hl") else "eyes_ink"]
|
||||||
|
if mat.cull_mode == BaseMaterial3D.CULL_FRONT:
|
||||||
|
return [LINEWORK, "outline_hull"]
|
||||||
|
var c: Color = mat.albedo_color
|
||||||
|
if maxf(maxf(c.r, c.g), c.b) < INK_LEVEL:
|
||||||
|
return [LINEWORK, "outline_hull"]
|
||||||
|
return [BODY, "skin"]
|
||||||
|
|
||||||
|
|
||||||
|
## (class, detail) for a surface, from the table where it has an entry and from
|
||||||
|
## the heuristic where it does not.
|
||||||
|
##
|
||||||
|
## The cull-mode test is re-run even when the table HAS an entry, because that
|
||||||
|
## is the one piece of evidence the build side cannot see: glTF has no way to
|
||||||
|
## say "draw only the backfaces", so an inverted-hull outline arrives in Blender
|
||||||
|
## indistinguishable from an ordinary surface and only shows itself here.
|
||||||
|
func resolve(mesh_name: String, surface_index: int, mat: BaseMaterial3D) -> Array:
|
||||||
|
var material_name := "" if mat == null else mat.resource_name
|
||||||
|
if mat != null and mat.albedo_texture == null \
|
||||||
|
and mat.cull_mode == BaseMaterial3D.CULL_FRONT:
|
||||||
|
return [LINEWORK, "outline_hull"]
|
||||||
|
var record := lookup(mesh_name, surface_index, material_name)
|
||||||
|
if record.is_empty():
|
||||||
|
return guess(mat)
|
||||||
|
return [record.get("class", BODY), record.get("detail", "")]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://diqctdcqixjqg
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
extends Object
|
||||||
|
class_name TuningStore
|
||||||
|
|
||||||
|
## Per-character art direction on disk, layered so a number can be set once for
|
||||||
|
## everyone and then contradicted exactly where it matters.
|
||||||
|
##
|
||||||
|
## defaults every character, every subject
|
||||||
|
## skins.<skin>._all this character, every subject
|
||||||
|
## skins.<skin>.<key> this character, this subject
|
||||||
|
##
|
||||||
|
## "Subject" is whatever the caller is keying on — a weapon id for how a gun is
|
||||||
|
## held, an anchor set for where it is held. The store does not care.
|
||||||
|
##
|
||||||
|
## This is the shape WeaponHoldTuning arrived at, pulled out so it is not the
|
||||||
|
## only thing that can have it. Every knob in the rifle hold started as a
|
||||||
|
## constant tuned against one rig and was wrong on the next character imported;
|
||||||
|
## the ones that can be derived from the skeleton now are, and what is left is
|
||||||
|
## genuinely an artist's judgement — how high a stock rides, where in the palm a
|
||||||
|
## grip sits. Judgement wants a slider and a file, not another guess in code.
|
||||||
|
##
|
||||||
|
## An absent or empty file means "use the built-in defaults", so the game runs
|
||||||
|
## perfectly well with nothing tuned at all. This only ever ADDS information.
|
||||||
|
|
||||||
|
## Where a tuning file is read from and written to.
|
||||||
|
##
|
||||||
|
## The project copy is preferred on save so a tuning pass lands in version
|
||||||
|
## control beside the character it belongs to. `user://` is the fallback for an
|
||||||
|
## exported build, where res:// is read-only — and it WINS on load, so a pass
|
||||||
|
## made in a shipped build is not silently discarded.
|
||||||
|
static func read(res_path: String, user_path: String) -> Dictionary:
|
||||||
|
var base := _read_one(res_path)
|
||||||
|
var over := _read_one(user_path)
|
||||||
|
if over.is_empty():
|
||||||
|
return base
|
||||||
|
if base.is_empty():
|
||||||
|
return over
|
||||||
|
# Shallow is enough: the layers below are merged per key anyway.
|
||||||
|
for k in over:
|
||||||
|
base[k] = over[k]
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
static func _read_one(path: String) -> Dictionary:
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
return {}
|
||||||
|
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||||
|
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
|
||||||
|
|
||||||
|
|
||||||
|
## The resolved table for one character and one subject, most general first.
|
||||||
|
##
|
||||||
|
## Vectors survive the JSON round trip as three-element arrays and are rebuilt
|
||||||
|
## here rather than at every read site — a caller that forgot would get an Array
|
||||||
|
## where it expected a Vector3, which fails somewhere else entirely.
|
||||||
|
static func resolve(all: Dictionary, skin_id: String, subject: String) -> Dictionary:
|
||||||
|
var out := {}
|
||||||
|
var skins: Dictionary = all.get("skins", {})
|
||||||
|
var mine: Dictionary = skins.get(skin_id, {})
|
||||||
|
for layer in [all.get("defaults", {}), mine.get("_all", {}),
|
||||||
|
mine.get(subject, {})]:
|
||||||
|
if typeof(layer) != TYPE_DICTIONARY:
|
||||||
|
continue
|
||||||
|
for k in layer:
|
||||||
|
out[k] = layer[k]
|
||||||
|
return revive(out)
|
||||||
|
|
||||||
|
|
||||||
|
## Three-element arrays back into Vector3s, in place. Anything else is left
|
||||||
|
## alone, so a knob that is genuinely a list of three numbers would need its own
|
||||||
|
## handling — none is, and one that was would be a Vector3 anyway.
|
||||||
|
static func revive(table: Dictionary) -> Dictionary:
|
||||||
|
for k in table.keys():
|
||||||
|
var v = table[k]
|
||||||
|
if v is Array and v.size() == 3:
|
||||||
|
table[k] = Vector3(float(v[0]), float(v[1]), float(v[2]))
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
static func flatten(table: Dictionary) -> Dictionary:
|
||||||
|
var flat := {}
|
||||||
|
for k in table:
|
||||||
|
var v = table[k]
|
||||||
|
flat[k] = [v.x, v.y, v.z] if v is Vector3 else v
|
||||||
|
return flat
|
||||||
|
|
||||||
|
|
||||||
|
## Store one character+subject's table and write the file. Returns where it went.
|
||||||
|
static func write(all: Dictionary, skin_id: String, subject: String,
|
||||||
|
table: Dictionary, res_path: String, user_path: String) -> String:
|
||||||
|
if not all.has("skins"):
|
||||||
|
all["skins"] = {}
|
||||||
|
if not all["skins"].has(skin_id):
|
||||||
|
all["skins"][skin_id] = {}
|
||||||
|
all["skins"][skin_id][subject] = flatten(table)
|
||||||
|
|
||||||
|
var text := JSON.stringify(all, " ")
|
||||||
|
for path in [res_path, user_path]:
|
||||||
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if f:
|
||||||
|
f.store_string(text)
|
||||||
|
f.close()
|
||||||
|
return path
|
||||||
|
return "<could not write>"
|
||||||
|
|
||||||
|
|
||||||
|
## The default for a knob, from a spec table shaped
|
||||||
|
## `[key, label, minimum, maximum, is_vector, default]`.
|
||||||
|
static func default_for(specs: Array, key: String):
|
||||||
|
for spec in specs:
|
||||||
|
if spec[0] == key:
|
||||||
|
return spec[5]
|
||||||
|
return 0.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cq378n7o0qnoc
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
extends Object
|
||||||
|
class_name WeaponHoldTuning
|
||||||
|
|
||||||
|
## Per-character, per-weapon overrides for how a gun is held.
|
||||||
|
##
|
||||||
|
## Every knob in the rifle hold used to be a constant tuned against one rig, and
|
||||||
|
## every one of them was wrong on the next character imported — the mount
|
||||||
|
## rotation, the wrist twist, the weapon size. The ones that CAN be derived from
|
||||||
|
## the skeleton now are. The rest are genuinely art direction: how high the stock
|
||||||
|
## rides, how far the elbow flares, how hard the fingers close. Those want an
|
||||||
|
## artist's eye and a slider, not another guess in code.
|
||||||
|
##
|
||||||
|
## This is where that judgement is stored. debug/rig_lab.gd writes it;
|
||||||
|
## SkinnedPlayerModel reads it when a weapon is equipped.
|
||||||
|
##
|
||||||
|
## Resolution is layered, most general first, so a single number can be set once
|
||||||
|
## for everything and then contradicted where it matters:
|
||||||
|
##
|
||||||
|
## defaults every character, every weapon
|
||||||
|
## skins.<skin>._all this character, every weapon
|
||||||
|
## skins.<skin>.<weapon> this character, this weapon
|
||||||
|
##
|
||||||
|
## An empty file means "use the built-in defaults", so the game runs perfectly
|
||||||
|
## well with no tuning at all — this only ever adds information.
|
||||||
|
|
||||||
|
const PATH := "res://assets/characters/weapon_holds.json"
|
||||||
|
## Written to the project when running from source; falls back to user:// for an
|
||||||
|
## exported build, where res:// is read-only.
|
||||||
|
const USER_PATH := "user://weapon_holds.json"
|
||||||
|
|
||||||
|
# ── The pose axis ────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Half of these knobs mean something different at low ready than they do down
|
||||||
|
# the sights, and half do not. Where a hand sits ON the weapon is a fact about
|
||||||
|
# the gun and the character's hands; how the weapon is carried is a fact about
|
||||||
|
# what they are doing with it.
|
||||||
|
#
|
||||||
|
# The runtime blends between exactly TWO holds, on `ads` — there is no third.
|
||||||
|
# "Running" and "Crouched" in the lab are locomotion states that still use the
|
||||||
|
# low-ready hold, because that is all `_apply_rifle_hold` can express. Offering
|
||||||
|
# four independent pose tunings would be inventing a capability the code does
|
||||||
|
# not have, and the fourth would silently do nothing.
|
||||||
|
#
|
||||||
|
# So: two poses, and a knob names the ones it exists for.
|
||||||
|
const POSE_HIP := "hip"
|
||||||
|
const POSE_ADS := "ads"
|
||||||
|
const POSE_NAMES := {POSE_HIP: "low ready", POSE_ADS: "aiming"}
|
||||||
|
|
||||||
|
|
||||||
|
## Which pose a given `ads` blend is being tuned as.
|
||||||
|
static func pose_for_ads(ads: float) -> String:
|
||||||
|
return POSE_ADS if ads > 0.5 else POSE_HIP
|
||||||
|
|
||||||
|
|
||||||
|
## key -> [label, minimum, maximum, is_vector, default]
|
||||||
|
##
|
||||||
|
## The lab builds its whole UI from this, so adding a knob here is all it takes
|
||||||
|
## to expose one. Ranges are what a plausible answer lives inside, not what the
|
||||||
|
## value can technically be.
|
||||||
|
##
|
||||||
|
## The DEFAULT must match what the code does when nothing is tuned, or the lab
|
||||||
|
## lies: a slider parked at 0 next to a code default of 1.0 means the first touch
|
||||||
|
## of that slider silently switches the behaviour off. Zero means "let the code
|
||||||
|
## decide" only where it is called out below.
|
||||||
|
##
|
||||||
|
## These are the pose-INDEPENDENT ones. They describe the weapon and the hands
|
||||||
|
## on it, which do not change when the character shoulders the gun.
|
||||||
|
const SHARED_KNOBS := [
|
||||||
|
["weapon_scale", "Weapon size (0 = fit to arm)", 0.0, 1.4, false, 0.0],
|
||||||
|
["gun_stock", "TRIGGER hand along the weapon, from the butt (0 = auto)",
|
||||||
|
0.0, 0.45, false, 0.0],
|
||||||
|
["gun_fore", "SUPPORT hand along the weapon, from the grip (0 = auto)",
|
||||||
|
0.0, 0.50, false, 0.0],
|
||||||
|
# The two hand anchors, off the barrel line.
|
||||||
|
#
|
||||||
|
# `gun_stock` and `gun_fore` above are DISTANCES ALONG the barrel, and for a
|
||||||
|
# long time that was the only freedom either anchor had: the trigger hand
|
||||||
|
# could slide up and down the gun's own axis and nowhere else, and so could
|
||||||
|
# the support hand. That is fine for where along a handguard to hold, and
|
||||||
|
# useless for a handguard that sits below the bore, an angled foregrip, or a
|
||||||
|
# pistol whose grip is nowhere near its barrel line.
|
||||||
|
#
|
||||||
|
# These are in the GUN's frame — x across, y up, z along the barrel — so they
|
||||||
|
# stay meaningful as the weapon pitches between low ready and ADS. Zero is
|
||||||
|
# exactly the old behaviour. Their z overlaps `gun_stock`/`gun_fore`, which
|
||||||
|
# is redundant but harmless, and keeping the along-axis distances separate is
|
||||||
|
# what lets the reach solver slide the support hand back down the handguard
|
||||||
|
# without also undoing a deliberate sideways nudge.
|
||||||
|
["grip_shift", "TRIGGER hand, off the barrel line", -0.15, 0.15, true,
|
||||||
|
Vector3.ZERO],
|
||||||
|
["fore_shift", "SUPPORT hand, off the barrel line", -0.15, 0.15, true,
|
||||||
|
Vector3.ZERO],
|
||||||
|
["curl_wrap", "Finger wrap", 0.0, 2.0, false, 1.0],
|
||||||
|
["curl_trigger", "Trigger finger", 0.0, 2.0, false, 1.0],
|
||||||
|
["curl_thumb", "Thumb", 0.0, 2.0, false, 1.0],
|
||||||
|
]
|
||||||
|
|
||||||
|
## stem -> [label, minimum, maximum, is_vector, {pose: default}]
|
||||||
|
##
|
||||||
|
## Stored and read as `<stem>_<pose>`, which is the convention `pocket_hip` and
|
||||||
|
## `pocket_ads` already used — generalised so every knob that ought to differ
|
||||||
|
## between the two holds can.
|
||||||
|
##
|
||||||
|
## A pose ABSENT from the defaults dictionary means the knob does not exist
|
||||||
|
## there, and the lab will not show it. `pitch` is the case that forces this:
|
||||||
|
## down the sights the muzzle follows the camera, so there is nothing to tune,
|
||||||
|
## and a "muzzle pitch, aiming" slider would be a control that does nothing.
|
||||||
|
const POSE_KNOBS := [
|
||||||
|
["pocket", "Stock pocket", -0.30, 0.30, true, {
|
||||||
|
POSE_HIP: Vector3(0.03, -0.07, 0.06),
|
||||||
|
POSE_ADS: Vector3(0.05, 0.01, 0.07)}],
|
||||||
|
["pitch", "Muzzle pitch", -0.6, 0.6, false, {POSE_HIP: 0.16}],
|
||||||
|
# Full wrist orientation, not just a roll.
|
||||||
|
#
|
||||||
|
# These were one scalar each, a twist about the barrel, because that is the
|
||||||
|
# only axis a hand wrapping a cylinder is free in ONCE the arc onto the
|
||||||
|
# barrel has been solved. That is true of the support hand and it was never
|
||||||
|
# true of the trigger hand, and even for the support hand it left no way to
|
||||||
|
# cock a wrist forward or break it inward — which is most of what separates a
|
||||||
|
# convincing rifle hold from a mannequin's.
|
||||||
|
#
|
||||||
|
# Pitch, yaw and roll, applied in the GUN's frame (about across, up, and the
|
||||||
|
# barrel) so the axes mean the same thing at any weapon pitch. Zero is
|
||||||
|
# exactly the old behaviour, since the roll term was zero by default too.
|
||||||
|
["wrist_r", "TRIGGER wrist — pitch / yaw / roll", -1.6, 1.6, true, {
|
||||||
|
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
|
||||||
|
["wrist_l", "SUPPORT wrist — pitch / yaw / roll", -1.6, 1.6, true, {
|
||||||
|
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
|
||||||
|
# Zero means "use the code's own default" for these two — see _tv in
|
||||||
|
# ShooterPoseModifier, which treats a zero-length vector as unset.
|
||||||
|
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, {
|
||||||
|
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
|
||||||
|
["pole_l", "Support elbow (0 = auto)", -1.5, 1.5, true, {
|
||||||
|
POSE_HIP: Vector3.ZERO, POSE_ADS: Vector3.ZERO}],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
## The spec table for one pose: the shared knobs, plus that pose's own, with
|
||||||
|
## their keys already suffixed.
|
||||||
|
##
|
||||||
|
## This is what the lab builds its sliders from, so a knob that does not apply
|
||||||
|
## to the pose being adjusted is not merely disabled — it is not there.
|
||||||
|
static func knobs_for(pose: String) -> Array:
|
||||||
|
var out: Array = SHARED_KNOBS.duplicate()
|
||||||
|
for spec in POSE_KNOBS:
|
||||||
|
var defaults: Dictionary = spec[5]
|
||||||
|
if not defaults.has(pose):
|
||||||
|
continue
|
||||||
|
out.append(["%s_%s" % [spec[0], pose],
|
||||||
|
"%s, %s" % [spec[1], POSE_NAMES[pose]],
|
||||||
|
spec[2], spec[3], spec[4], defaults[pose]])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Every knob across every pose. For anything that has to reason about the whole
|
||||||
|
## table rather than about one screen of it — resetting, saving, and the checks.
|
||||||
|
static func all_knobs() -> Array:
|
||||||
|
var out: Array = SHARED_KNOBS.duplicate()
|
||||||
|
for spec in POSE_KNOBS:
|
||||||
|
var defaults: Dictionary = spec[5]
|
||||||
|
for pose in defaults:
|
||||||
|
out.append(["%s_%s" % [spec[0], pose],
|
||||||
|
"%s, %s" % [spec[1], POSE_NAMES[pose]],
|
||||||
|
spec[2], spec[3], spec[4], defaults[pose]])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## The built-in value for a knob, for a lab that has nothing saved yet.
|
||||||
|
##
|
||||||
|
## Across ALL poses, not just the one on screen: a reset or a save has to know
|
||||||
|
## what `pocket_ads` defaults to even while low ready is being adjusted.
|
||||||
|
static func default_for(key: String):
|
||||||
|
return TuningStore.default_for(all_knobs(), key)
|
||||||
|
|
||||||
|
|
||||||
|
## The layering, the JSON round trip and the res://-then-user:// write all live
|
||||||
|
## in TuningStore now, because they are not specific to weapons — rig anchors
|
||||||
|
## want exactly the same behaviour, and having two copies of it would mean two
|
||||||
|
## places for "an exported build's tuning pass is silently discarded" to come
|
||||||
|
## back. The on-disk format is unchanged.
|
||||||
|
static func load_all() -> Dictionary:
|
||||||
|
return TuningStore.read(PATH, USER_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
## The resolved knob table for one character holding one weapon.
|
||||||
|
static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary:
|
||||||
|
return TuningStore.resolve(all, skin_id, weapon_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Store one character+weapon's knobs and write the file. Returns where it went.
|
||||||
|
static func save(all: Dictionary, skin_id: String, weapon_id: String,
|
||||||
|
knobs: Dictionary) -> String:
|
||||||
|
return TuningStore.write(all, skin_id, weapon_id, knobs, PATH, USER_PATH)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cxbghp14y3i7v
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bw4ojr7c451pq
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d3m2g70t1tkho
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://br28g1yiljtpp
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dck4ag2tssaea
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Dev tool: does the LEG actually poke through the CLOTH?
|
||||||
|
##
|
||||||
|
## godot --headless --path . -s res://debug/cloth_clip_check.gd -- [skin_glb]
|
||||||
|
##
|
||||||
|
## Skins every cloth vertex itself over a sweep of movement states and measures
|
||||||
|
## how far each one ends up INSIDE the leg capsules from <model>.rig.json.
|
||||||
|
##
|
||||||
|
## This exists because debug/cloth_settle_check.gd measures the wrong thing for
|
||||||
|
## this question. That one reports how far a cloth BONE penetrates, which came
|
||||||
|
## back at about a millimetre while the thigh was still visibly through the
|
||||||
|
## skirt in almost every animation — because a skirt panel is a wide sheet and
|
||||||
|
## its bone is a single stick from the waist. Keeping the stick out of the leg
|
||||||
|
## says nothing about the hundreds of vertices hanging off it.
|
||||||
|
##
|
||||||
|
## Reports per surface, worst over the sweep:
|
||||||
|
## DEPTH how far the deepest vertex sits inside a capsule (metres)
|
||||||
|
## COUNT how many vertices are inside at that worst moment
|
||||||
|
##
|
||||||
|
## THE POSE IS READ FROM INSIDE THE MODIFIER PASS, from an observer
|
||||||
|
## SkeletonModifier3D added after SpringBones. It has to be. Godot restores every
|
||||||
|
## bone's local pose once the modifier pass is over, so a reader that calls
|
||||||
|
## force_update_all_bone_transforms() afterwards recomputes the global poses from
|
||||||
|
## the ANIMATION ALONE and never sees a single thing the cloth solver did. This
|
||||||
|
## tool did exactly that, and reported the same ~95 mm whether the collision was
|
||||||
|
## fully enabled or commented out — which is how the mistake was found.
|
||||||
|
|
||||||
|
## state, speed
|
||||||
|
## Idle FIRST and again LAST. A number taken from the state that happens to
|
||||||
|
## follow a dash is measuring the garment settling, not the garment at rest, and
|
||||||
|
## the two want opposite fixes — the sweep used to end on idle and reported the
|
||||||
|
## recovery as an idle failure.
|
||||||
|
const SWEEP := [["ground", 0.0], ["ground", 3.0], ["ground", 9.0], ["air", 6.0],
|
||||||
|
["air", -8.0], ["slide", 10.0], ["dash", 14.0], ["ground", 0.0]]
|
||||||
|
const FRAMES_PER_STATE := 60
|
||||||
|
|
||||||
|
var _frames := 0
|
||||||
|
var _model: SkinnedPlayerModel = null
|
||||||
|
var _caps: Array = [] # [bone_a, bone_b, r_head, r_tail]
|
||||||
|
var _worst := {}
|
||||||
|
var _worst_n := {}
|
||||||
|
var _cloth_bones := {} # skin bind index sets are per surface; see below
|
||||||
|
var _driver := {} # mesh -> bone dominating its deepest vertex
|
||||||
|
var _rest := {} # "mesh/surface" -> per-vertex rest clearance
|
||||||
|
var _key := ""
|
||||||
|
var _spring = null
|
||||||
|
## Full weight list of each surface's deepest vertex. A cloth solver can only
|
||||||
|
## move a vertex the CLOTH drives — one that is half-weighted to a thigh follows
|
||||||
|
## that thigh however well the garment is simulated, so "how much of this vertex
|
||||||
|
## does the skirt actually own" has to be part of the report.
|
||||||
|
var _mix := {}
|
||||||
|
## Worst phase of the sweep per surface, so a failure points at a movement state.
|
||||||
|
var _phase_of := {}
|
||||||
|
var _phase := 0
|
||||||
|
## bone name -> deepest contact the SOLVER reported on it over the sweep.
|
||||||
|
var _saw := {}
|
||||||
|
var _probe: PoseProbe = null
|
||||||
|
## bone name -> overlap still left once the relaxation had converged.
|
||||||
|
var _res := {}
|
||||||
|
var _per_phase := {}
|
||||||
|
var _per_phase_n := {}
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
var path: String = args[0] if args.size() > 0 \
|
||||||
|
else "res://assets/characters/skins/taila.glb"
|
||||||
|
var scene := Node3D.new()
|
||||||
|
root.add_child(scene)
|
||||||
|
current_scene = scene
|
||||||
|
_model = SkinnedPlayerModel.new()
|
||||||
|
_model.model_path = path
|
||||||
|
scene.add_child(_model)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_caps(skel: Skeleton3D, path: String) -> void:
|
||||||
|
var side := path.get_basename() + ".rig.json"
|
||||||
|
if not FileAccess.file_exists(side):
|
||||||
|
print("no sidecar — nothing to check against")
|
||||||
|
return
|
||||||
|
var info = JSON.parse_string(FileAccess.get_file_as_string(side))
|
||||||
|
if typeof(info) != TYPE_DICTIONARY:
|
||||||
|
return
|
||||||
|
for c in info.get("colliders", []):
|
||||||
|
var a := skel.find_bone(String(c.get("bone", "")))
|
||||||
|
var b := skel.find_bone(String(c.get("child", "")))
|
||||||
|
if a < 0 or b < 0:
|
||||||
|
continue
|
||||||
|
var tail := float(c.get("radius_tail", c.get("radius", 0.1)))
|
||||||
|
_caps.append([a, b, float(c.get("radius_head", tail)), tail,
|
||||||
|
float(c.get("from", 0.0))])
|
||||||
|
for c in info.get("chains", []):
|
||||||
|
for n in c.get("bones", []):
|
||||||
|
var i := skel.find_bone(String(n))
|
||||||
|
if i >= 0:
|
||||||
|
_cloth_bones[i] = true
|
||||||
|
print("checking %d cloth bones against %d leg capsules" % [
|
||||||
|
_cloth_bones.size(), _caps.size()])
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
_frames += 1
|
||||||
|
if _frames < 8:
|
||||||
|
return false
|
||||||
|
var skel: Skeleton3D = _model.skeleton
|
||||||
|
if skel == null:
|
||||||
|
return true
|
||||||
|
if _caps.is_empty() and _cloth_bones.is_empty():
|
||||||
|
_load_caps(skel, _model.model_path)
|
||||||
|
if _caps.is_empty():
|
||||||
|
return true
|
||||||
|
if _spring == null:
|
||||||
|
# Headless runs uncapped, so the engine delta is sub-millisecond and the
|
||||||
|
# solver integrates almost nothing. Pin it to a real frame so the sweep
|
||||||
|
# measures cloth in motion rather than cloth held at its rest pose.
|
||||||
|
_spring = skel.get_node_or_null("SpringBones")
|
||||||
|
if _spring:
|
||||||
|
_spring.fixed_delta = 1.0 / 60.0
|
||||||
|
_probe = PoseProbe.new()
|
||||||
|
_probe.name = "ClipProbe"
|
||||||
|
skel.add_child(_probe) # AFTER SpringBones, so it sees the final pose
|
||||||
|
return false
|
||||||
|
if _rest.is_empty():
|
||||||
|
# Baseline first: a skirt legitimately drapes INSIDE the thigh capsule,
|
||||||
|
# so absolute depth says nothing. What matters is the leg getting closer
|
||||||
|
# to a piece of cloth than the artist modelled it.
|
||||||
|
_capture_rest(skel)
|
||||||
|
return false
|
||||||
|
var phase: int = clampi((_frames - 8) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
|
||||||
|
_phase = phase
|
||||||
|
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
|
||||||
|
_model.set_locomotion(0.0, 1.0, 0.0)
|
||||||
|
_measure(skel)
|
||||||
|
# What the SOLVER thinks is happening, alongside what the mesh is doing. If
|
||||||
|
# a bone's vertices are deep inside a leg while its own contact report is
|
||||||
|
# near zero, the solver is not blind by tuning — it is not looking at the
|
||||||
|
# geometry that is clipping.
|
||||||
|
if _spring:
|
||||||
|
var rep: Dictionary = _spring.debug_hit_report()
|
||||||
|
for b in rep:
|
||||||
|
_saw[skel.get_bone_name(b)] = maxf(_saw.get(skel.get_bone_name(b), 0.0),
|
||||||
|
float(rep[b]))
|
||||||
|
var res: Dictionary = _spring.debug_residual_report()
|
||||||
|
for b in res:
|
||||||
|
_res[skel.get_bone_name(b)] = maxf(_res.get(skel.get_bone_name(b), 0.0),
|
||||||
|
float(res[b]))
|
||||||
|
if _frames > 8 + FRAMES_PER_STATE * SWEEP.size():
|
||||||
|
_report()
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
## Clearance of every cloth vertex to the legs in the REST pose.
|
||||||
|
func _capture_rest(skel: Skeleton3D) -> void:
|
||||||
|
var segs: Array = []
|
||||||
|
for c in _caps:
|
||||||
|
# The `from` offset MATTERS. SpringBones starts a limb capsule 10% down
|
||||||
|
# the bone because the top of a thigh is hip, buried inside the body the
|
||||||
|
# skirt hangs from — see tools/retarget.py::_leg_colliders. Measuring
|
||||||
|
# against the full bone tests a band the solver is deliberately not
|
||||||
|
# defending and reports it as clipping that no tuning can ever fix.
|
||||||
|
var ra: Vector3 = skel.get_bone_global_rest(c[0]).origin
|
||||||
|
var rb: Vector3 = skel.get_bone_global_rest(c[1]).origin
|
||||||
|
segs.append([ra.lerp(rb, c[4]), rb, c[2], c[3]])
|
||||||
|
for mi in _model.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh == null or mi.skin == null:
|
||||||
|
continue
|
||||||
|
var skin: Skin = mi.skin
|
||||||
|
var bone_of := {}
|
||||||
|
for b in skin.get_bind_count():
|
||||||
|
var bi := skin.get_bind_bone(b)
|
||||||
|
if bi < 0:
|
||||||
|
bi = skel.find_bone(skin.get_bind_name(b))
|
||||||
|
bone_of[b] = bi
|
||||||
|
for s in range(mi.mesh.get_surface_count()):
|
||||||
|
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
||||||
|
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||||
|
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||||
|
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||||
|
if bones.is_empty() or verts.is_empty():
|
||||||
|
continue
|
||||||
|
var per: int = bones.size() / verts.size()
|
||||||
|
var out := PackedFloat32Array()
|
||||||
|
out.resize(verts.size())
|
||||||
|
for v in verts.size():
|
||||||
|
var q := Vector3.ZERO
|
||||||
|
for k in per:
|
||||||
|
var w: float = weights[v * per + k]
|
||||||
|
if w <= 0.0:
|
||||||
|
continue
|
||||||
|
var bi: int = bone_of[bones[v * per + k]]
|
||||||
|
if bi < 0:
|
||||||
|
continue
|
||||||
|
q += (skel.get_bone_global_rest(bi) * skin.get_bind_pose(bones[v * per + k]) * verts[v]) * w
|
||||||
|
out[v] = _clearance(q, segs)
|
||||||
|
_rest["%s/%d" % [mi.name, s]] = out
|
||||||
|
|
||||||
|
|
||||||
|
## Which capsule the last _clearance() call picked. Reported for the deepest
|
||||||
|
## vertex, because "inside a leg" and "inside the waist lid" are different
|
||||||
|
## failures with different fixes and the bare number cannot tell them apart.
|
||||||
|
var _which := -1
|
||||||
|
|
||||||
|
## Distance from the nearest capsule SURFACE (negative = inside).
|
||||||
|
func _clearance(p: Vector3, segs: Array) -> float:
|
||||||
|
var best := INF
|
||||||
|
var idx := 0
|
||||||
|
for s in segs:
|
||||||
|
var a: Vector3 = s[0]
|
||||||
|
var ab: Vector3 = s[1] - a
|
||||||
|
var d2: float = ab.length_squared()
|
||||||
|
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
|
||||||
|
var r: float = lerpf(s[2], s[3], t)
|
||||||
|
var d := p.distance_to(a + ab * t) - r
|
||||||
|
if d < best:
|
||||||
|
best = d
|
||||||
|
_which = idx
|
||||||
|
idx += 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
## Snapshot of every bone's global pose, taken INSIDE the modifier pass. See the
|
||||||
|
## header: read any later and the cloth solver's work is already gone.
|
||||||
|
class PoseProbe extends SkeletonModifier3D:
|
||||||
|
var pose: Array = []
|
||||||
|
|
||||||
|
func _process_modification() -> void:
|
||||||
|
var skel := get_skeleton()
|
||||||
|
if skel == null:
|
||||||
|
return
|
||||||
|
pose.resize(skel.get_bone_count())
|
||||||
|
for i in skel.get_bone_count():
|
||||||
|
pose[i] = skel.get_bone_global_pose(i)
|
||||||
|
|
||||||
|
|
||||||
|
func _measure(skel: Skeleton3D) -> void:
|
||||||
|
if _probe == null or _probe.pose.size() != skel.get_bone_count():
|
||||||
|
return
|
||||||
|
var segs: Array = []
|
||||||
|
for c in _caps:
|
||||||
|
var pa: Vector3 = (_probe.pose[c[0]] as Transform3D).origin
|
||||||
|
var pb: Vector3 = (_probe.pose[c[1]] as Transform3D).origin
|
||||||
|
segs.append([pa.lerp(pb, c[4]), pb, c[2], c[3]])
|
||||||
|
|
||||||
|
for mi in _model.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh == null or mi.skin == null:
|
||||||
|
continue
|
||||||
|
var skin: Skin = mi.skin
|
||||||
|
var bone_of := {}
|
||||||
|
for b in skin.get_bind_count():
|
||||||
|
var bi := skin.get_bind_bone(b)
|
||||||
|
if bi < 0:
|
||||||
|
bi = skel.find_bone(skin.get_bind_name(b))
|
||||||
|
bone_of[b] = bi
|
||||||
|
for s in range(mi.mesh.get_surface_count()):
|
||||||
|
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
||||||
|
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||||
|
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||||
|
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||||
|
if bones.is_empty() or verts.is_empty():
|
||||||
|
continue
|
||||||
|
var per: int = bones.size() / verts.size()
|
||||||
|
var deepest := 0.0
|
||||||
|
var count := 0
|
||||||
|
var deep_v := -1
|
||||||
|
var deep_cap := -1
|
||||||
|
for v in verts.size():
|
||||||
|
# Only vertices the CLOTH actually drives — the body's own legs
|
||||||
|
# are inside these capsules by definition.
|
||||||
|
var is_cloth := false
|
||||||
|
var q := Vector3.ZERO
|
||||||
|
for k in per:
|
||||||
|
var w: float = weights[v * per + k]
|
||||||
|
if w <= 0.0:
|
||||||
|
continue
|
||||||
|
var bind: int = bones[v * per + k]
|
||||||
|
var bi: int = bone_of[bind]
|
||||||
|
if bi < 0:
|
||||||
|
continue
|
||||||
|
if _cloth_bones.has(bi) and w > 0.5:
|
||||||
|
is_cloth = true
|
||||||
|
q += ((_probe.pose[bi] as Transform3D) * skin.get_bind_pose(bind) * verts[v]) * w
|
||||||
|
if not is_cloth:
|
||||||
|
continue
|
||||||
|
var rest_arr: PackedFloat32Array = _rest.get("%s/%d" % [mi.name, s], PackedFloat32Array())
|
||||||
|
if v >= rest_arr.size():
|
||||||
|
continue
|
||||||
|
# How far INSIDE a leg this piece of cloth now is, over and above
|
||||||
|
# however far inside the artist modelled it.
|
||||||
|
#
|
||||||
|
# Not "how much closer the leg got": a hem 200 mm clear of a shin
|
||||||
|
# legitimately comes 180 mm closer when the leg kicks out in a
|
||||||
|
# slide, and counting that as a failure buried the real clipping
|
||||||
|
# under motion the character is supposed to have. Only cloth that
|
||||||
|
# is actually within the capsule can be showing a leg through it.
|
||||||
|
var inside := -_clearance(q, segs)
|
||||||
|
var hit := _which
|
||||||
|
if inside <= 0.0:
|
||||||
|
continue
|
||||||
|
var d := inside - maxf(-rest_arr[v], 0.0)
|
||||||
|
if d > 0.0:
|
||||||
|
count += 1
|
||||||
|
if d > deepest:
|
||||||
|
deepest = d
|
||||||
|
deep_v = v
|
||||||
|
deep_cap = hit
|
||||||
|
if deepest <= 0.0:
|
||||||
|
continue
|
||||||
|
# Per state as well as overall: one worst number over a whole sweep
|
||||||
|
# hides which movement actually breaks, and it moves to a different
|
||||||
|
# state after every change, which reads as "no progress" when a real
|
||||||
|
# failure has in fact been fixed and a smaller one exposed.
|
||||||
|
var pk := "%d" % _phase
|
||||||
|
if deepest > _per_phase.get(pk, 0.0):
|
||||||
|
_per_phase[pk] = deepest
|
||||||
|
_per_phase_n[pk] = count
|
||||||
|
var key: String = "%s/%s" % [mi.name, mi.mesh.surface_get_name(s)]
|
||||||
|
if deepest > _worst.get(key, 0.0):
|
||||||
|
_worst[key] = deepest
|
||||||
|
_worst_n[key] = count
|
||||||
|
_phase_of[key] = "%s@%.0f in %s" % [SWEEP[_phase][0],
|
||||||
|
SWEEP[_phase][1],
|
||||||
|
skel.get_bone_name(_caps[deep_cap][0]) if deep_cap >= 0 else "?"]
|
||||||
|
# EVERY bone driving the deepest vertex, not just the strongest.
|
||||||
|
# A solver can only move what the cloth owns: a vertex half
|
||||||
|
# weighted to a thigh follows that thigh however well the garment
|
||||||
|
# is simulated, and no amount of solver work will change it.
|
||||||
|
var mix: Array = []
|
||||||
|
var best := 0.0
|
||||||
|
var bn := -1
|
||||||
|
for k in per:
|
||||||
|
var w: float = weights[deep_v * per + k]
|
||||||
|
if w <= 0.001:
|
||||||
|
continue
|
||||||
|
var bi: int = bone_of[bones[deep_v * per + k]]
|
||||||
|
mix.append("%s=%.2f" % [
|
||||||
|
skel.get_bone_name(bi) if bi >= 0 else "?", w])
|
||||||
|
if w > best:
|
||||||
|
best = w
|
||||||
|
bn = bi
|
||||||
|
_mix[key] = " ".join(mix)
|
||||||
|
_driver[mi.name] = "%s w=%.2f" % [
|
||||||
|
skel.get_bone_name(bn) if bn >= 0 else "?", best]
|
||||||
|
|
||||||
|
# _model.set_locomotion is enough to keep the pose layer fed.
|
||||||
|
|
||||||
|
|
||||||
|
## How far inside the nearest leg capsule this point is (0 if clear).
|
||||||
|
func _penetration(p: Vector3, segs: Array) -> float:
|
||||||
|
var worst := 0.0
|
||||||
|
for s in segs:
|
||||||
|
var a: Vector3 = s[0]
|
||||||
|
var ab: Vector3 = s[1] - a
|
||||||
|
var d2: float = ab.length_squared()
|
||||||
|
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
|
||||||
|
var r: float = lerpf(s[2], s[3], t)
|
||||||
|
worst = maxf(worst, r - p.distance_to(a + ab * t))
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
|
func _report() -> void:
|
||||||
|
print("\n=== worst LEG-INSIDE-CLOTH penetration over the sweep ===")
|
||||||
|
if _worst.is_empty():
|
||||||
|
print(" none — no cloth vertex entered a leg capsule\n")
|
||||||
|
return
|
||||||
|
var keys := _worst.keys()
|
||||||
|
keys.sort_custom(func(a, b): return _worst[a] > _worst[b])
|
||||||
|
for k in keys:
|
||||||
|
print(" %-30s %6.1f mm %4d verts worst in %-10s" % [
|
||||||
|
k, _worst[k] * 1000.0, _worst_n[k], _phase_of.get(k, "?")])
|
||||||
|
print(" deepest vertex weights: %s" % _mix.get(k, "?"))
|
||||||
|
var owner: String = _mix.get(k, "=").get_slice("=", 0)
|
||||||
|
print(" on %s: contact seen %.1f mm, left after solving %.1f mm" % [
|
||||||
|
owner, _saw.get(owner, 0.0) * 1000.0, _res.get(owner, 0.0) * 1000.0])
|
||||||
|
print(" per movement state, worst cloth vertex inside a capsule:")
|
||||||
|
for i in SWEEP.size():
|
||||||
|
print(" %-12s %6.1f mm %4d verts" % [
|
||||||
|
"%s@%.0f" % [SWEEP[i][0], SWEEP[i][1]],
|
||||||
|
_per_phase.get("%d" % i, 0.0) * 1000.0, _per_phase_n.get("%d" % i, 0)])
|
||||||
|
print("")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c4x5gy6vjcvb0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d3td7sln77h1i
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bk7pst2vhawvt
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ccx4eh7wgfwrh
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Print every mesh surface of every shipping skin: node name, surface index,
|
||||||
|
## material name, whether it carries a texture, its albedo and cull mode, and
|
||||||
|
## which bones dominate it.
|
||||||
|
##
|
||||||
|
## This exists because the surface table in the rig sidecar is written by Blender
|
||||||
|
## and read by Godot, and the two do not have to agree on what anything is
|
||||||
|
## called. Rather than assume the glTF round trip preserves names and ordering,
|
||||||
|
## this measures what Godot ends up holding, so the sidecar can be keyed on
|
||||||
|
## something that actually survives.
|
||||||
|
##
|
||||||
|
## godot --headless --path . -s res://debug/dump_surfaces.gd
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var registry := "res://assets/characters/skins/skins.json"
|
||||||
|
var data = JSON.parse_string(FileAccess.get_file_as_string(registry))
|
||||||
|
for entry in data["skins"]:
|
||||||
|
var path: String = entry.get("model", "")
|
||||||
|
if path == "" or not ResourceLoader.exists(path):
|
||||||
|
continue
|
||||||
|
print("\n=== %s (%s)" % [entry["id"], path])
|
||||||
|
var scene: Node = load(path).instantiate()
|
||||||
|
var skel: Skeleton3D = _first_skeleton(scene)
|
||||||
|
for mi in scene.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh == null:
|
||||||
|
continue
|
||||||
|
var owner_names := _dominant_bones(mi, skel)
|
||||||
|
for s in mi.mesh.get_surface_count():
|
||||||
|
var m: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
|
||||||
|
var mat_name := "<none>" if m == null else m.resource_name
|
||||||
|
var tex := m != null and m.albedo_texture != null
|
||||||
|
var col := Color.WHITE if m == null else m.albedo_color
|
||||||
|
var cull := -1 if m == null else int(m.cull_mode)
|
||||||
|
print(" %-34s s%d mat=%-28s tex=%s albedo=(%.2f,%.2f,%.2f) cull=%d bones=%s"
|
||||||
|
% [mi.name, s, mat_name, "Y" if tex else "n",
|
||||||
|
col.r, col.g, col.b, cull, owner_names])
|
||||||
|
scene.free()
|
||||||
|
quit()
|
||||||
|
|
||||||
|
|
||||||
|
func _first_skeleton(node: Node) -> Skeleton3D:
|
||||||
|
if node is Skeleton3D:
|
||||||
|
return node
|
||||||
|
for c in node.get_children():
|
||||||
|
var found := _first_skeleton(c)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## The five bones holding the most dominant-weight vertices on this mesh.
|
||||||
|
func _dominant_bones(mi: MeshInstance3D, skel: Skeleton3D) -> String:
|
||||||
|
if mi.skin == null or skel == null or mi.mesh == null:
|
||||||
|
return "<unskinned>"
|
||||||
|
var bone_of := {}
|
||||||
|
for b in mi.skin.get_bind_count():
|
||||||
|
var n := mi.skin.get_bind_name(b)
|
||||||
|
bone_of[b] = skel.find_bone(n) if n != "" else mi.skin.get_bind_bone(b)
|
||||||
|
var tally := {}
|
||||||
|
var arrays: Array = mi.mesh.surface_get_arrays(0)
|
||||||
|
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||||
|
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||||
|
if bones.is_empty():
|
||||||
|
return "<no weights>"
|
||||||
|
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||||
|
var per := bones.size() / maxi(1, verts.size())
|
||||||
|
for v in verts.size():
|
||||||
|
var best := -1
|
||||||
|
var best_w := 0.0
|
||||||
|
for k in per:
|
||||||
|
var w := weights[v * per + k]
|
||||||
|
if w > best_w:
|
||||||
|
best_w = w
|
||||||
|
best = bones[v * per + k]
|
||||||
|
if best >= 0 and best_w > 0.25:
|
||||||
|
var bi: int = bone_of.get(best, -1)
|
||||||
|
if bi >= 0:
|
||||||
|
var nm := skel.get_bone_name(bi)
|
||||||
|
tally[nm] = tally.get(nm, 0) + 1
|
||||||
|
var names: Array = tally.keys()
|
||||||
|
names.sort_custom(func(a, b): return tally[a] > tally[b])
|
||||||
|
var out: PackedStringArray = []
|
||||||
|
for i in mini(5, names.size()):
|
||||||
|
out.append("%s:%d" % [names[i], tally[names[i]]])
|
||||||
|
return ", ".join(out)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ch7c3vv6t2gei
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dydhylqo46x1n
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Dev tool: does the cloth actually JITTER when the character is standing still?
|
||||||
|
##
|
||||||
|
## godot --path . --windowed --resolution 900x900 \
|
||||||
|
## -s res://debug/idle_jitter_check.gd -- <out_dir>
|
||||||
|
##
|
||||||
|
## debug/cloth_settle_check.gd answers this in degrees per frame of LOCAL bone
|
||||||
|
## rotation, and that number is inflated for a chain: correcting a panel root
|
||||||
|
## shows up as an equal and opposite delta on its segments, so a hem that has not
|
||||||
|
## moved at all in world space can report ten degrees. It has misled before.
|
||||||
|
##
|
||||||
|
## This renders consecutive frames of a still idle from a fixed camera and saves
|
||||||
|
## them; comparing neighbouring PNGs gives the only number that matters, which is
|
||||||
|
## whether anything on screen moved.
|
||||||
|
|
||||||
|
var _frames := 0
|
||||||
|
var _out := "."
|
||||||
|
var _model: SkinnedPlayerModel = null
|
||||||
|
var _cam: Camera3D = null
|
||||||
|
var _shots := 0
|
||||||
|
const SHOTS := 12
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
_out = args[0] if args.size() > 0 else "."
|
||||||
|
var scene := Node3D.new()
|
||||||
|
root.add_child(scene)
|
||||||
|
current_scene = scene
|
||||||
|
|
||||||
|
var env := WorldEnvironment.new()
|
||||||
|
var e := Environment.new()
|
||||||
|
e.background_mode = Environment.BG_COLOR
|
||||||
|
e.background_color = Color(0.05, 0.05, 0.08)
|
||||||
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
e.ambient_light_color = Color(1, 1, 1)
|
||||||
|
e.ambient_light_energy = 1.3
|
||||||
|
env.environment = e
|
||||||
|
scene.add_child(env)
|
||||||
|
var sun := DirectionalLight3D.new()
|
||||||
|
sun.rotation_degrees = Vector3(-40, 35, 0)
|
||||||
|
scene.add_child(sun)
|
||||||
|
|
||||||
|
_model = SkinnedPlayerModel.new()
|
||||||
|
_model.model_path = "res://assets/characters/skins/taila.glb"
|
||||||
|
scene.add_child(_model)
|
||||||
|
_cam = Camera3D.new()
|
||||||
|
_cam.fov = 28.0
|
||||||
|
scene.add_child(_cam)
|
||||||
|
_cam.current = true
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
_frames += 1
|
||||||
|
if _frames < 10 or not _model.loaded:
|
||||||
|
return false
|
||||||
|
_model.update_state("ground", 0.0, false)
|
||||||
|
_model.set_locomotion(0.0, 0.0, 0.0)
|
||||||
|
var hips := 0.95
|
||||||
|
if _model.skeleton:
|
||||||
|
var h := _model.skeleton.find_bone("DEF-spine")
|
||||||
|
if h >= 0:
|
||||||
|
hips = _model.skeleton.get_bone_global_pose(h).origin.y
|
||||||
|
# NEGATIVE Z is the FRONT. SkinnedPlayerModel spins the imported scene 180
|
||||||
|
# degrees (`facing_flip`: glTF forward is +Z, players face -Z), so a camera
|
||||||
|
# on +Z looks at the character's BACK. Every tool in here used to sit on +Z,
|
||||||
|
# and every "front" judgement made from them was of the back of the skirt.
|
||||||
|
_cam.position = Vector3(0.0, hips - 0.08, -0.9)
|
||||||
|
_cam.look_at(Vector3(0, hips - 0.14, 0), Vector3.UP)
|
||||||
|
# Let the chains settle before recording — the first second is the model
|
||||||
|
# dropping into its hanging pose, which is not jitter.
|
||||||
|
if _frames > 130 and _shots < SHOTS:
|
||||||
|
root.get_texture().get_image().save_png("%s/idle_%02d.png" % [_out, _shots])
|
||||||
|
_shots += 1
|
||||||
|
if _shots == SHOTS:
|
||||||
|
print("saved %d idle frames" % SHOTS)
|
||||||
|
return true
|
||||||
|
return _frames > 400
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://6qrwda6ux54f
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://co25textiw4vq
|
||||||