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]>
113 lines
5.9 KiB
Markdown
113 lines
5.9 KiB
Markdown
# 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`.
|