feat(tooling): two-pass clip discriminator for garment QA (T-1089)

Second garment-only render pass per view at the identical paused animation
time; the analyzer intersects so body-key pixels split into exposed_skin
(no garment behind — informational: collars, sleeveless arms) vs
clip_through (garment behind — gating). Highlights differ: lime exposed,
red clip. Peasant re-run: 72 captures, 56 clip-through flags — real
collar micro-clips under crouch/walk plus suspected 1px boundary
artifacts; gate threshold + garment-mask dilation are the tuning knobs,
to be calibrated against the first real modern garments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:51:04 +02:00
co-authored by Claude Fable 5
parent eaca6c8c44
commit 74fa16260d
3 changed files with 274 additions and 121 deletions
+135 -55
View File
@@ -1,33 +1,49 @@
extends Node3D
## Chromakey garment-clipping QA capture scene (T-1089).
##
## Technique: replace the materials of exactly the body segments a garment CLAIMS
## to cover (coverage.json "hides") with a flat UNSHADED key color (pure magenta).
## The garment keeps its normal materials; head/hands/uncovered segments keep theirs.
## Any key-color pixel the analyzer finds through the garment = a clip-through.
## Two-pass depth-proximity technique per view, sampling the identical (paused)
## animation frame twice so the passes are pixel-aligned:
## Pass A (full) — the body segments a garment CLAIMS to cover (coverage.json
## "hides") are painted flat UNSHADED magenta; garment + head/hands
## keep normal materials. Magenta the camera sees = body in FRONT of
## the garment (or exposed beyond it).
## Pass B (shift) — identical, except the garment is rendered flat CYAN and every
## garment vertex is nudged CLIP_EPSILON metres toward the camera
## (a view-space Z bias). Where the body sits within epsilon in
## front of the cloth, the nudged garment now covers it → the pixel
## flips magenta→cyan.
##
## The analyzer flags a pixel as a REAL clip only if it is magenta in A AND cyan in B:
## body ≤ epsilon in front of cloth = poking through. A limb passing ~15 cm in front of
## the torso, or an open collar with the back panel far behind, stays magenta in B and
## is reported as exposed_skin (informational), not a clip. This is why "is there any
## garment behind the ray" is not enough — proximity is the whole signal.
##
## To hold exact references to the garment meshes (head-template + hair are skinned,
## unmeta'd, and structurally indistinguishable from garment meshes on the skeleton),
## the scene builds body+head+hair via CharacterVisual with NO clothing, then attaches
## the garment GLBs itself — mirroring character_visual.gd:517-535.
##
## This scene lives under client/ (not tooling/) because Godot res:// paths cannot
## leave the client project root — see tooling/garment-qa/README.md. It reuses
## CharacterVisual (client/scripts/rendering/character_visual.gd): load_descriptor()
## builds the character, get_active_coverage() exposes each garment's coverage.json,
## and get_animation_player() drives the clip cycling (mirrors the T-1088 sandbox).
##
## Config is a JSON file whose path arrives in the GARMENT_QA_CONFIG env var:
## leave the client project root — see tooling/garment-qa/README.md. Config is a JSON
## file whose path arrives in the GARMENT_QA_CONFIG env var:
## {
## "garments": [{"item_id": "peasant_tunic", "slot": "torso"}, ...]
## (or {"glb": "res://...", "slot": "...", "covers": ["torso", ...]}
## for a raw WIP garment not yet in the catalogue),
## (or {"glb": "res://...", "covers": ["torso", ...]} for a raw
## WIP garment not yet in the catalogue),
## "body_types": ["average_m", "average_f"],
## "clips": ["Walk", "Sprint", "Crouch_Fwd"],
## "frames_per_clip": 3,
## "yaws": [0, 90, 180, 270],
## "clip_epsilon_m": 0.03, # body-in-front-of-cloth tolerance (default 3 cm)
## "head_id": "head_001", "hair_id": "buzzed",
## "eyebrow_id": "regular", "skin_tone": 3,
## "out_dir": "/abs/path/for/pngs"
## }
const KEY_COLOR := Color(1.0, 0.0, 1.0) # pure magenta — no skin/garment texture lands here
const DEFAULT_EPSILON := 0.03 # metres — body within this far in front of cloth = clip
const VIEWPORT_SIZE := Vector2i(768, 1024) # portrait — maximises body pixels per capture
const CLOTHING_DIR := "res://assets/characters/clothing/"
const BODY_KEY_TO_ENUM := {
"average_m": CharacterVisualDescriptor.BodyType.AVERAGE_M,
@@ -47,6 +63,7 @@ var _config: Dictionary = {}
var _out_dir: String = ""
var _cam: Camera3D = null
var _key_material: ShaderMaterial = null
var _shift_material: ShaderMaterial = null
func _ready() -> void:
@@ -55,6 +72,7 @@ func _ready() -> void:
return
get_window().size = VIEWPORT_SIZE
_key_material = _make_key_material()
_shift_material = _make_shift_material(float(_config.get("clip_epsilon_m", DEFAULT_EPSILON)))
_setup_stage()
_run.call_deferred()
@@ -84,9 +102,8 @@ func _load_config() -> bool:
return true
## Flat, unshaded magenta. render_mode unshaded → ALBEDO is written straight to the
## framebuffer with no lighting; cull_disabled so a body backface poking through an
## opening still registers. No outline pass is attached to this material (see _apply_key).
## Flat unshaded magenta (pass A key). ALBEDO written straight to the framebuffer with
## no lighting; cull_disabled so a backface poking through an opening still registers.
func _make_key_material() -> ShaderMaterial:
var shader := Shader.new()
shader.code = (
@@ -101,6 +118,31 @@ func _make_key_material() -> ShaderMaterial:
return mat
## Flat unshaded cyan whose vertices are nudged `shift` metres toward the camera in
## view space (pass B). Cull is left default so only camera-facing cloth counts as the
## near layer — a far back panel shifted epsilon closer is still far and won't cover the
## body-key. Works on skinned garments: VERTEX arrives already skinned in model space.
func _make_shift_material(epsilon: float) -> ShaderMaterial:
var shader := Shader.new()
shader.code = (
"shader_type spatial;\n"
+ "render_mode unshaded;\n"
+ "uniform float shift = 0.03;\n"
+ "void vertex() {\n"
+ "\tvec4 view_pos = MODELVIEW_MATRIX * vec4(VERTEX, 1.0);\n"
+ "\tview_pos.z += shift;\n" # camera looks down -Z; +Z is toward the camera
+ "\tPOSITION = PROJECTION_MATRIX * view_pos;\n"
+ "}\n"
+ "void fragment() {\n"
+ "\tALBEDO = vec3(0.0, 1.0, 1.0);\n"
+ "}\n"
)
var mat := ShaderMaterial.new()
mat.shader = shader
mat.set_shader_parameter("shift", epsilon)
return mat
func _setup_stage() -> void:
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-45, 30, 0)
@@ -112,8 +154,8 @@ func _setup_stage() -> void:
env.ambient_light_color = Color(0.8, 0.8, 0.85)
env.ambient_light_energy = 0.9
env.background_mode = Environment.BG_COLOR
# Dark neutral background — never magenta, never skin-toned, so it cannot
# be mistaken for a clip pixel by the analyzer.
# Dark neutral background — never magenta, never cyan, never skin-toned, so it
# cannot be mistaken for a key or garment pixel by the analyzer.
env.background_color = Color(0.18, 0.19, 0.22)
var we := WorldEnvironment.new()
we.environment = env
@@ -133,6 +175,7 @@ func _run() -> void:
var body_types: Array = _config.get("body_types", ["average_m"])
print("chromakey_scene: out_dir = ", _out_dir)
print("chromakey_scene: body_types = ", body_types)
print("chromakey_scene: clip_epsilon_m = ", _config.get("clip_epsilon_m", DEFAULT_EPSILON))
for body_key: String in body_types:
await _capture_body(str(body_key))
print("chromakey_scene: DONE")
@@ -146,6 +189,8 @@ func _capture_body(body_key: String) -> void:
var cv := CharacterVisual.new()
add_child(cv)
# Build body + head + hair only; the garments are attached below so the scene
# holds exact references to garment geometry for the pass-B shift material.
cv.load_descriptor(_build_descriptor(body_key))
var skel := cv.get_skeleton()
@@ -154,13 +199,17 @@ func _capture_body(body_key: String) -> void:
cv.queue_free()
return
_attach_raw_garments(skel)
var covered := _covered_segments(cv)
var garments: Array[MeshInstance3D] = []
var covered := {}
_attach_garments(skel, body_key, garments, covered)
var keyed := _apply_key(skel, covered)
print(
"chromakey_scene[%s]: covered=%s keyed_meshes=%d" % [body_key, covered.keys(), keyed]
"chromakey_scene[%s]: covered=%s keyed=%d garment_meshes=%d"
% [body_key, covered.keys(), keyed, garments.size()]
)
if garments.is_empty():
push_warning("chromakey_scene[%s]: no garment meshes attached" % body_key)
var ap := cv.get_animation_player()
if ap == null:
@@ -179,7 +228,7 @@ func _capture_body(body_key: String) -> void:
push_warning("chromakey_scene[%s]: clip '%s' not found" % [body_key, clip])
continue
cv.play_animation(resolved)
ap.pause() # freeze — seek() below poses without advancing between yaw shots
ap.pause() # freeze — seek() below poses without advancing; both passes share it
var length := ap.current_animation_length
var times := _sample_times(length, frames)
for fi in times.size():
@@ -187,18 +236,36 @@ func _capture_body(body_key: String) -> void:
await get_tree().process_frame # let the seeked pose propagate to the skeleton
for yaw in yaws:
cv.rotation_degrees.y = float(yaw)
await RenderingServer.frame_post_draw
var img := get_viewport().get_texture().get_image()
var fname := (
"%s__%s__f%d__yaw%03d.png" % [body_key, str(clip), fi, int(yaw)]
)
img.save_png(_out_dir.path_join(fname))
var stem := "%s__%s__f%d__yaw%03d" % [body_key, str(clip), fi, int(yaw)]
await _capture_pair(garments, stem)
print("chromakey_scene[%s]: captured clip '%s' (%d frames)" % [body_key, clip, times.size()])
cv.queue_free()
await _wait_frames(2)
## Capture pass A (garment normal) then pass B (garment cyan + shifted) at the current,
## frozen pose. Only the garment material changes between passes — pixel-aligned.
func _capture_pair(garments: Array[MeshInstance3D], stem: String) -> void:
for g in garments:
g.material_override = null
await RenderingServer.frame_post_draw
_save(stem + ".png")
for g in garments:
g.material_override = _shift_material
await RenderingServer.frame_post_draw
_save(stem + "__shift.png")
for g in garments:
g.material_override = null
func _save(fname: String) -> void:
var img := get_viewport().get_texture().get_image()
img.save_png(_out_dir.path_join(fname))
func _build_descriptor(body_key: String) -> CharacterVisualDescriptor:
var desc := CharacterVisualDescriptor.new()
desc.body_type = BODY_KEY_TO_ENUM[body_key]
@@ -206,24 +273,37 @@ func _build_descriptor(body_key: String) -> CharacterVisualDescriptor:
desc.hair_id = str(_config.get("hair_id", "buzzed"))
desc.eyebrow_id = str(_config.get("eyebrow_id", "regular"))
desc.skin_tone = int(_config.get("skin_tone", 3))
var slots := {}
for g: Dictionary in _config.get("garments", []):
if g.has("item_id") and g.has("slot"):
slots[str(g["slot"])] = str(g["item_id"])
desc.clothing_slots = slots
desc.clothing_slots = {} # garments attached manually in _attach_garments
return desc
## Reparent skinned meshes from a raw garment GLB (config "glb" entries) onto our
## skeleton — mirrors character_visual.gd:517-535. Used only for WIP garments that
## are not yet catalogue items; catalogue garments load via clothing_slots instead.
func _attach_raw_garments(skel: Skeleton3D) -> void:
## Attach every configured garment's skinned meshes onto the skeleton (mirrors
## character_visual.gd:517-535) and accumulate the covered-segment set. Catalogue
## garments resolve to clothing/<item_id>/<body_key>.glb with hides read from the
## sibling coverage.json; raw garments carry a "glb" path + explicit "covers" list.
## Garments keep their embedded GLB materials (opaque, depth-writing) in pass A —
## geometry, not shading, is what clip detection needs.
func _attach_garments(
skel: Skeleton3D, body_key: String, out_meshes: Array[MeshInstance3D], out_covered: Dictionary
) -> void:
for g: Dictionary in _config.get("garments", []):
if not g.has("glb"):
var glb_path := ""
if g.has("item_id"):
glb_path = CLOTHING_DIR + "%s/%s.glb" % [str(g["item_id"]), body_key]
for seg in _read_coverage_hides(str(g["item_id"])):
out_covered[str(seg)] = true
elif g.has("glb"):
glb_path = str(g["glb"])
if g.has("covers"):
for seg in g["covers"]:
out_covered[str(seg)] = true
if glb_path.is_empty() or not ResourceLoader.exists(glb_path):
push_warning("chromakey_scene: garment GLB missing: %s" % glb_path)
continue
var scene := load(str(g["glb"])) as PackedScene
var scene := load(glb_path) as PackedScene
if scene == null:
push_error("chromakey_scene: raw garment GLB failed to load: %s" % g["glb"])
push_error("chromakey_scene: garment GLB failed to load: %s" % glb_path)
continue
var inst := scene.instantiate()
var stack: Array[Node] = [inst]
@@ -236,23 +316,23 @@ func _attach_raw_garments(skel: Skeleton3D) -> void:
mi.get_parent().remove_child(mi)
mi.owner = null
skel.add_child(mi)
out_meshes.append(mi)
inst.queue_free()
## Union of the body segments every equipped garment claims to cover. Catalogue
## garments report this via CharacterVisual.get_active_coverage() (coverage.json
## "hides"); raw garments carry an explicit "covers" list in the config.
func _covered_segments(cv: CharacterVisual) -> Dictionary:
var covered := {}
for g: Dictionary in _config.get("garments", []):
if g.has("item_id"):
var cov: Dictionary = cv.get_active_coverage(str(g["item_id"]))
for seg in cov.get("hides", []):
covered[str(seg)] = true
if g.has("covers"):
for seg in g["covers"]:
covered[str(seg)] = true
return covered
func _read_coverage_hides(item_id: String) -> Array:
var path := CLOTHING_DIR + "%s/coverage.json" % item_id
if not FileAccess.file_exists(path):
push_warning("chromakey_scene: coverage.json missing for %s" % item_id)
return []
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return []
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if parsed is Dictionary and (parsed as Dictionary).has("hides"):
return (parsed as Dictionary)["hides"]
return []
## Key-colour every loaded body segment in the covered set and delete the outline