Files
Papay-Shooter/.claude/skills/character-pipeline/references/stylization.md
T
Nicholas ButzkeandClaude Opus 5 1e6f3001ac 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]>
2026-07-27 15:00:42 -04:00

5.9 KiB

Stylization — the cel-shaded look

Two passes, applied at load in SkinnedPlayerModel.load_model:

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.