Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
4 changed files with 199 additions and 21 deletions
Showing only changes of commit 1e6f3001ac - Show all commits
+103 -14
View File
@@ -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: