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/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://...", "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//.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