extends Object class_name SkinLegRepair ## Keeps the HUMANOID rigid while letting the CLOTHES flow. ## ## That split is the whole design rule here, and it decides what every pass below ## is allowed to touch: ## ## * LIMB vertices — anything hugging a leg's own bone chain: skin, stockings, ## boots — are cleaned up hard. They belong to one leg, they follow it, and ## they hold their shape. Deforming here is a bug. ## * DRAPE vertices — the skirt and anything else hanging clear of both leg ## chains — are LEFT ALONE, so they keep swinging with the body. ## ## DO NOT stiffen the skirt onto the hips. It was tried (to stop it flattening at ## a wide stride) and reverted: it makes the clothes read as a rigid shell, which ## is worse than the flattening it fixed. Cloth is supposed to move. ## ## Current split, from debug/limb_deform_check.gd: the body holds 0.98 of its ## cross-section at the knee, while the skirt is free at 0.88 — rigid limbs, ## moving cloth. ## ## ## Stops below-the-knee geometry being dragged by BOTH legs at once. ## ## Taila's boots are skinned with weights that bleed across the centre line: ## 262 vertices in the boot/cuff surface (plus 208 in the model's outline shell ## and 176 in the body) carry weight from the left AND right leg, the worst at a ## dead-even 49/51 split. A vertex pulled equally by both feet sits halfway ## between them and stays there while the legs separate, stretching every ## triangle around it. That is the "ankle cuffs are linked" stretching, and the ## elongated boot that reads as the legs being squashed. ## ## Everything else about the rig is fine, which is why this took so long to ## find. Measured through the full runtime stack during a run: no bone's pose ## basis deviates from a pure rotation by more than 0.00001, no bone's length ## drifts from its rest offset by more than 0.0000 m, and no below-knee vertex ## is influenced by any non-leg bone. The skeleton is correct; the weights are ## not. Freezing the AnimationTree at the rest pose renders the boots perfectly, ## which is what proves it is a skinning problem rather than a pose one. ## ## Three steps: ## ## 1. BELOW THE KNEE (height taken from the skeleton's own rest pose, so this ## scales to any character): snap each vertex to the leg that already ## dominates it and renormalise, so nothing is pulled in two directions. ## 2. ABOVE THE KNEE, only for vertices that are part of a LEG rather than ## draping cloth: same snap, to the nearer leg. 122 vertices on Taila's ## thigh mesh carry up to 35% of the opposite leg — that is the squashing ## that survived the below-knee pass and showed up when running and ## jumping split the legs. Limb membership is decided by distance to the ## leg's own bone chain, NOT by height or by surface name: a thigh vertex ## hugs its bone, while a skirt vertex hangs well clear of both and is ## left blended, which is what lets a skirt drape across both legs. ## 3. Cap how much the TORSO owns a leg vertex. The top of the thigh is ## weighted between the hips and the thigh; at a wide stride, linear-blend ## skinning averages the near-static hips against a thigh swung 60 degrees ## out, and the top of the leg flattens into a wedge. That is the hip ## collapse visible from the side while running and jumping, and it is the ## worst number the measurement reports (cross-section 0.85 at the hip ## against 0.97-0.99 at the knee). Leg vertices keep at most MAX_TORSO of ## hips/spine influence, and the excess goes to the leg bone that already ## dominates them, so the thigh follows its own bone. ## 4. Drop any triangle still spanning the two legs below the knee. Those are ## the midline band between the ankles, which has no correct pose either ## way. ## ## Measure with debug/limb_deform_check.gd. const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"] ## Bones that belong to the torso, not the leg. const TORSO_BONE_HINTS := ["hips", "spine", "pelvis"] ## The most torso influence a leg vertex may keep. Some is wanted — it is what ## rounds the hip off — but past this the thigh stops following its own bone. const MAX_TORSO := 0.15 ## Ignore influences below this — they are rounding, not real weighting. const EPSILON := 0.005 ## Returns [vertices_snapped, triangles_removed] so callers can log the result. static func repair(root: Node, skeleton: Skeleton3D) -> Array: var knee := _knee_height(skeleton) if is_nan(knee): return [0, 0] var chain_l := _leg_chain(skeleton, ".L") var chain_r := _leg_chain(skeleton, ".R") if chain_l.is_empty() or chain_r.is_empty(): return [0, 0] # "Part of a leg" means within roughly half the gap between the two legs of # that leg's bone chain — self-scaling to the character's proportions. var limb_radius: float = maxf(absf(chain_l[0].x - chain_r[0].x) * 0.5, 0.02) var snapped_total := 0 var removed_total := 0 for mi in root.find_children("*", "MeshInstance3D", true, false): if mi.mesh == null or mi.skin == null: continue # Rebuilding a mesh drops blend shapes, so a skin that uses them (a face # rig) is left alone rather than silently losing its expressions. if mi.mesh.get_blend_shape_count() > 0: continue var r := _repair_mesh(mi, skeleton, knee, chain_l, chain_r, limb_radius) snapped_total += r[0] removed_total += r[1] return [snapped_total, removed_total] ## Rest-pose joint positions down one leg, used as a polyline to measure how ## close a vertex sits to that limb. static func _leg_chain(skeleton: Skeleton3D, suffix: String) -> PackedVector3Array: var out := PackedVector3Array() for stem in ["DEF-thigh", "DEF-shin", "DEF-foot", "DEF-toe"]: var i := skeleton.find_bone(stem + suffix) if i < 0: i = skeleton.find_bone(stem.trim_prefix("DEF-") + suffix) if i >= 0: out.append(skeleton.get_bone_global_rest(i).origin) return out ## Distance from a point to a polyline. static func _dist_to_chain(p: Vector3, chain: PackedVector3Array) -> float: var best := INF for i in range(chain.size() - 1): var a: Vector3 = chain[i] var b: Vector3 = chain[i + 1] var ab: Vector3 = b - a var len2: float = ab.length_squared() var t: float = 0.0 if len2 < 0.000001 else clampf((p - a).dot(ab) / len2, 0.0, 1.0) best = minf(best, p.distance_to(a + ab * t)) return best static func _knee_height(skeleton: Skeleton3D) -> float: if skeleton == null: return NAN for n in ["DEF-shin.L", "shin.L", "DEF-shin.R", "shin.R"]: var i := skeleton.find_bone(n) if i >= 0: return skeleton.get_bone_global_rest(i).origin.y return NAN static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D, knee: float, chain_l: PackedVector3Array, chain_r: PackedVector3Array, limb_radius: float) -> Array: var safe_bounds := mi.mesh.get_aabb() var side := _side_map(mi.skin, skeleton) var torso_bone := _torso_map(mi.skin, skeleton) var surfaces: Array = [] var snapped := 0 var removed := 0 for s in range(mi.mesh.get_surface_count()): var arrays: Array = mi.mesh.surface_get_arrays(s) var r := _repair_surface(arrays, side, torso_bone, knee, chain_l, chain_r, limb_radius) snapped += r[0] removed += r[1] surfaces.append({ "arrays": arrays, "material": mi.mesh.surface_get_material(s), "name": mi.mesh.surface_get_name(s), }) if snapped == 0 and removed == 0: return [0, 0] var rebuilt := ArrayMesh.new() for i in surfaces.size(): var entry: Dictionary = surfaces[i] rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, entry["arrays"]) rebuilt.surface_set_material(i, entry["material"]) if entry["name"] != "": rebuilt.surface_set_name(i, entry["name"]) # ArrayMesh rebuilds per-bind bone bounds from ARRAY_BONES. Some runtime # GLBs have a sparse Skin bind table, so that derived array can be longer # than the renderer skeleton and mesh_get_aabb rejects it. A padded bind-pose # bound is conservative for humanoid animation and avoids that invalid path. var padding := Vector3.ONE * maxf(safe_bounds.size.length() * 0.12, 0.25) safe_bounds.position -= padding safe_bounds.size += padding * 2.0 rebuilt.custom_aabb = safe_bounds mi.mesh = rebuilt return [snapped, removed] ## Is each bind a torso bone? Keyed by SKIN BIND index, like _side_map. static func _torso_map(skin: Skin, skeleton: Skeleton3D) -> Array: var out: Array = [] out.resize(skin.get_bind_count()) for b in skin.get_bind_count(): var n := skin.get_bind_name(b) if n == "": var bone := skin.get_bind_bone(b) n = skeleton.get_bone_name(bone) if bone >= 0 else "" out[b] = false for hint in TORSO_BONE_HINTS: if n.findn(hint) != -1: out[b] = true break return out ## -1 left leg, +1 right leg, 0 anything else — keyed by SKIN BIND index, which ## is what ARRAY_BONES stores (not the skeleton's bone index). static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array: var out := PackedInt32Array() out.resize(skin.get_bind_count()) for b in skin.get_bind_count(): var n := skin.get_bind_name(b) if n == "": var bone := skin.get_bind_bone(b) n = skeleton.get_bone_name(bone) if bone >= 0 else "" var is_leg := false for hint in LEG_BONE_HINTS: if n.findn(hint) != -1: is_leg = true break if not is_leg: out[b] = 0 elif n.ends_with(".L"): out[b] = -1 elif n.ends_with(".R"): out[b] = 1 else: out[b] = 0 return out static func _repair_surface(arrays: Array, side: PackedInt32Array, torso_bone: Array, knee: float, chain_l: PackedVector3Array, chain_r: PackedVector3Array, limb_radius: float) -> Array: var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES] var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS] var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] if bones.is_empty() or verts.is_empty(): return [0, 0] var per: int = bones.size() / verts.size() # Step 1 — one leg per vertex. var vside := PackedInt32Array() vside.resize(verts.size()) var snapped := 0 for v in verts.size(): var wl := 0.0 var wr := 0.0 for k in per: var w: float = weights[v * per + k] if w <= EPSILON: continue match side[bones[v * per + k]]: -1: wl += w 1: wr += w if wl <= 0.0 and wr <= 0.0: vside[v] = 0 continue var keep: int = -1 if wl >= wr else 1 vside[v] = keep if minf(wl, wr) <= EPSILON: continue # already single-legged if verts[v].y > knee: # Above the knee, only repair vertices that belong to a LEG. Cloth # that drapes across both legs sits clear of either bone chain and # is left blended so it can keep draping. var dl: float = _dist_to_chain(verts[v], chain_l) var dr: float = _dist_to_chain(verts[v], chain_r) if minf(dl, dr) > limb_radius: continue # hugs neither chain — cloth, leave it alone # Side comes from WEIGHT, not from which chain is nearer. An earlier # version also demanded one chain be 1.25x closer than the other, # which skipped everything near the centre line — and that is exactly # where the damage was: inner-thigh vertices kept 21% of the OPPOSITE # leg and were torn apart when the legs split (measured 0.46). A limb # vertex belongs to whichever leg actually drives it. # Drop the losing leg's influence and renormalise what remains. var total := 0.0 for k in per: var b: int = bones[v * per + k] if side[b] != 0 and side[b] != keep: weights[v * per + k] = 0.0 total += weights[v * per + k] if total > 0.0: for k in per: weights[v * per + k] /= total snapped += 1 # Step 2 — stop the torso holding the top of the leg back. Only vertices that # actually belong to a limb are touched, so the skirt keeps swinging from # the hips as it should. for v in verts.size(): if vside[v] == 0: continue var chain: PackedVector3Array = chain_l if vside[v] == -1 else chain_r if _dist_to_chain(verts[v], chain) > limb_radius: continue var torso := 0.0 var dom_k := -1 var dom_w := 0.0 for k in per: var b: int = bones[v * per + k] var w: float = weights[v * per + k] if torso_bone[b]: torso += w elif side[b] != 0 and w > dom_w: dom_w = w dom_k = k if torso <= MAX_TORSO or dom_k < 0: continue # Scale the torso influence down to the cap and hand the rest to the # leg bone this vertex already follows. var keep_scale: float = MAX_TORSO / torso for k in per: if torso_bone[bones[v * per + k]]: weights[v * per + k] *= keep_scale weights[v * per + dom_k] += torso - MAX_TORSO var sum := 0.0 for k in per: sum += weights[v * per + k] if sum > 0.0: for k in per: weights[v * per + k] /= sum snapped += 1 # Step 3 — belt and braces: NO vertex that sits on a limb may carry any # weight from the opposite leg, at any height. The staged rules above each # have their own guards and between them they were still letting inner-thigh # vertices through with ~20% of the far leg, which tears them apart when the # legs split (measured 0.46 — the worst collapse left on the model). This is # unconditional and runs last so nothing can reintroduce it. for v in verts.size(): var near_l: float = _dist_to_chain(verts[v], chain_l) var near_r: float = _dist_to_chain(verts[v], chain_r) # Generous radius: the limb radius is measured from the bone AXIS, so a # vertex on the front or back of a thigh clears it easily, and those were # exactly the ones slipping through with opposite-leg weight. The skirt # hangs far enough out to stay outside even this. if minf(near_l, near_r) > limb_radius * 1.6: continue # cloth var own: int = -1 if near_l < near_r else 1 # Prefer the leg that actually drives it; fall back to the nearer chain. var wl2 := 0.0 var wr2 := 0.0 for k in per: match side[bones[v * per + k]]: -1: wl2 += weights[v * per + k] 1: wr2 += weights[v * per + k] if maxf(wl2, wr2) > 0.0: own = -1 if wl2 >= wr2 else 1 if minf(wl2, wr2) <= 0.0: continue # already single-legged var tot := 0.0 for k in per: var b2: int = bones[v * per + k] if side[b2] != 0 and side[b2] != own: weights[v * per + k] = 0.0 tot += weights[v * per + k] if tot > 0.0: for k in per: weights[v * per + k] /= tot vside[v] = own snapped += 1 # Step 4 — drop triangles that still span the legs below the knee. var removed := 0 if not idx.is_empty(): var keep_idx := PackedInt32Array() for t in range(0, idx.size(), 3): var a: int = idx[t] var b: int = idx[t + 1] var c: int = idx[t + 2] var below: bool = (verts[a].y + verts[b].y + verts[c].y) / 3.0 < knee var has_l: bool = vside[a] == -1 or vside[b] == -1 or vside[c] == -1 var has_r: bool = vside[a] == 1 or vside[b] == 1 or vside[c] == 1 if below and has_l and has_r: removed += 1 continue keep_idx.append(a) keep_idx.append(b) keep_idx.append(c) if removed > 0: arrays[Mesh.ARRAY_INDEX] = keep_idx if snapped > 0: arrays[Mesh.ARRAY_WEIGHTS] = weights return [snapped, removed]