feat(characters): say what every surface IS, and light it accordingly
A character has arrived as separate body, garment and hair meshes since the
pipeline stopped joining them — but nothing recorded which was which, so every
system downstream re-guessed from the material. That guess ("untextured and
nearly black means ink") had already rendered the mannequin's flat yellow body
as a black silhouette once.
The question is answerable once, at build time, where the mesh, the weights and
the skeleton are all in hand. tools/surface_map.py answers it three ways, in
order of how much it trusts them: the material name, which on VRoid exports is
formal and on hand-authored models is still explicit; the weights, which are
decisive when the name says nothing — a surface pulled by the skirt chain is a
skirt whatever it is called; and the material flags, which catch the model's own
line-work. The answer goes in the rig sidecar next to the roles and the chains,
and SkinSurfaces reads it.
All eighteen of Taila's surfaces, and every surface of the other five skins,
now resolve from the table with nothing falling through to the heuristic
(debug/surface_class_check.gd). The heuristic stays as the fallback, which is
the one job it was ever right for.
What that buys immediately is per-class art direction, which was impossible
while every surface had to take numbers calibrated on skin. Hair takes a much
thinner line — at the body's 5 mm each strand's hull swallows its neighbour and
the head reads as a solid dark cap. Cloth takes a heavier line and a crisper
terminator, because a garment's silhouette is most of what separates a character
from the background at range. Accessories take the heaviest. `body` is unchanged
on purpose, so the look this was all calibrated against does not move.
That required moving the outline from the instance to the surface: Miku's body,
face and hair are three surfaces of ONE mesh, so an instance-wide overlay could
only ever give all three the same weight.
Two things found on the way, fixed here because they are one line each: the
surface classifier skips meshes with no vertex groups, which drops the stray
42-vertex Icosphere that rides inside every shipped skin — two older tools
already skipped it by spelling its name — and load_model now clears _rig_info,
which a model with no skeleton used to inherit from the last character loaded.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
33d07b3717
commit
53f175ed6d
@@ -0,0 +1,116 @@
|
||||
extends RefCounted
|
||||
class_name SkinSurfaces
|
||||
|
||||
## What every surface of a character IS — body, cloth, hair or accessory.
|
||||
##
|
||||
## Written at build time by tools/surface_map.py into `<skin>.rig.json`, read
|
||||
## here. Nothing at runtime re-derives it, which is the point: the question has
|
||||
## one right answer per model and it is knowable in Blender, where the mesh, the
|
||||
## weights and the skeleton are all in hand. Asking it again from a material at
|
||||
## load time is how the mannequin's flat yellow body came to be rendered as a
|
||||
## black silhouette — "untextured" is not the same question as "is ink".
|
||||
##
|
||||
## The heuristic that mistake came from is still here, as `guess()`, and still
|
||||
## earns its place: it is the fallback for a model imported before the surface
|
||||
## table existed, or one whose material genuinely says nothing. But it is now
|
||||
## the last resort rather than the only source.
|
||||
|
||||
const BODY := "body"
|
||||
const CLOTH := "cloth"
|
||||
const HAIR := "hair"
|
||||
const ACCESSORY := "accessory"
|
||||
const LINEWORK := "linework"
|
||||
|
||||
## Same threshold as tools/surface_map.INK_LEVEL. The two must agree, or a model
|
||||
## with a surface table would render differently from one without.
|
||||
const INK_LEVEL := 0.18
|
||||
|
||||
var _by_slot: Dictionary = {} # "mesh|index" -> record
|
||||
var _by_material: Dictionary = {} # material name -> record
|
||||
## Materials used by two surfaces the classifier disagreed about. Taila reuses
|
||||
## `ClothA` on three meshes and `FullBlack` on three more; those agree, so they
|
||||
## stay usable. One that did not would silently give whichever surface was read
|
||||
## first, so it is dropped from the material index instead and falls back to the
|
||||
## slot key.
|
||||
var _material_conflict: Dictionary = {}
|
||||
|
||||
|
||||
static func from_rig_info(rig_info: Dictionary) -> SkinSurfaces:
|
||||
var out := SkinSurfaces.new()
|
||||
for record in rig_info.get("surfaces", []):
|
||||
if not record is Dictionary:
|
||||
continue
|
||||
out._by_slot["%s|%d" % [record.get("mesh", ""),
|
||||
int(record.get("surface", 0))]] = record
|
||||
var mat: String = record.get("material", "")
|
||||
if mat == "":
|
||||
continue
|
||||
var seen = out._by_material.get(mat)
|
||||
if seen != null and seen.get("class", "") != record.get("class", ""):
|
||||
out._material_conflict[mat] = true
|
||||
else:
|
||||
out._by_material[mat] = record
|
||||
return out
|
||||
|
||||
|
||||
func is_empty() -> bool:
|
||||
return _by_slot.is_empty()
|
||||
|
||||
|
||||
## The build-time record for one surface, or an empty Dictionary.
|
||||
##
|
||||
## The mesh node name and surface index are tried first because they name
|
||||
## exactly one surface. The material name is the fallback because it is what
|
||||
## survives best — every character in this game arrives with its meshes called
|
||||
## `Object_7` through `Object_32`, and a renamed node would take the slot key
|
||||
## with it while `ClothB` stays `ClothB`.
|
||||
func lookup(mesh_name: String, surface_index: int, material_name: String) -> Dictionary:
|
||||
var by_slot = _by_slot.get("%s|%d" % [mesh_name, surface_index])
|
||||
if by_slot != null:
|
||||
return by_slot
|
||||
if material_name != "" and not _material_conflict.has(material_name):
|
||||
var by_mat = _by_material.get(material_name)
|
||||
if by_mat != null:
|
||||
return by_mat
|
||||
return {}
|
||||
|
||||
|
||||
## (class, detail) for a surface with no build-time record.
|
||||
##
|
||||
## This is the pre-surface-table heuristic, kept verbatim for the one job it is
|
||||
## still right for. It answers a narrow question — is this untextured surface
|
||||
## part of the model's own DRAWING? — and it answers it from the three things
|
||||
## that actually distinguish line-work: being black, being drawn inside-out, or
|
||||
## saying outright that it is an eye card. Anything else is treated as body,
|
||||
## which is the safe answer because body is ordinary character shading.
|
||||
static func guess(mat: BaseMaterial3D) -> Array:
|
||||
if mat == null:
|
||||
return [BODY, "skin"]
|
||||
var name := mat.resource_name.to_lower()
|
||||
if mat.albedo_texture == null:
|
||||
if name.begins_with("eyes"):
|
||||
return [BODY, "eyes_highlight" if name.contains("hl") else "eyes_ink"]
|
||||
if mat.cull_mode == BaseMaterial3D.CULL_FRONT:
|
||||
return [LINEWORK, "outline_hull"]
|
||||
var c: Color = mat.albedo_color
|
||||
if maxf(maxf(c.r, c.g), c.b) < INK_LEVEL:
|
||||
return [LINEWORK, "outline_hull"]
|
||||
return [BODY, "skin"]
|
||||
|
||||
|
||||
## (class, detail) for a surface, from the table where it has an entry and from
|
||||
## the heuristic where it does not.
|
||||
##
|
||||
## The cull-mode test is re-run even when the table HAS an entry, because that
|
||||
## is the one piece of evidence the build side cannot see: glTF has no way to
|
||||
## say "draw only the backfaces", so an inverted-hull outline arrives in Blender
|
||||
## indistinguishable from an ordinary surface and only shows itself here.
|
||||
func resolve(mesh_name: String, surface_index: int, mat: BaseMaterial3D) -> Array:
|
||||
var material_name := "" if mat == null else mat.resource_name
|
||||
if mat != null and mat.albedo_texture == null \
|
||||
and mat.cull_mode == BaseMaterial3D.CULL_FRONT:
|
||||
return [LINEWORK, "outline_hull"]
|
||||
var record := lookup(mesh_name, surface_index, material_name)
|
||||
if record.is_empty():
|
||||
return guess(mat)
|
||||
return [record.get("class", BODY), record.get("detail", "")]
|
||||
@@ -0,0 +1 @@
|
||||
uid://diqctdcqixjqg
|
||||
@@ -92,6 +92,9 @@ var _joint_helpers: Array = []
|
||||
## and leg colliders, written by tools/retarget.py. Empty for a model that was
|
||||
## rebound onto the library skeleton instead of keeping its own rig.
|
||||
var _rig_info: Dictionary = {}
|
||||
## What each surface of this model IS — body, cloth, hair, accessory. Read from
|
||||
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
|
||||
var _surfaces: SkinSurfaces = null
|
||||
var _spring_mod: SpringBones
|
||||
var is_holding_weapon: bool = false
|
||||
## Which skin this is, so per-character hold tuning can be looked up. Set by
|
||||
@@ -183,6 +186,11 @@ func load_model(path: String) -> void:
|
||||
_resolved_clips.clear()
|
||||
_current_clip = ""
|
||||
_weapon_attachment = null
|
||||
# Cleared, not left standing: a model with no skeleton never reaches the
|
||||
# branch that reloads these, and would otherwise be described by the LAST
|
||||
# character's sidecar.
|
||||
_rig_info = {}
|
||||
_surfaces = null
|
||||
|
||||
var scene := GLBLoader.load(path)
|
||||
if not scene:
|
||||
@@ -240,13 +248,16 @@ func load_model(path: String) -> void:
|
||||
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
|
||||
% [path.get_file(), driven])
|
||||
|
||||
# Cel-shaded look: toon shading over the imported textures + ink outline.
|
||||
# Cel-shaded look: toon shading over the imported textures...
|
||||
LevelMaterials.apply_toon_recursive(scene)
|
||||
# ...then the character-only pass: flat line-work + softer banding (see the
|
||||
# function — toon-lighting the model's own outline shell is what put a white
|
||||
# rim on every hair strand, and re-banding already-shaded textures read as
|
||||
# gloss).
|
||||
LevelMaterials.apply_character_look(scene)
|
||||
# ...then the character-only pass, which is where the surface table earns
|
||||
# its keep: it says which surfaces are hair, cloth, body or an accessory, so
|
||||
# each can take its own outline weight and banding instead of all of them
|
||||
# taking numbers calibrated on skin. It also identifies the model's own
|
||||
# line-work by name and weight rather than by "is it nearly black", which is
|
||||
# what used to render a flat-coloured model as a black silhouette.
|
||||
_surfaces = SkinSurfaces.from_rig_info(_rig_info)
|
||||
LevelMaterials.apply_character_look(scene, _surfaces)
|
||||
if animation_player:
|
||||
_index_animations()
|
||||
_setup_anim_tree(scene)
|
||||
@@ -375,6 +386,33 @@ func _load_rig_info(model_path: String) -> Dictionary:
|
||||
return parsed
|
||||
|
||||
|
||||
## Every [mesh, surface index] of this model belonging to one surface class —
|
||||
## SkinSurfaces.BODY, CLOTH, HAIR or ACCESSORY.
|
||||
##
|
||||
## The point of separating a character into a body, garments and hair is that
|
||||
## the game can then treat them differently, and it can only do that if it can
|
||||
## ask which is which. This is that question. It is used by the rig lab to let
|
||||
## an artist isolate a class, and it is what a damage flash on skin only, or a
|
||||
## hidden hat, or a per-class LOD would be built on.
|
||||
func surfaces_of(surface_class: String) -> Array:
|
||||
var out: Array = []
|
||||
if _surfaces == null:
|
||||
return out
|
||||
for mi in find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh == null:
|
||||
continue
|
||||
for s in mi.mesh.get_surface_count():
|
||||
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
|
||||
if _surfaces.resolve(mi.name, s, src)[0] == surface_class:
|
||||
out.append([mi, s])
|
||||
return out
|
||||
|
||||
|
||||
## The surface table, for tools that want to show or edit it. May be null.
|
||||
func surface_table() -> SkinSurfaces:
|
||||
return _surfaces
|
||||
|
||||
|
||||
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
|
||||
## A correctly-exported GLB binds automatically, but if one imports with a skin
|
||||
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
|
||||
|
||||
Reference in New Issue
Block a user