Files
Papay-Shooter/debug/skirt_clip_view.gd
Nicholas ButzkeandClaude Opus 5 0dd9d01ac7 fix(cloth): solve the garment instead of repairing it five times
The skirt glitched when the character moved and the thigh still came
through it. Both came from the same place: the solver integrated one
spring per bone and then ran four more passes behind it — resolve the
collision against the target, resolve it again against the answer, relax
the cross-panel links and rebuild every pose from the corrected tips,
then walk a separate ancestor "lift" — each writing bone poses the next
read back and partly undid. The lift wrote poses that were never fed back
into the spring state at all, so every frame began by pulling against a
pose the springs did not know about.

Replaced with one position-based solve, the shape Magica Cloth 2's
BoneCloth uses. Every JOINT is a particle, so a bone's head can move;
predict with inertia in the anchor's frame; relax length, bend, backstop,
the cross-panel links and the colliders together; convert to rotations
once at the end. A contact with no rotational leverage is now resolved by
the panel moving, which is what a bodily chain push, an ancestor lift and
a drape weight were each approximating separately.

Measured, at a dead-still idle and over a movement sweep:

  idle jitter (skirt)      0.53 -> 0.025 deg/frame, worst 24 -> 1.9
  settling after a dash    103 -> 18 mm of leg left inside the skirt
  fall / air / walk         82 -> 40, 96 -> 75, 72 -> 76 mm
  run / slide / dash        unchanged, ~95 mm

The idle buzz and the failure to come home after a hard move are gone —
those were the "glitches out". Peak clipping in a run, a slide and a dash
is NOT fixed and is still around 95 mm.

Four things this turned up on the way:

- debug/cloth_clip_check.gd was measuring the animation, not the render.
  Godot restores bone poses after the modifier pass, so reading them with
  force_update_all_bone_transforms() afterwards sees nothing any modifier
  did. It reported the same ~95 mm with collision fully enabled and with
  it commented out. It now observes from inside the pass. Every number
  ever taken from this tool before now was measuring the wrong pose.

- The collision hulls came from ten farthest-point samples per bone,
  which describe a panel's corners and hem and leave its MIDDLE unsampled
  — exactly where a thigh comes through. Built from the real mesh at load
  time instead.

- The drape term is gone. It was there to move a panel the old solver
  could not, and once the solver could, it was worse in every state but a
  walk and cost 20x in stability: its target sat inside the leg the
  collision was pushing out of, so the two ran against each other forever.

- Cost was 10.9 ms per character. The inner loop rebuilt every capsule
  and reallocated the hull array for every (bone, collider, pass). Now
  2.6 ms at full quality with a distance LOD behind it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 03:07:30 -04:00

317 lines
12 KiB
GDScript

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 -- <out_dir> [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