From 1e6f3001ac685f8222cd5cca5d19f851ae820e1e Mon Sep 17 00:00:00 2001 From: Nicholas Butzke <137417822+DottsGit@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:00:42 -0400 Subject: [PATCH] docs(skill): the surface table, rig anchors, and asserting the consequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/character-pipeline/SKILL.md | 117 +++++++++++++++--- .../references/separation.md | 33 +++++ .../references/stylization.md | 41 ++++-- .../references/verification.md | 29 +++++ 4 files changed, 199 insertions(+), 21 deletions(-) diff --git a/.claude/skills/character-pipeline/SKILL.md b/.claude/skills/character-pipeline/SKILL.md index 7b427ad..b917528 100644 --- a/.claude/skills/character-pipeline/SKILL.md +++ b/.claude/skills/character-pipeline/SKILL.md @@ -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 | | 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 | `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 | +| Per-character judgement calls | `characters/tuning_store.gd` | Art direction not becoming another constant | + +## The surface table + +The sidecar carries a `surfaces` list saying what each mesh surface IS — `body`, +`cloth`, `hair`, `accessory`, or `linework` (the model's own ink shell, which is +not a surface of the character at all). It is keyed on the MATERIAL name, because +every character in this game arrives with its meshes called `Object_7` through +`Object_32` while material names survive the glTF round trip intact. + +`SkinSurfaces` reads it and `apply_character_look` acts on it: hair takes a much +thinner outline than the body, cloth a heavier one and a crisper terminator, +accessories the heaviest. `SkinnedPlayerModel.surfaces_of(cls)` answers the +question for anything else that needs it. + +Backfill a character that predates it, without re-importing: + +```bash +blender --background --python tools/surface_map.py -- \ + assets/characters/skins/.glb +``` + +New imports get it from `describe_rig`, built from the same chains the solver +uses, so the surface table and the cloth solver can never disagree about which +bones are a skirt. + +## Per-character judgement + +Anything derivable from the skeleton is derived. What is left is genuinely an +artist's call, and it lives in layered JSON rather than in a constant: + +| File | Scope | Class | +|---|---|---| +| `assets/characters/weapon_holds.json` | character + weapon | `WeaponHoldTuning` | +| `assets/characters/rig_anchors.json` | character | `RigAnchors` | + +Both layer `defaults` → `skins.._all` → `skins..` through +`TuningStore`. An absent file means "use what the code derives", so nothing here +is required for the game to run. Adding a knob is adding a row to a `KNOBS` spec +table — the lab builds its whole UI from those. + +`RigAnchors` is where "the grip sits here in the palm" lives. A hand bone's +origin is the WRIST; how far down the palm a grip belongs depends on the +character's hand and cannot be derived. It defaults to identity, and identity is +exactly the derived mount. Do **not** put a fixed rotation on the weapon mount +instead — a bone attachment is expressed in the BONE's axes, no two rigs agree on +those, and that constant is why the hand mount points were once wrong on every +character. + +## The rig lab + +```bash +godot --path . res://debug/rig_lab.tscn +``` + +Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD +(character + weapon) and the ANCHORS (character), and save. 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 @@ -129,13 +189,30 @@ is a list of things that cost a debugging cycle to learn. ## After importing ```bash -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/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 +godot --headless --path . -s res://debug/spawn_smoke_test.gd # 29 checks +godot --headless --path . -s res://debug/surface_class_check.gd # every surface classified +godot --headless --path . -s res://debug/character_picker_check.gd # the escape-menu roster +godot --headless --path . -s res://debug/rig_anchor_check.gd # anchors move the weapon +godot --headless --path . -s res://debug/cloth_clip_check.gd # leg-through-cloth +godot --headless --path . -s res://debug/cloth_settle_check.gd # idle stability +godot --headless --path . -s res://debug/cloth_perf_check.gd # ms per character +godot --headless --path . -s res://debug/travel_dir_check.gd # legs face travel ``` +And LOOK at it, which is where four of the last five real defects were found: + +```bash +godot --path . -s res://debug/roster_capture.gd -- # every character, one shot each +godot --path . -s res://debug/ui_capture.gd -- # every menu screen +godot --path . res://debug/rig_lab.tscn -- shot +``` + +`surface_class_check` fails on any surface that falls through to the heuristic +rather than resolving from the table. That is deliberate: a model whose names +stopped matching still RENDERS, because the fallback catches it — it just +quietly loses its per-class art direction, which is exactly the kind of +regression nothing else would report. + What "good" looks like on Taila, for calibration: | Measure | Good | Bad | @@ -149,15 +226,16 @@ What "good" looks like on Taila, for calibration: ## 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 | -| kiyoko | VRoid, CC-BY | 13 | 20 | -| aria | VRoid, CC-BY | 15 | 15 | -| momo | VRoid, CC-BY | 5 | 9 | -| hikari | VRoid, CC-BY | 13 | 10 *(zero-length — do not simulate)* | -| miku | unrigged source, auto-rigged | 1 | 19 *(hair grown)* | -| mannequin | Quaternius CC0, from the animation library | 1 | 0 *(no costume)* | +| 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 @@ -170,3 +248,14 @@ What "good" looks like on Taila, for calibration: - **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`. 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. diff --git a/.claude/skills/character-pipeline/references/separation.md b/.claude/skills/character-pipeline/references/separation.md index 77b85d7..d2a3bce 100644 --- a/.claude/skills/character-pipeline/references/separation.md +++ b/.claude/skills/character-pipeline/references/separation.md @@ -19,6 +19,7 @@ the convention): | 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 @@ -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 whole reason `.rig.json` exists. +Where there IS an artist in the loop, there is now somewhere to put the answer: +`debug/rig_lab.tscn` and the layered files behind it (see the SKILL). Measuring +is the default and hand-authoring is the override, rather than the other way +round. + +## Separation is only half of it — the parts have to be NAMED + +Keeping the meshes apart is structural. Knowing which is which is what lets +anything act on the difference, and until the surface table existed nothing did: +every surface of every character took one set of shading numbers, calibrated on +skin, because there was no way to ask whether a surface was hair. + +The table lives in the sidecar as `surfaces`, keyed on the MATERIAL name — mesh +node names are `Object_7` through `Object_32` on every character in this game and +carry no meaning, while material names survive the glTF round trip intact and are +what the artist actually chose. It is decided three ways, in descending order of +how much it trusts them: + +1. **the material name.** On VRoid exports it is formal — + `N00_000_00_Body_00_SKIN_Instance` carries its own class infix, and every + VRoid character here uses SKIN / FACE / EYE / HAIR / CLOTH. +2. **the weights.** Decisive when the name says nothing: a surface pulled by the + skirt chain is a skirt whatever it is called. The threshold is deliberately + low (5%), because VRoid welds the whole cap of the hair to the head bone and + springs only the strands — kiyoko's hair mesh is 85% head, and a majority rule + would call it skin. +3. **the material flags.** These catch line-work, which is the one class that is + not a surface of the character at all. + +It is built from the same chains the spring solver uses, so the two can never +disagree about which bones are a skirt. + ## Why the separation is load-bearing **Materials.** The body wants skin shading, hair wants an anisotropic-ish ramp diff --git a/.claude/skills/character-pipeline/references/stylization.md b/.claude/skills/character-pipeline/references/stylization.md index 178a216..8cb065e 100644 --- a/.claude/skills/character-pipeline/references/stylization.md +++ b/.claude/skills/character-pipeline/references/stylization.md @@ -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 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. `_is_line_work()` 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. +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: @@ -37,9 +44,29 @@ What it then does: 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, `_is_line_work()` and the name-matching -below it are where to look. The conventions vary by source and this is the one -place they are read. +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 diff --git a/.claude/skills/character-pipeline/references/verification.md b/.claude/skills/character-pipeline/references/verification.md index e316db4..7a3cf75 100644 --- a/.claude/skills/character-pipeline/references/verification.md +++ b/.claude/skills/character-pipeline/references/verification.md @@ -41,7 +41,36 @@ Two related traps: | `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 | | `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: