extends SceneTree ## Fixed simulation step — see _lock_timestep. const STEP := 1.0 / 60.0 ## Dev tool: is the thigh THROUGH the skirt, or just showing past its edge? ## ## godot --path . --windowed --resolution 900x900 \ ## -s res://debug/skirt_clip_view.gd -- [skin] ## ## debug/skirt_closeup.gd renders the character as it ships, and at that point ## the two failures look identical: a wedge of thigh against dark cloth reads the ## same whether the leg is in front of a panel or simply visible between two of ## them. Both were guessed at, in both directions, before this existed. ## ## So: every cloth surface is painted flat MAGENTA and the body flat GREY, ## unshaded, no outline. ## ## Alongside each pair it writes `hip_N.txt`: the screen row of the hip joint. ## tools/measure_clipview.py counts only BELOW that line, because the torso is ## legitimately in front of the skirt's waistband and counting it reported 5% of ## the cloth covered on a pose that is actually clean. Colouring the legs ## separately does not work — the bare thigh is part of the body mesh, and a ## per-surface colour caught only the boots. Cloth is then a solid silhouette, and the question ## has one answer — any grey inside the magenta is the leg in front of the skirt, ## and grey outside it is just the leg past the hem, which is correct. ## ## Each shot is saved twice: `clipview_N` with the whole character, and ## `cloth_N` with the body hidden. A grey wedge that is a HOLE in the cloth-only ## image is the leg showing between two panels that have drifted apart, which is ## a different bug with a different fix; a grey wedge over solid magenta is the ## leg in front of the cloth. ## ## Cloth is identified from the skin itself (a surface whose vertices are mostly ## driven by the sidecar's cloth chains), not by material or surface name. const SHOT_EVERY := 3 ## Frames to let every exponential smoother settle before measuring anything. const WARMUP := 120 ## Shots taken. A whole run cycle rather than a handful of samples of it: ## the capture is not locked to the simulation step, so a few samples land on ## a different phase every run and the total swings 2-3x. Averaged over the ## full cycle that phase noise cancels and the number is comparable again. const SHOTS := 40 ## The movement states the skirt has to survive, one per shot pair. Running is ## where the defect was first seen, but a jump and a slide put the thigh through ## the front of the skirt in poses a run never reaches. const STATES := [["ground", 9.0], ["ground", 3.0], ["air", 6.0], ["air", -8.0], ["slide", 10.0], ["dash", 14.0], ["ground", 9.0], ["air", 6.0]] var _frames := 0 var _out := "." var _model: SkinnedPlayerModel = null var _cam: Camera3D = null var _shots := 0 var _painted := false ## [MeshInstance3D, surface, body material] for every non-cloth surface, so the ## body can be blanked for the cloth-only frame and put back. var _body: Array = [] var _blank: Material = null var _grey: Material = null func _initialize() -> void: var args := OS.get_cmdline_user_args() _out = args[0] if args.size() > 0 else "." var path := "res://assets/characters/skins/taila.glb" if args.size() > 1: path = "res://assets/characters/skins/%s.glb" % args[1] var scene := Node3D.new() root.add_child(scene) current_scene = scene var env := WorldEnvironment.new() var e := Environment.new() e.background_mode = Environment.BG_COLOR e.background_color = Color(0.05, 0.05, 0.08) e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR e.ambient_light_color = Color(1, 1, 1) e.ambient_light_energy = 1.0 env.environment = e scene.add_child(env) _model = SkinnedPlayerModel.new() _model.model_path = path scene.add_child(_model) _cam = Camera3D.new() _cam.fov = 30.0 scene.add_child(_cam) _cam.current = true ## Flat unshaded colours: magenta for anything the cloth chains drive, grey for ## the rest. Overrides, so the character's own toon materials are untouched. func _paint(skel: Skeleton3D) -> void: var cloth := {} var side: String = _model.model_path.get_basename() + ".rig.json" var info = JSON.parse_string(FileAccess.get_file_as_string(side)) if typeof(info) == TYPE_DICTIONARY: for c in info.get("chains", []): for n in c.get("bones", []): var i := skel.find_bone(String(n)) if i >= 0: cloth[i] = true # Which bones are LIMBS, so their surfaces can be told from the torso. var limb := {} if typeof(info) == TYPE_DICTIONARY: for c in info.get("colliders", []): for key in ["bone", "child"]: var bn := String(c.get(key, "")) for b in skel.get_bone_count(): var n := skel.get_bone_name(b) if n == bn or n.begins_with(bn + "."): limb[b] = true # DEPTH, written into the colour channel — not a flat tag colour. # # A silhouette test cannot answer this question. With the legs apart you see # the FAR side of the skirt through the gap between them, the thigh is # correctly in front of that, and a mask test counts every one of those # pixels: it reported 25% of the cloth covered on poses that are fine, and # sent two rounds of tuning after a defect that was not there. Comparing # distances instead, a leg only counts when it is nearer than the NEAREST # CLOTH at that pixel, which is exactly what "the thigh is showing through # the skirt" means. # # 0 stays "no geometry here", so real depths start just above it. var sh := Shader.new() # The cloth pass culls BACK FACES, so only the NEAR surface of the garment is # drawn. Without that the far side of the skirt — what you see through the gap # between the legs — is in the mask too, the thigh is correctly in front of it, # and every one of those pixels reads as a defect. sh.code = "shader_type spatial; " + "render_mode unshaded, cull_back; " + "varying float view_z; " + "void vertex() { view_z = -(MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).z; } " + "void fragment() { " + " float d = clamp((view_z - 0.4) / 1.6, 0.0, 0.96); " + " ALBEDO = vec3(0.02 + d); " + "} " var mag := ShaderMaterial.new() mag.shader = sh var sh_body := Shader.new() sh_body.code = sh.code.replace("cull_back", "cull_disabled") var body_mat := ShaderMaterial.new() body_mat.shader = sh_body var cyan := body_mat _grey = body_mat var grey := body_mat for mi in _model.find_children("*", "MeshInstance3D", true, false): if mi.mesh == null: continue if mi.skin == null: # The model's own outline shell has no skin and would hide # everything behind it. mi.visible = false continue var skin: Skin = mi.skin var bone_of := {} for b in skin.get_bind_count(): var bi := skin.get_bind_bone(b) if bi < 0: bi = skel.find_bone(skin.get_bind_name(b)) bone_of[b] = bi for s in range(mi.mesh.get_surface_count()): var arrays: Array = mi.mesh.surface_get_arrays(s) var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES] var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS] var is_cloth := false var is_limb := false if not bones.is_empty() and not verts.is_empty(): var per: int = bones.size() / verts.size() var n := 0 var legn := 0 for v in verts.size(): # The SUM of the cloth chains' share, not the single # heaviest bone. tools/retarget.py's bind_cloth_to_legs # hands cloth vertices resting on a thigh most of their # weight, so on the panels that matter here the dominant # bone is the LEG and a heaviest-bone test calls the whole # skirt body. var w := 0.0 for k in per: if cloth.has(bone_of[bones[v * per + k]]): w += weights[v * per + k] if w > 0.25: n += 1 var lw := 0.0 for k in per: if limb.has(bone_of[bones[v * per + k]]): lw += weights[v * per + k] if lw > 0.5: legn += 1 is_cloth = n * 4 > verts.size() is_limb = not is_cloth and legn * 2 > verts.size() var mat: Material = grey if is_cloth: mat = mag elif is_limb: mat = cyan mi.set_surface_override_material(s, mat) if not is_cloth: _body.append([mi, s, mat]) print(" %s/%d %s" % [mi.name, s, "CLOTH" if is_cloth else ("LIMB" if is_limb else "body")]) _blank = StandardMaterial3D.new() _blank.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _blank.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA _blank.albedo_color = Color(0, 0, 0, 0) _blank.no_depth_test = false _blank.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED print("painted %d cloth bones' surfaces magenta" % cloth.size()) ## Screen row of the hip joint, so the measurement can ignore the torso. func _write_hip_row() -> void: var skel: Skeleton3D = _model.skeleton var h := skel.find_bone("DEF-thigh.L") if h < 0: h = skel.find_bone("DEF-spine") if h < 0: return var world: Vector3 = skel.global_transform * skel.get_bone_global_pose(h).origin var f := FileAccess.open("%s/hip_%d.txt" % [_out, _shots], FileAccess.WRITE) if f: f.store_string("%d" % int(_cam.unproject_position(world).y)) f.close() ## Drive the animation and the solver on a FIXED timestep. ## ## Both advance on the real frame delta otherwise, so the pose at a given frame ## drifts between runs and the same build measured 146k and 398k offending ## pixels. Every A/B comparison made without this was noise, and several tuning ## decisions were taken on the strength of it. ## ## The modifier stack needs pinning too: on PHYSICS it runs a variable number ## of times per rendered frame, so with a fixed step the amount of simulated ## time per frame still wandered. func _lock_timestep() -> void: var skel: Skeleton3D = _model.skeleton if skel: skel.modifier_callback_mode_process = \ Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE var spring := skel.get_node_or_null("SpringBones") if spring: spring.fixed_delta = STEP for n in _model.find_children("*", "AnimationTree", true, false): n.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL ## One fixed step of the animation. Call once per rendered frame. func _step_anim() -> void: for n in _model.find_children("*", "AnimationTree", true, false): n.advance(STEP) func _process(_delta: float) -> bool: _frames += 1 if _frames < 10 or not _model.loaded: return false if not _painted: if _model.skeleton == null: return true # Fixed timestep, or nothing here is repeatable: the solver integrates # against the real frame delta, so the same build measured 93k and 76k # offending pixels on consecutive runs and every A/B comparison was noise. _lock_timestep() _paint(_model.skeleton) _painted = true return false var st: Array = STATES[clampi(_shots * STATES.size() / SHOTS, 0, STATES.size() - 1)] _model.update_state(st[0], st[1], false) _model.set_locomotion(0.0, 1.0, 0.0) _step_anim() var hips := 0.95 if _model.skeleton: var h := _model.skeleton.find_bone("DEF-spine") if h >= 0: hips = _model.skeleton.get_bone_global_pose(h).origin.y # Level with the hem and dead in front: the view a player actually gets. # Long warm-up before the first shot. The pose layer's lean/hold smoothers # still run on the real frame delta and only converge exponentially, so # sampling early made the run irreproducible however tightly the solver and # the animation were pinned. if _frames > WARMUP and _shots < SHOTS: var phase := _frames % SHOT_EVERY if phase == 0: # NEGATIVE Z is the FRONT. SkinnedPlayerModel spins the imported scene 180 # degrees (`facing_flip`: glTF forward is +Z, players face -Z), so a camera # on +Z looks at the character's BACK. Every tool in here used to sit on +Z, # and every "front" judgement made from them was of the back of the skirt. _cam.position = Vector3(0.0, hips - 0.10, -0.95) _cam.look_at(Vector3(0, hips - 0.14, 0), Vector3.UP) elif phase == 1: root.get_texture().get_image().save_png( "%s/clipview_%d.png" % [_out, _shots]) for e in _body: e[0].set_surface_override_material(e[1], _blank) elif phase == 2: # Same pose, one frame later — near enough to read the coverage. root.get_texture().get_image().save_png( "%s/cloth_%d.png" % [_out, _shots]) _write_hip_row() for e in _body: e[0].set_surface_override_material(e[1], e[2]) print("saved clipview_%d (%s %.0f) + cloth_%d" % [ _shots, st[0], st[1], _shots]) _shots += 1 if _shots >= SHOTS or _frames > WARMUP + SHOTS * SHOT_EVERY + 40: return true return false