`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.
Parity, from baselines taken before anything moved:
- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
(the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
summary
run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.
The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.
Archived, with reasons in tooling/archive/README.md:
- setup_clothing_metadata.py wrote coverage data for five garments that no
longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db
segment_reference_distribution.md moved to docs/assets/visual/.
Behaviour changes:
- The QA analyzer exited 0 whatever it found, though its own README says
clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
rewrote an unchanged file: it left them as orphans and then found them
again. Only nodes still linked into the graph count now, and a first pass
writes the same bytes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
16 KiB
GDScript
406 lines
16 KiB
GDScript
extends Node3D
|
|
## Chromakey garment-clipping QA capture scene (T-1089).
|
|
##
|
|
## 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/domains/character/GARMENT_QA.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://...", "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 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,
|
|
"average_f": CharacterVisualDescriptor.BodyType.AVERAGE_F,
|
|
"muscular_m": CharacterVisualDescriptor.BodyType.MUSCULAR_M,
|
|
"muscular_f": CharacterVisualDescriptor.BodyType.MUSCULAR_F,
|
|
"thin_m": CharacterVisualDescriptor.BodyType.THIN_M,
|
|
"thin_f": CharacterVisualDescriptor.BodyType.THIN_F,
|
|
"heavy_m": CharacterVisualDescriptor.BodyType.HEAVY_M,
|
|
"heavy_f": CharacterVisualDescriptor.BodyType.HEAVY_F,
|
|
"teen_m": CharacterVisualDescriptor.BodyType.TEEN_M,
|
|
"teen_f": CharacterVisualDescriptor.BodyType.TEEN_F,
|
|
"child": CharacterVisualDescriptor.BodyType.CHILD,
|
|
}
|
|
|
|
var _config: Dictionary = {}
|
|
var _out_dir: String = ""
|
|
var _cam: Camera3D = null
|
|
var _key_material: ShaderMaterial = null
|
|
var _shift_material: ShaderMaterial = null
|
|
|
|
|
|
func _ready() -> void:
|
|
if not _load_config():
|
|
get_tree().quit(1)
|
|
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()
|
|
|
|
|
|
func _load_config() -> bool:
|
|
var path := OS.get_environment("GARMENT_QA_CONFIG")
|
|
if path.is_empty():
|
|
push_error("chromakey_scene: GARMENT_QA_CONFIG env var not set")
|
|
return false
|
|
if not FileAccess.file_exists(path):
|
|
push_error("chromakey_scene: config file not found: %s" % path)
|
|
return false
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
push_error("chromakey_scene: could not open config: %s" % path)
|
|
return false
|
|
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
|
file.close()
|
|
if not (parsed is Dictionary):
|
|
push_error("chromakey_scene: config is not a JSON object: %s" % path)
|
|
return false
|
|
_config = parsed
|
|
_out_dir = str(_config.get("out_dir", ""))
|
|
if _out_dir.is_empty():
|
|
push_error("chromakey_scene: config missing 'out_dir'")
|
|
return false
|
|
return true
|
|
|
|
|
|
## 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 = (
|
|
"shader_type spatial;\n"
|
|
+ "render_mode unshaded, cull_disabled;\n"
|
|
+ "void fragment() {\n"
|
|
+ "\tALBEDO = vec3(1.0, 0.0, 1.0);\n"
|
|
+ "}\n"
|
|
)
|
|
var mat := ShaderMaterial.new()
|
|
mat.shader = shader
|
|
return mat
|
|
|
|
|
|
## Flat unshaded cyan garment with a DEPTH-ONLY bias of `shift` metres toward the camera
|
|
## (pass B). The screen x/y is kept exactly (clip_orig.xy) while the NDC depth is taken
|
|
## from the epsilon-nearer vertex — so the garment renders at its true silhouette but
|
|
## depth-tests as if `shift` closer. This biases depth WITHOUT the ~1.25% silhouette
|
|
## magnification a plain view-space translation would cause, so skin merely BESIDE a
|
|
## cloth edge is not falsely covered — only skin the garment actually projects over
|
|
## (i.e. cloth truly behind it) and within epsilon flips to cyan. Cull is left default
|
|
## so a far back panel (shifted epsilon closer, still far) 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"
|
|
+ "\tvec4 clip_orig = PROJECTION_MATRIX * view_pos;\n"
|
|
+ "\tvec4 view_near = view_pos;\n"
|
|
+ "\tview_near.z += shift;\n" # camera looks down -Z; +Z is toward the camera
|
|
+ "\tvec4 clip_near = PROJECTION_MATRIX * view_near;\n"
|
|
+ "\tfloat ndc_z_near = clip_near.z / clip_near.w;\n"
|
|
+ "\tPOSITION = vec4(clip_orig.x, clip_orig.y, ndc_z_near * clip_orig.w, clip_orig.w);\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)
|
|
light.light_energy = 1.2
|
|
add_child(light)
|
|
|
|
var env := Environment.new()
|
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
|
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 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
|
|
add_child(we)
|
|
|
|
_cam = Camera3D.new()
|
|
_cam.fov = 50.0
|
|
add_child(_cam)
|
|
# Fixed frame that fits a standing ~1.8 m character with margin. The character
|
|
# rotates (not the camera), so the four yaws give front/left/back/right views.
|
|
_cam.position = Vector3(0.0, 0.95, 2.4)
|
|
_cam.look_at_from_position(_cam.position, Vector3(0.0, 0.95, 0.0), Vector3.UP)
|
|
|
|
|
|
func _run() -> void:
|
|
DirAccess.make_dir_recursive_absolute(_out_dir)
|
|
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")
|
|
get_tree().quit(0)
|
|
|
|
|
|
func _capture_body(body_key: String) -> void:
|
|
if not BODY_KEY_TO_ENUM.has(body_key):
|
|
push_warning("chromakey_scene: unknown body_type '%s' — skipped" % body_key)
|
|
return
|
|
|
|
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()
|
|
if skel == null:
|
|
push_error("chromakey_scene: no skeleton for %s" % body_key)
|
|
cv.queue_free()
|
|
return
|
|
|
|
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=%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:
|
|
push_warning("chromakey_scene[%s]: no AnimationPlayer — skipping clips" % body_key)
|
|
cv.queue_free()
|
|
await _wait_frames(2)
|
|
return
|
|
|
|
var clips: Array = _config.get("clips", ["Walk"])
|
|
var frames: int = int(_config.get("frames_per_clip", 3))
|
|
var yaws: Array = _config.get("yaws", [0, 90, 180, 270])
|
|
|
|
for clip: String in clips:
|
|
var resolved := _resolve_clip(ap, str(clip))
|
|
if resolved.is_empty():
|
|
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; both passes share it
|
|
var length := ap.current_animation_length
|
|
var times := _sample_times(length, frames)
|
|
for fi in times.size():
|
|
ap.seek(times[fi], true)
|
|
await get_tree().process_frame # let the seeked pose propagate to the skeleton
|
|
for yaw in yaws:
|
|
cv.rotation_degrees.y = float(yaw)
|
|
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]
|
|
desc.head_id = str(_config.get("head_id", "head_001"))
|
|
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))
|
|
desc.clothing_slots = {} # garments attached manually in _attach_garments
|
|
return desc
|
|
|
|
|
|
## 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", []):
|
|
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(glb_path) as PackedScene
|
|
if scene == null:
|
|
push_error("chromakey_scene: garment GLB failed to load: %s" % glb_path)
|
|
continue
|
|
var inst := scene.instantiate()
|
|
var stack: Array[Node] = [inst]
|
|
while not stack.is_empty():
|
|
var n: Node = stack.pop_back()
|
|
for c in n.get_children():
|
|
stack.append(c)
|
|
if n is MeshInstance3D and (n as MeshInstance3D).skin != null:
|
|
var mi := n as MeshInstance3D
|
|
mi.get_parent().remove_child(mi)
|
|
mi.owner = null
|
|
skel.add_child(mi)
|
|
out_meshes.append(mi)
|
|
inst.queue_free()
|
|
|
|
|
|
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
|
|
## duplicates (the dark inverted-hull outline would otherwise poke through the garment
|
|
## and mask a clip as dark rather than magenta). Returns the number of keyed meshes.
|
|
func _apply_key(skel: Skeleton3D, covered: Dictionary) -> int:
|
|
var keyed := 0
|
|
var to_free: Array[Node] = []
|
|
for child in skel.get_children():
|
|
if not (child is MeshInstance3D):
|
|
continue
|
|
var mi := child as MeshInstance3D
|
|
if mi.name.begins_with("_outline_"):
|
|
to_free.append(mi) # strip ALL outlines — see docstring
|
|
continue
|
|
var seg := str(mi.get_meta("segment", ""))
|
|
if not seg.is_empty() and covered.has(seg):
|
|
mi.material_override = _key_material
|
|
keyed += 1
|
|
for n in to_free:
|
|
skel.remove_child(n)
|
|
n.free()
|
|
return keyed
|
|
|
|
|
|
## Resolve a logical clip name to a playable animation. Tries an exact match first,
|
|
## then a case-insensitive exact match, then a case-insensitive substring — robust
|
|
## to library prefixes. Returns "" if nothing matches.
|
|
func _resolve_clip(ap: AnimationPlayer, requested: String) -> String:
|
|
var names: Array[String] = []
|
|
for lib_name in ap.get_animation_library_list():
|
|
var lib := ap.get_animation_library(lib_name)
|
|
for a in lib.get_animation_list():
|
|
names.append(str(a))
|
|
for n in names:
|
|
if n == requested:
|
|
return n
|
|
for n in names:
|
|
if n.to_lower() == requested.to_lower():
|
|
return n
|
|
for n in names:
|
|
if n.to_lower().contains(requested.to_lower()):
|
|
return n
|
|
return ""
|
|
|
|
|
|
## Evenly spaced sample times across a clip, centred in each 1/count slice so we
|
|
## never land exactly on t=0 (rest pose) or t=length (loop wrap).
|
|
func _sample_times(length: float, count: int) -> Array[float]:
|
|
var out: Array[float] = []
|
|
if count <= 1 or length <= 0.0:
|
|
out.append(maxf(length, 0.0) * 0.5)
|
|
return out
|
|
for i in count:
|
|
out.append(length * (float(i) + 0.5) / float(count))
|
|
return out
|
|
|
|
|
|
func _wait_frames(n: int) -> void:
|
|
for i in n:
|
|
await get_tree().process_frame
|