docs(skill): the surface table, rig anchors, and asserting the consequence
The skill is this project's own instructions, and three commits made it wrong:
it described a runtime that re-guessed what every surface was, a lab that only
tuned weapon holds, and a suite that could not see any of the last five defects.
It also still listed a seventh character that is not in skins.json.
Records what is new — the surface table and how it is decided, the layered
tuning files and why a fixed rotation on the weapon mount is the wrong answer,
the rig lab — and what the work taught:
* assert the CONSEQUENCE. Asking a model which clip it is playing reads a
variable it set on itself, and says "Idle" just as happily when nothing is
ticking. Asking whether an anchor saved says nothing about whether the gun
moved.
* four of the last five real defects came from looking at a PNG. Every one of
them passed every assertion.
* the line-work rule now exists twice, at build time and as the runtime
fallback, and they must be changed together or a model with a surface table
starts rendering differently from one without.
Also records momo's broken idle and the stray Icosphere under known-unsolved,
with what is worth suspecting first in each case, since neither is fixed.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
99a2ee131d
commit
1e6f3001ac
@@ -72,9 +72,69 @@ Blender is required (`BLENDER_PATH`, or auto-found under
|
|||||||
| Subdivide cloth panels | `retarget.py::subdivide_cloth_panels` | A skirt being able to bend 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 |
|
| 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 |
|
| 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 |
|
| Write the rig sidecar | `retarget.py::describe_rig` | The runtime never re-guessing anatomy |
|
||||||
| Cel look | `LevelMaterials.apply_character_look` | The model's own line-work not being re-lit |
|
| 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 |
|
| 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. Click a surface class
|
||||||
|
to isolate it — that is how the classifier gets checked: click `hair` and
|
||||||
|
anything else still standing was misclassified.
|
||||||
|
|
||||||
## Read before you touch anything
|
## Read before you touch anything
|
||||||
|
|
||||||
@@ -129,13 +189,30 @@ is a list of things that cost a debugging cycle to learn.
|
|||||||
## After importing
|
## After importing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks
|
godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks
|
||||||
godot --headless --path . -s res://debug/cloth_clip_check.gd # leg-through-cloth
|
godot --headless --path . -s res://debug/surface_class_check.gd # every surface classified
|
||||||
godot --headless --path . -s res://debug/cloth_settle_check.gd # idle stability
|
godot --headless --path . -s res://debug/character_picker_check.gd # the escape-menu roster
|
||||||
godot --headless --path . -s res://debug/cloth_perf_check.gd # ms per character
|
godot --headless --path . -s res://debug/rig_anchor_check.gd # anchors move the weapon
|
||||||
godot --headless --path . -s res://debug/travel_dir_check.gd # legs face travel
|
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:
|
What "good" looks like on Taila, for calibration:
|
||||||
|
|
||||||
| Measure | Good | Bad |
|
| Measure | Good | Bad |
|
||||||
@@ -149,15 +226,16 @@ What "good" looks like on Taila, for calibration:
|
|||||||
|
|
||||||
## Characters currently shipping
|
## Characters currently shipping
|
||||||
|
|
||||||
| Skin | Source | Meshes | Cloth chains |
|
The six in `skins.json`, with what `surface_class_check` reports:
|
||||||
|
|
||||||
|
| Skin | Source | Cloth chains | Surfaces |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| taila | rigged, Sketchfab CC-BY | 18 | 35 |
|
| taila | rigged, Sketchfab CC-BY | 35 | 18 — body 6, cloth 7, hair 1, linework 4 |
|
||||||
| kiyoko | VRoid, CC-BY | 13 | 20 |
|
| kiyoko | VRoid, CC-BY | 20 | 13 — body 8, cloth 3, hair 2 |
|
||||||
| aria | VRoid, CC-BY | 15 | 15 |
|
| aria | VRoid, CC-BY | 15 | 15 — body 9, cloth 4, hair 2 |
|
||||||
| momo | VRoid, CC-BY | 5 | 9 |
|
| momo | VRoid, CC-BY | 9 | 5 — body 2, cloth 1, hair 2 |
|
||||||
| hikari | VRoid, CC-BY | 13 | 10 *(zero-length — do not simulate)* |
|
| miku | unrigged source, auto-rigged | 0 | 4 — body 3, hair 1 *(one mesh, four surfaces)* |
|
||||||
| miku | unrigged source, auto-rigged | 1 | 19 *(hair grown)* |
|
| mannequin | Quaternius CC0, from the animation library | 0 | 2 — body 2 |
|
||||||
| mannequin | Quaternius CC0, from the animation library | 1 | 0 *(no costume)* |
|
|
||||||
|
|
||||||
## Known-unsolved
|
## Known-unsolved
|
||||||
|
|
||||||
@@ -170,3 +248,14 @@ What "good" looks like on Taila, for calibration:
|
|||||||
- **No strafe or backpedal clips.** Direction is conveyed by yawing the hips
|
- **No strafe or backpedal clips.** Direction is conveyed by yawing the hips
|
||||||
(`SkinnedPlayerModel._update_travel`), which is capped, so a pure sidestep
|
(`SkinnedPlayerModel._update_travel`), which is capped, so a pure sidestep
|
||||||
still runs its legs ~40° off the direction of travel.
|
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`. Her source rig is the reason to suspect —
|
||||||
|
its bones are auto-named (`Item_O_Sphere_005`, `Unused_Noname_004`,
|
||||||
|
`Root_001_001`), so the role resolver has almost nothing to go on, and a limb
|
||||||
|
role claimed by the wrong bone would look exactly like this. Start by dumping
|
||||||
|
her resolved roles against her skeleton.
|
||||||
|
- **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.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ the convention):
|
|||||||
| Cloth/hair bones carry NO animation keys; physics owns them | `export_optimize_animation_keep_anim_armature=False` |
|
| 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`) |
|
| 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 |
|
| 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` |
|
| 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
|
Where we differ: their collider capsules and cloth parameters are hand-authored
|
||||||
@@ -26,6 +27,38 @@ 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
|
geometry at build time, because there is no artist in this loop. That is the
|
||||||
whole reason `<model>.rig.json` exists.
|
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
|
## Why the separation is load-bearing
|
||||||
|
|
||||||
**Materials.** The body wants skin shading, hair wants an anisotropic-ish ramp
|
**Materials.** The body wants skin shading, hair wants an anisotropic-ish ramp
|
||||||
|
|||||||
@@ -23,10 +23,17 @@ rim term lights it brightly exactly where it is supposed to read as ink.
|
|||||||
it flat and unshaded. "Untextured" alone is NOT the test — that made every
|
it flat and unshaded. "Untextured" alone is NOT the test — that made every
|
||||||
flat-coloured model render as a black silhouette, because Quaternius' mannequin
|
flat-coloured model render as a black silhouette, because Quaternius' mannequin
|
||||||
has two untextured materials (a yellow body, lilac joints) and both were hidden
|
has two untextured materials (a yellow body, lilac joints) and both were hidden
|
||||||
as though they were an outline shell. `_is_line_work()` asks three things
|
as though they were an outline shell. The test asks three things instead: is it
|
||||||
instead: is it named `eyes*`, is it drawn front-face-culled (the classic
|
named `eyes*`, is it drawn front-face-culled (the classic inverted-hull setup),
|
||||||
inverted-hull setup), or is its albedo near-black. An ink shell is black; a
|
or is its albedo near-black. An ink shell is black; a flat-coloured character is
|
||||||
flat-coloured character is any colour at all.
|
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:
|
What it then does:
|
||||||
|
|
||||||
@@ -37,9 +44,29 @@ What it then does:
|
|||||||
with `HL` in the name, which is the glint in the pupil and really is white.
|
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,
|
If a newly imported character comes out with a white halo, a black silhouette,
|
||||||
or black eyes that should have irises, `_is_line_work()` and the name-matching
|
or black eyes that should have irises, the line-work test and the name-matching
|
||||||
below it are where to look. The conventions vary by source and this is the one
|
below it are where to look — now in `tools/surface_map.py`, mirrored by
|
||||||
place they are read.
|
`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
|
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
|
surfaces are untextured, and the glTF import hands every untextured surface a
|
||||||
|
|||||||
@@ -41,7 +41,36 @@ Two related traps:
|
|||||||
| `travel_dir_check.gd` | stride direction vs. travel direction | < 10° except a capped sidestep |
|
| `travel_dir_check.gd` | stride direction vs. travel direction | < 10° except a capped sidestep |
|
||||||
| `limb_deform_check.gd` | joint collapse | knee ~0.99 |
|
| `limb_deform_check.gd` | joint collapse | knee ~0.99 |
|
||||||
| `verify_character.py` | meshes, bones, weights of a SOURCE model | several meshes, cloth bones present |
|
| `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 |
|
||||||
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
| `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 | — |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- `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:
|
Run them:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user