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]>
This commit is contained in:
Nicholas Butzke
2026-07-26 03:07:30 -04:00
co-authored by Claude Opus 5
parent 374d9f9822
commit 0dd9d01ac7
24 changed files with 3322 additions and 170 deletions
+195 -10
View File
@@ -136,7 +136,26 @@ var _cur_ads: float = 0.0
var _cur_slide: float = 0.0
var _cur_wall: float = 0.0
var _owner_visible: bool = false
## Horizontal speed from the last update_state, so the lean can scale with how
## fast the character is really moving.
var _speed: float = 0.0
var _loco_tier: int = 0
var _tier_age: float = 0.0
const POSE_SMOOTH := 10.0
## Separate, slower rate for the whole-body lean. Roughly a 0.36 s time constant,
## so the posture arrives with the clip crossfade instead of a tenth of a second
## ahead of it.
const LEAN_SMOOTH := 4.5
## Ground locomotion tiers, slowest first, and how far below the promoting
## threshold the speed must fall before dropping back a tier.
const LOCO_TIERS := ["Idle", "Walk", "Run", "Sprint"]
const LOCO_HYSTERESIS := 0.78
## A locomotion tier is held at least this long before another change is
## allowed. Hard acceleration genuinely passes through walking pace in about a
## fifth of a second, so without this Walk got 0.19 s — less than half of its own
## 0.40 s crossfade — and was cut off mid-blend by Run. Multi-tier jumps still
## happen in one step, so this delays nothing that was not already a blur.
const MIN_TIER_DWELL := 0.22
func _ready() -> void:
@@ -200,7 +219,8 @@ func load_model(path: String) -> void:
_spring_mod = SpringBones.new()
_spring_mod.name = "SpringBones"
skeleton.add_child(_spring_mod)
var driven := _spring_mod.setup(skeleton, _rig_info)
var driven := _spring_mod.setup(skeleton, _rig_info,
_cloth_hulls(scene))
if driven == 0:
_spring_mod.queue_free()
_spring_mod = null
@@ -231,6 +251,101 @@ func load_model(path: String) -> void:
_play_clip("Idle")
## Collision hulls for the cloth solver, taken from the MESH rather than from
## the sidecar: bone name -> the points that bone drives, in its own rest space.
##
## The sidecar carries ten farthest-point samples per cloth bone, which is a good
## description of a panel's OUTLINE and a poor one of a panel. Farthest-point
## sampling lands on corners, edges and the hem; a thigh comes up through the
## MIDDLE of a panel, between every sample, and the solver reported each frame's
## contacts fully resolved while 158 vertices sat 95 mm inside a leg.
##
## The runtime has the actual mesh, so it does not have to guess. Every vertex a
## cloth bone dominates is binned into a ~16 mm grid and one representative per
## cell is kept, which covers a panel evenly for a bounded number of points —
## unlike keeping every vertex, which would be thousands of collision tests per
## frame for no extra accuracy at the scale a limb is shaped.
const HULL_CELL := 0.020
const HULL_MAX := 14
func _cloth_hulls(scene: Node) -> Dictionary:
var cloth := {}
for c in _rig_info.get("chains", []):
for n in c.get("bones", []):
var bi := skeleton.find_bone(String(n))
if bi >= 0:
cloth[bi] = true
if cloth.is_empty():
return {}
# bone -> cell key -> the vertex nearest that cell's centre.
var cells := {}
for mi in scene.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
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 = skeleton.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in 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]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
for v in verts.size():
# A vertex belongs to whichever bone holds the largest share of
# it — that is the bone whose motion actually decides where it
# ends up, and so the bone that has to keep it out of a leg.
var best := 0.0
var bind := -1
for k in per:
var w: float = weights[v * per + k]
if w > best:
best = w
bind = bones[v * per + k]
if bind < 0 or best < 0.5:
continue
var bi: int = bone_of.get(bind, -1)
if not cloth.has(bi):
continue
# The bind pose maps a vertex straight into its bone's rest
# space, which is exactly the frame the solver poses hulls in.
var local: Vector3 = skin.get_bind_pose(bind) * verts[v]
var key := "%d_%d_%d" % [
int(round(local.x / HULL_CELL)),
int(round(local.y / HULL_CELL)),
int(round(local.z / HULL_CELL))]
if not cells.has(bi):
cells[bi] = {}
if not cells[bi].has(key):
cells[bi][key] = local
var out := {}
var total := 0
for bi in cells:
var pts: Array = cells[bi].values()
if pts.size() > HULL_MAX:
# Keep the OUTERMOST cells. What clips is the part of a panel
# furthest from the bone it hangs on, and the grid has already made
# sure those are spread over the whole sheet rather than clustered.
pts.sort_custom(func(a, b): return a.length_squared() > b.length_squared())
pts = pts.slice(0, HULL_MAX)
var packed := PackedVector3Array()
for p in pts:
packed.append(p)
out[skeleton.get_bone_name(bi)] = packed
total += packed.size()
print("SkinnedPlayerModel: cloth hulls from mesh — %d bones, %d points"
% [out.size(), total])
return out
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
##
## Its presence is also the signal that this model kept its OWN skeleton and
@@ -429,6 +544,7 @@ func add_gun_recoil(strength: float = 1.0) -> void:
## Same contract as HumanoidModel.update_state(). Called by the movement
## controller each frame with either local or network-synced state.
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
_speed = speed
if not loaded or not animation_player:
return
@@ -454,12 +570,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
clip = "Dance"
elif is_crouching:
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
elif speed > run_anim_reference_speed * 1.35:
clip = "Sprint"
elif speed > walk_anim_reference_speed * 1.2:
clip = "Run"
elif speed > 0.5:
clip = "Walk"
else:
clip = _loco_clip(speed)
# Armed idle uses the plain Idle clip — the rifle-hold pose layer
# owns the arms, so the odd arms-crossed PistolIdle base reads worse.
"air":
@@ -529,17 +641,36 @@ func set_grapple_target(point_world: Vector3) -> void:
_grapple_point_world = point_world
## How far away each cloth detail level starts, in metres. See SpringBones.lod —
## the solver is expensive enough that only the character being looked at can
## afford the full thing.
const CLOTH_LOD_RANGES := [6.0, 14.0, 28.0]
var _lod_timer: float = 0.0
func _process(delta: float) -> void:
_update_cloth_lod(delta)
if not _pose_mod:
return
var t := 1.0 - exp(-POSE_SMOOTH * delta)
_cur_strafe = lerpf(_cur_strafe, _target_strafe, t)
_cur_fwd = lerpf(_cur_fwd, _target_fwd, t)
# The body lean gets its own, much slower rate, and is scaled by how fast the
# character is ACTUALLY moving rather than by which key is held.
#
# The controller passes a normalised input direction, so `fwd` jumps 0 -> 1
# the instant W is pressed. At the shared rate that planted the full forward
# lean in about a tenth of a second while the Idle->Run crossfade was still
# 0.4 s from finishing — the body snapped into a run posture ahead of the run
# cycle. Tying it to speed means the lean now grows as the character
# accelerates, and lands with the clip.
var lean_t := 1.0 - exp(-LEAN_SMOOTH * delta)
var drive: float = clampf(_speed / maxf(run_anim_reference_speed, 0.01), 0.0, 1.0)
_cur_strafe = lerpf(_cur_strafe, _target_strafe * drive, lean_t)
_cur_fwd = lerpf(_cur_fwd, _target_fwd * drive, lean_t)
_cur_ads = lerpf(_cur_ads, _target_ads, t)
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
_cur_slide = lerpf(_cur_slide, slide_target, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, t)
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
_pose_mod.ads = _cur_ads
@@ -611,6 +742,50 @@ func _process(delta: float) -> void:
_pose_mod.reload_phase = rl_target
## Pick the cloth solver's detail level from how far the camera is.
##
## Re-checked a few times a second rather than every frame: the answer changes
## slowly, and the distance query is not free either.
func _update_cloth_lod(delta: float) -> void:
if _spring_mod == null:
return
_lod_timer -= delta
if _lod_timer > 0.0:
return
_lod_timer = 0.25
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
if cam == null:
return
var d := cam.global_position.distance_to(global_position)
var want := CLOTH_LOD_RANGES.size()
for i in CLOTH_LOD_RANGES.size():
if d < CLOTH_LOD_RANGES[i]:
want = i
break
_spring_mod.lod = want
## Locomotion clip for a ground speed, with HYSTERESIS.
##
## The thresholds used to be a bare elif chain, so a character accelerating from
## a standstill crossed all three in under a second and each crossfade cut off
## the one before it — and any speed hovering on a boundary flickered between
## two clips forever. Dropping back down needs the speed to fall well under the
## threshold that promoted it, so a tier, once entered, is committed to.
func _loco_clip(speed: float) -> String:
_tier_age += get_process_delta_time()
var up := [0.5, walk_anim_reference_speed * 1.2, run_anim_reference_speed * 1.35]
var want := _loco_tier
while want < LOCO_TIERS.size() - 1 and speed > up[want]:
want += 1
while want > 0 and speed < up[want - 1] * LOCO_HYSTERESIS:
want -= 1
if want != _loco_tier and _tier_age >= MIN_TIER_DWELL:
_loco_tier = want
_tier_age = 0.0
return LOCO_TIERS[_loco_tier]
func _play_clip(canonical: String, restart: bool = false) -> void:
if not _anim_tree or not _resolved_clips.has(canonical):
return
@@ -622,6 +797,16 @@ func _play_clip(canonical: String, restart: bool = false) -> void:
_current_clip = clip_name
## Current smoothed forward lean, 0..1. For debug/transition_check.gd.
func get_lean_debug() -> float:
return _cur_fwd
## Clip currently playing. For debug/transition_check.gd.
func current_clip_debug() -> String:
return _current_clip
## Vertical velocity of the body this model is attached to (0 if detached).
func _vertical_speed() -> float:
var p := get_parent()
+1137 -158
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
extends SceneTree
## Dev tool: how much room does the collision solver actually HAVE?
##
## godot --headless --path . -s res://debug/cloth_allow_check.gd -- [skin_glb]
##
## SpringBones caps each cloth point's collider radius to just inside where that
## point rests, so the authored rest pose is a valid state and the idle does not
## buzz (see SpringBones._rest_clearances). That cap is also the ceiling on what
## the collision can ever do: a hull point resting 60 mm from a thigh's axis gets
## an allowance of 54 mm, so a 110 mm thigh can put 56 mm of itself inside that
## piece of cloth before a single constraint fires.
##
## This prints, per cloth bone, the gap between the limb's REAL radius and the
## allowance the solver is given — which is the clipping the solver is blind to
## by construction, before any tuning is considered.
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := GLBLoader.load(path)
if scene == null:
print("could not load ", path)
quit()
return
root.add_child(scene)
var skel: Skeleton3D = _find(scene, "Skeleton3D") as Skeleton3D
var side := path.get_basename() + ".rig.json"
var info = JSON.parse_string(FileAccess.get_file_as_string(side))
if skel == null or typeof(info) != TYPE_DICTIONARY:
print("no skeleton or sidecar")
quit()
return
var cols: Array = []
for c in info.get("colliders", []):
var a := skel.find_bone(String(c.get("bone", "")))
var b := skel.find_bone(String(c.get("child", "")))
if a < 0 or b < 0:
continue
var tail := float(c.get("radius_tail", c.get("radius", 0.1)))
cols.append({
"name": String(c.get("bone", "")),
"a": a, "b": b, "from": float(c.get("from", 0.0)),
"lid": bool(c.get("lid", false)),
"rh": float(c.get("radius_head", tail)), "rt": tail,
})
print("\n=== how much of each limb the solver is blind to, per cloth bone ===")
print(" BLIND = limb radius here - the allowance the rest-clearance cap gives\n")
var rows: Array = []
for ch in info.get("chains", []):
if String(ch.get("class", "")) not in SpringBones.DRAPE_CLASSES:
continue
var names: Array = ch.get("bones", [])
var tips: Array = ch.get("tips", [])
var hulls: Array = ch.get("hulls", [])
for i in names.size():
var bi := skel.find_bone(String(names[i]))
if bi < 0 or i >= tips.size():
continue
var t: Array = tips[i]
if t.size() != 3:
continue
var rest := skel.get_bone_global_rest(bi)
var hull := PackedVector3Array()
if i < hulls.size():
for h in hulls[i]:
if h.size() == 3:
hull.append(Vector3(h[0], h[1], h[2]))
var pts := SpringBones._sample_points(rest, rest.origin,
rest * Vector3(t[0], t[1], t[2]), hull)
var worst := 0.0
var who := ""
for col in cols:
if col["lid"]:
continue
var a: Vector3 = skel.get_bone_global_rest(col["a"]).origin
var b: Vector3 = skel.get_bone_global_rest(col["b"]).origin
a = a.lerp(b, float(col["from"]))
var ab := b - a
var d2 := ab.length_squared()
for p: Vector3 in pts:
var u: float = 0.0 if d2 < 1e-9 \
else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
var d: float = p.distance_to(a + ab * u)
var r: float = lerpf(float(col["rh"]), float(col["rt"]), u)
# Exactly SpringBones._rest_clearances.
var allow: float = maxf(r, d * 0.9) if d >= r else d * 0.9
if r - allow > worst:
worst = r - allow
who = String(col["name"])
if worst > 0.001:
rows.append([worst, skel.get_bone_name(bi), who])
rows.sort_custom(func(x, y): return x[0] > y[0])
for r in rows.slice(0, 24):
print(" %-26s BLIND %5.1f mm against %s" % [r[1], r[0] * 1000.0, r[2]])
print(" ... %d cloth bones have a blind band at all\n" % rows.size())
quit()
func _find(node: Node, cls: String) -> Node:
if node.is_class(cls):
return node
for c in node.get_children():
var f := _find(c, cls)
if f:
return f
return null
+381
View File
@@ -0,0 +1,381 @@
extends SceneTree
## Dev tool: does the LEG actually poke through the CLOTH?
##
## godot --headless --path . -s res://debug/cloth_clip_check.gd -- [skin_glb]
##
## Skins every cloth vertex itself over a sweep of movement states and measures
## how far each one ends up INSIDE the leg capsules from <model>.rig.json.
##
## This exists because debug/cloth_settle_check.gd measures the wrong thing for
## this question. That one reports how far a cloth BONE penetrates, which came
## back at about a millimetre while the thigh was still visibly through the
## skirt in almost every animation — because a skirt panel is a wide sheet and
## its bone is a single stick from the waist. Keeping the stick out of the leg
## says nothing about the hundreds of vertices hanging off it.
##
## Reports per surface, worst over the sweep:
## DEPTH how far the deepest vertex sits inside a capsule (metres)
## COUNT how many vertices are inside at that worst moment
##
## THE POSE IS READ FROM INSIDE THE MODIFIER PASS, from an observer
## SkeletonModifier3D added after SpringBones. It has to be. Godot restores every
## bone's local pose once the modifier pass is over, so a reader that calls
## force_update_all_bone_transforms() afterwards recomputes the global poses from
## the ANIMATION ALONE and never sees a single thing the cloth solver did. This
## tool did exactly that, and reported the same ~95 mm whether the collision was
## fully enabled or commented out — which is how the mistake was found.
## state, speed
## Idle FIRST and again LAST. A number taken from the state that happens to
## follow a dash is measuring the garment settling, not the garment at rest, and
## the two want opposite fixes — the sweep used to end on idle and reported the
## recovery as an idle failure.
const SWEEP := [["ground", 0.0], ["ground", 3.0], ["ground", 9.0], ["air", 6.0],
["air", -8.0], ["slide", 10.0], ["dash", 14.0], ["ground", 0.0]]
const FRAMES_PER_STATE := 60
var _frames := 0
var _model: SkinnedPlayerModel = null
var _caps: Array = [] # [bone_a, bone_b, r_head, r_tail]
var _worst := {}
var _worst_n := {}
var _cloth_bones := {} # skin bind index sets are per surface; see below
var _driver := {} # mesh -> bone dominating its deepest vertex
var _rest := {} # "mesh/surface" -> per-vertex rest clearance
var _key := ""
var _spring = null
## Full weight list of each surface's deepest vertex. A cloth solver can only
## move a vertex the CLOTH drives — one that is half-weighted to a thigh follows
## that thigh however well the garment is simulated, so "how much of this vertex
## does the skirt actually own" has to be part of the report.
var _mix := {}
## Worst phase of the sweep per surface, so a failure points at a movement state.
var _phase_of := {}
var _phase := 0
## bone name -> deepest contact the SOLVER reported on it over the sweep.
var _saw := {}
var _probe: PoseProbe = null
## bone name -> overlap still left once the relaxation had converged.
var _res := {}
var _per_phase := {}
var _per_phase_n := {}
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _load_caps(skel: Skeleton3D, path: String) -> void:
var side := path.get_basename() + ".rig.json"
if not FileAccess.file_exists(side):
print("no sidecar — nothing to check against")
return
var info = JSON.parse_string(FileAccess.get_file_as_string(side))
if typeof(info) != TYPE_DICTIONARY:
return
for c in info.get("colliders", []):
var a := skel.find_bone(String(c.get("bone", "")))
var b := skel.find_bone(String(c.get("child", "")))
if a < 0 or b < 0:
continue
var tail := float(c.get("radius_tail", c.get("radius", 0.1)))
_caps.append([a, b, float(c.get("radius_head", tail)), tail,
float(c.get("from", 0.0))])
for c in info.get("chains", []):
for n in c.get("bones", []):
var i := skel.find_bone(String(n))
if i >= 0:
_cloth_bones[i] = true
print("checking %d cloth bones against %d leg capsules" % [
_cloth_bones.size(), _caps.size()])
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if _caps.is_empty() and _cloth_bones.is_empty():
_load_caps(skel, _model.model_path)
if _caps.is_empty():
return true
if _spring == null:
# Headless runs uncapped, so the engine delta is sub-millisecond and the
# solver integrates almost nothing. Pin it to a real frame so the sweep
# measures cloth in motion rather than cloth held at its rest pose.
_spring = skel.get_node_or_null("SpringBones")
if _spring:
_spring.fixed_delta = 1.0 / 60.0
_probe = PoseProbe.new()
_probe.name = "ClipProbe"
skel.add_child(_probe) # AFTER SpringBones, so it sees the final pose
return false
if _rest.is_empty():
# Baseline first: a skirt legitimately drapes INSIDE the thigh capsule,
# so absolute depth says nothing. What matters is the leg getting closer
# to a piece of cloth than the artist modelled it.
_capture_rest(skel)
return false
var phase: int = clampi((_frames - 8) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
_phase = phase
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
_model.set_locomotion(0.0, 1.0, 0.0)
_measure(skel)
# What the SOLVER thinks is happening, alongside what the mesh is doing. If
# a bone's vertices are deep inside a leg while its own contact report is
# near zero, the solver is not blind by tuning — it is not looking at the
# geometry that is clipping.
if _spring:
var rep: Dictionary = _spring.debug_hit_report()
for b in rep:
_saw[skel.get_bone_name(b)] = maxf(_saw.get(skel.get_bone_name(b), 0.0),
float(rep[b]))
var res: Dictionary = _spring.debug_residual_report()
for b in res:
_res[skel.get_bone_name(b)] = maxf(_res.get(skel.get_bone_name(b), 0.0),
float(res[b]))
if _frames > 8 + FRAMES_PER_STATE * SWEEP.size():
_report()
return true
return false
## Clearance of every cloth vertex to the legs in the REST pose.
func _capture_rest(skel: Skeleton3D) -> void:
var segs: Array = []
for c in _caps:
# The `from` offset MATTERS. SpringBones starts a limb capsule 10% down
# the bone because the top of a thigh is hip, buried inside the body the
# skirt hangs from — see tools/retarget.py::_leg_colliders. Measuring
# against the full bone tests a band the solver is deliberately not
# defending and reports it as clipping that no tuning can ever fix.
var ra: Vector3 = skel.get_bone_global_rest(c[0]).origin
var rb: Vector3 = skel.get_bone_global_rest(c[1]).origin
segs.append([ra.lerp(rb, c[4]), rb, c[2], c[3]])
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
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]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
var out := PackedFloat32Array()
out.resize(verts.size())
for v in verts.size():
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
if w <= 0.0:
continue
var bi: int = bone_of[bones[v * per + k]]
if bi < 0:
continue
q += (skel.get_bone_global_rest(bi) * skin.get_bind_pose(bones[v * per + k]) * verts[v]) * w
out[v] = _clearance(q, segs)
_rest["%s/%d" % [mi.name, s]] = out
## Which capsule the last _clearance() call picked. Reported for the deepest
## vertex, because "inside a leg" and "inside the waist lid" are different
## failures with different fixes and the bare number cannot tell them apart.
var _which := -1
## Distance from the nearest capsule SURFACE (negative = inside).
func _clearance(p: Vector3, segs: Array) -> float:
var best := INF
var idx := 0
for s in segs:
var a: Vector3 = s[0]
var ab: Vector3 = s[1] - a
var d2: float = ab.length_squared()
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
var r: float = lerpf(s[2], s[3], t)
var d := p.distance_to(a + ab * t) - r
if d < best:
best = d
_which = idx
idx += 1
return best
## Snapshot of every bone's global pose, taken INSIDE the modifier pass. See the
## header: read any later and the cloth solver's work is already gone.
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _measure(skel: Skeleton3D) -> void:
if _probe == null or _probe.pose.size() != skel.get_bone_count():
return
var segs: Array = []
for c in _caps:
var pa: Vector3 = (_probe.pose[c[0]] as Transform3D).origin
var pb: Vector3 = (_probe.pose[c[1]] as Transform3D).origin
segs.append([pa.lerp(pb, c[4]), pb, c[2], c[3]])
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
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]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
var deepest := 0.0
var count := 0
var deep_v := -1
var deep_cap := -1
for v in verts.size():
# Only vertices the CLOTH actually drives — the body's own legs
# are inside these capsules by definition.
var is_cloth := false
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
if w <= 0.0:
continue
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0:
continue
if _cloth_bones.has(bi) and w > 0.5:
is_cloth = true
q += ((_probe.pose[bi] as Transform3D) * skin.get_bind_pose(bind) * verts[v]) * w
if not is_cloth:
continue
var rest_arr: PackedFloat32Array = _rest.get("%s/%d" % [mi.name, s], PackedFloat32Array())
if v >= rest_arr.size():
continue
# How far INSIDE a leg this piece of cloth now is, over and above
# however far inside the artist modelled it.
#
# Not "how much closer the leg got": a hem 200 mm clear of a shin
# legitimately comes 180 mm closer when the leg kicks out in a
# slide, and counting that as a failure buried the real clipping
# under motion the character is supposed to have. Only cloth that
# is actually within the capsule can be showing a leg through it.
var inside := -_clearance(q, segs)
var hit := _which
if inside <= 0.0:
continue
var d := inside - maxf(-rest_arr[v], 0.0)
if d > 0.0:
count += 1
if d > deepest:
deepest = d
deep_v = v
deep_cap = hit
if deepest <= 0.0:
continue
# Per state as well as overall: one worst number over a whole sweep
# hides which movement actually breaks, and it moves to a different
# state after every change, which reads as "no progress" when a real
# failure has in fact been fixed and a smaller one exposed.
var pk := "%d" % _phase
if deepest > _per_phase.get(pk, 0.0):
_per_phase[pk] = deepest
_per_phase_n[pk] = count
var key: String = "%s/%s" % [mi.name, mi.mesh.surface_get_name(s)]
if deepest > _worst.get(key, 0.0):
_worst[key] = deepest
_worst_n[key] = count
_phase_of[key] = "%s@%.0f in %s" % [SWEEP[_phase][0],
SWEEP[_phase][1],
skel.get_bone_name(_caps[deep_cap][0]) if deep_cap >= 0 else "?"]
# EVERY bone driving the deepest vertex, not just the strongest.
# A solver can only move what the cloth owns: a vertex half
# weighted to a thigh follows that thigh however well the garment
# is simulated, and no amount of solver work will change it.
var mix: Array = []
var best := 0.0
var bn := -1
for k in per:
var w: float = weights[deep_v * per + k]
if w <= 0.001:
continue
var bi: int = bone_of[bones[deep_v * per + k]]
mix.append("%s=%.2f" % [
skel.get_bone_name(bi) if bi >= 0 else "?", w])
if w > best:
best = w
bn = bi
_mix[key] = " ".join(mix)
_driver[mi.name] = "%s w=%.2f" % [
skel.get_bone_name(bn) if bn >= 0 else "?", best]
# _model.set_locomotion is enough to keep the pose layer fed.
## How far inside the nearest leg capsule this point is (0 if clear).
func _penetration(p: Vector3, segs: Array) -> float:
var worst := 0.0
for s in segs:
var a: Vector3 = s[0]
var ab: Vector3 = s[1] - a
var d2: float = ab.length_squared()
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
var r: float = lerpf(s[2], s[3], t)
worst = maxf(worst, r - p.distance_to(a + ab * t))
return worst
func _report() -> void:
print("\n=== worst LEG-INSIDE-CLOTH penetration over the sweep ===")
if _worst.is_empty():
print(" none — no cloth vertex entered a leg capsule\n")
return
var keys := _worst.keys()
keys.sort_custom(func(a, b): return _worst[a] > _worst[b])
for k in keys:
print(" %-30s %6.1f mm %4d verts worst in %-10s" % [
k, _worst[k] * 1000.0, _worst_n[k], _phase_of.get(k, "?")])
print(" deepest vertex weights: %s" % _mix.get(k, "?"))
var owner: String = _mix.get(k, "=").get_slice("=", 0)
print(" on %s: contact seen %.1f mm, left after solving %.1f mm" % [
owner, _saw.get(owner, 0.0) * 1000.0, _res.get(owner, 0.0) * 1000.0])
print(" per movement state, worst cloth vertex inside a capsule:")
for i in SWEEP.size():
print(" %-12s %6.1f mm %4d verts" % [
"%s@%.0f" % [SWEEP[i][0], SWEEP[i][1]],
_per_phase.get("%d" % i, 0.0) * 1000.0, _per_phase_n.get("%d" % i, 0)])
print("")
+1
View File
@@ -0,0 +1 @@
uid://c4x5gy6vjcvb0
+61
View File
@@ -0,0 +1,61 @@
extends SceneTree
## Dev tool: what does the cloth solver cost per character, per frame?
##
## godot --headless --path . -s res://debug/cloth_perf_check.gd -- [skin_glb]
##
## The solver runs a Gauss-Seidel relaxation over every cloth joint and tests
## every collision hull point against every capsule on every pass, so its cost is
## the product of four numbers that are all easy to raise by accident. This is
## the budget check: a character is one of several on screen and the whole frame
## is 16 ms.
const FRAMES := 240
var _frames := 0
var _model: SkinnedPlayerModel = null
var _spring = null
var _usec := 0
var _samples := 0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if _spring == null:
_spring = skel.get_node_or_null("SpringBones")
if _spring == null:
print("no SpringBones on this model")
return true
_spring.fixed_delta = 1.0 / 60.0
return false
# A run cycle, which is where the colliders are busiest.
_model.update_state("ground", 9.0, false)
_model.set_locomotion(0.0, 1.0, 0.0)
var t0 := Time.get_ticks_usec()
_spring._process_modification()
_usec += Time.get_ticks_usec() - t0
_samples += 1
if _frames > FRAMES:
print("\n=== cloth solver cost ===")
print(" %.3f ms per character per frame (%d samples, running)" % [
float(_usec) / float(_samples) / 1000.0, _samples])
print(" budget: a 60 fps frame is 16.7 ms and holds several characters\n")
return true
return false
+167
View File
@@ -0,0 +1,167 @@
extends SceneTree
## Dev tool: is the cloth MOVING SANELY, or spasming?
##
## godot --headless --path . -s res://debug/cloth_settle_check.gd -- [speed] [nospring] [glb]
##
## Reports how far each cloth bone ROTATES per frame, in degrees, read from the
## bone's LOCAL pose — which is exactly what SpringBones writes, and is immune to
## the head bobbing or the character travelling.
##
## speed 0 everything should fall towards 0.
## speed 9 a few degrees per frame is cloth. Tens of degrees per frame, at
## frame rate, is the "blur spazzing about".
##
## WARNING: the deg/frame column is LOCAL bone rotation, and for a chain that
## is not the same as visible motion. Correcting a panel root shows up as an
## equal and opposite delta on each of its segments, so a hem that has not
## moved on screen at all can report 12-18 deg/frame. Measured against
## debug/idle_jitter_check.gd, which counts changed PIXELS between
## consecutive idle frames: with collision on 24866 px/frame, with collision
## off 38594 — the collision was the thing this tool accused, and it is
## actually damping the idle rather than driving it. Use the pixel check
## before believing a settling number here.
##
## HOW IT MEASURES, AND WHY THAT MATTERS. Sampling is done by an observer
## SkeletonModifier3D appended AFTER SpringBones, so it sees precisely the pose
## the renderer will use. Two earlier versions of this tool were wrong and both
## sent the investigation the wrong way:
##
## * Reading `get_bone_pose_rotation()` from `_process` reported an identical
## 0.06 deg in EVERY configuration. `_process` runs BEFORE the modifiers, and
## cloth bones carry no animation tracks, so it read the rest pose every time.
## * Calling `force_update_all_bone_transforms()` RE-RUNS the modification
## stack, stepping the solver a second time per frame. A blend applied to the
## final pose write — which cannot fail to reduce motion — moved the reading
## from 32.02 to 32.00 mm/frame. Six real changes in a row read as no-ops.
##
## If a change to the solver does not move these numbers, suspect this file
## before concluding the change did nothing.
const WINDOW := 30
var _frames := 0
var _model: SkinnedPlayerModel = null
var _spring: SpringBones = null
var _obs: Observer = null
var _speed := 0.0
class Observer extends SkeletonModifier3D:
var bones: PackedInt32Array = PackedInt32Array()
var names: Array = []
var prev: Array = []
var worst := 0.0
var worst_name := ""
var sum := 0.0
var n := 0
# Per class, because "the cloth moves" can hide "the hair is frozen".
var hair_sum := 0.0
var hair_n := 0
var hair_worst := 0.0
var skirt_sum := 0.0
var skirt_n := 0
var started := false
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or bones.is_empty():
return
for i in bones.size():
var q := skel.get_bone_pose_rotation(bones[i])
if started:
var d: float = absf(q.angle_to(prev[i]))
if d > worst:
worst = d
worst_name = names[i]
sum += d
n += 1
if names[i].findn("hair") != -1:
hair_sum += d
hair_n += 1
hair_worst = maxf(hair_worst, d)
elif names[i].findn("skirt") != -1:
skirt_sum += d
skirt_n += 1
prev[i] = q
started = true
func _initialize() -> void:
var path := "res://assets/characters/skins/taila.glb"
for a in OS.get_cmdline_user_args():
if a.begins_with("res://"):
path = a
elif a != "nospring":
_speed = a.to_float()
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _setup(skel: Skeleton3D) -> bool:
_spring = skel.get_node_or_null("SpringBones") as SpringBones
if _spring == null:
print("SpringBones not installed")
return false
_spring.fixed_delta = 1.0 / 60.0
if OS.get_cmdline_user_args().has("nospring"):
_spring.active = false
print("springs DISABLED (animation-only baseline)")
var info = JSON.parse_string(
FileAccess.get_file_as_string(_model.model_path.get_basename() + ".rig.json"))
if typeof(info) != TYPE_DICTIONARY:
return false
_obs = Observer.new()
_obs.name = "ClothObserver"
for c in info.get("chains", []):
for nm in c.get("bones", []):
var i := skel.find_bone(String(nm))
if i >= 0:
_obs.bones.append(i)
_obs.names.append(String(nm))
_obs.prev.append(Quaternion.IDENTITY)
# AFTER SpringBones in the tree, so it observes the final pose.
skel.add_child(_obs)
print("tracking %d cloth bones at %.1f m/s" % [_obs.bones.size(), _speed])
return _obs.bones.size() > 0
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 6:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
print("no skeleton")
return true
if _obs == null and not _setup(skel):
return true
_model.update_state("ground", _speed, false)
_model.set_locomotion(0.0, 1.0 if _speed > 0.1 else 0.0, 0.0)
_model.position += Vector3(0, 0, -_speed) / 60.0
if _frames % WINDOW == 0 and _frames > 20:
print("t=%4d worst %6.2f deg/frame (%-18s) mean %5.3f contacts/frame %.1f" % [
_frames, rad_to_deg(_obs.worst), _obs.worst_name,
rad_to_deg(_obs.sum / maxf(_obs.n, 1)),
_spring.debug_collisions_per_frame()])
print(" hair mean %5.3f deg/frame (worst %5.2f) skirt mean %5.3f" % [
rad_to_deg(_obs.hair_sum / maxf(_obs.hair_n, 1)),
rad_to_deg(_obs.hair_worst),
rad_to_deg(_obs.skirt_sum / maxf(_obs.skirt_n, 1))])
_obs.hair_sum = 0.0
_obs.hair_n = 0
_obs.hair_worst = 0.0
_obs.skirt_sum = 0.0
_obs.skirt_n = 0
_obs.worst = 0.0
_obs.sum = 0.0
_obs.n = 0
if _frames > WINDOW * 8:
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://bk7pst2vhawvt
+280
View File
@@ -0,0 +1,280 @@
extends SceneTree
## Dev tool: is the skirt STRETCHING around the thigh, or tearing open?
##
## godot --headless --path . -s res://debug/cloth_stretch_check.gd -- [skin_glb]
##
## The collision and drape solve each cloth bone on its own. Neighbouring skirt
## panels therefore get different answers, and the mesh between them has to
## absorb the difference — which linear-blend skinning does by pulling the shared
## edge apart. On screen that reads as the skirt "breaking" open around the thigh
## instead of deforming over it, and no capsule or spring number shows it,
## because every individual bone is behaving.
##
## So measure the MESH: skin every cloth triangle over a movement sweep and
## compare each edge against its own rest length. An edge whose two ends are
## driven by different panels is a SEAM — that is where a tear appears — so those
## are reported separately from edges inside one panel.
##
## Reports, worst over the sweep:
## stretch posed edge length / rest length
## gap how many millimetres that edge grew
const SWEEP := [["ground", 9.0], ["ground", 3.0], ["air", 6.0],
["air", -8.0], ["slide", 10.0], ["dash", 14.0]]
const FRAMES_PER_STATE := 30
## An edge has to grow by more than this to count as a tear rather than noise.
const REPORT_MM := 8.0
var _frames := 0
var _model: SkinnedPlayerModel = null
var _cloth := {} # bone index -> panel family name
var _edges: Array = [] # [mesh, surface, ia, ib, rest_len, family_a, family_b]
var _skins: Array = [] # [mesh, skin, bone_of, verts, bones, weights, per]
var _worst := {} # "famA|famB" -> [stretch, grow_m, bones, rest_m, state]
## The single worst edge seen, kept so _report can dump what actually drives it.
var _peak := 0.0
var _peak_edge: Array = []
var _state := ""
var _ready := false
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
## Panel a bone belongs to: the chain root's name, with the segments built by
## tools/retarget.py::subdivide_cloth_panels stripped off. Two segments of the
## same panel are meant to bend apart; two different panels are not.
static func _family(bone_name: String) -> String:
var n := bone_name
var cut := n.find(".seg")
return n.substr(0, cut) if cut >= 0 else n
func _build(skel: Skeleton3D) -> void:
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:
print("no sidecar")
return
for c in info.get("chains", []):
if String(c.get("class", "")) == "hair":
continue
for n in c.get("bones", []):
var i := skel.find_bone(String(n))
if i >= 0:
_cloth[i] = _family(String(n))
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
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 idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if bones.is_empty() or verts.is_empty() or idx.is_empty():
continue
var per: int = bones.size() / verts.size()
var sk := [mi, skin, bone_of, verts, bones, weights, per]
# Which panel drives each vertex, and its rest position.
var fam := []
var drv := []
var rest := PackedVector3Array()
var any := false
fam.resize(verts.size())
drv.resize(verts.size())
rest.resize(verts.size())
for v in verts.size():
var bw := 0.0
var cw := 0.0
var f := ""
var dn := ""
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_rest(bi) * skin.get_bind_pose(bind)
* verts[v]) * w
if _cloth.has(bi):
cw += w
if w > bw:
bw = w
f = _cloth[bi]
dn = skel.get_bone_name(bi)
# The cloth chains must actually OWN this vertex. Body surfaces
# carry stray cloth influence — one arm vertex measured 0.54
# forearm, 0.35 skirt — and counting those made the skirt look
# like it was tearing by half a metre when the arm was simply
# moving during a dash.
if cw < 0.75:
f = ""
dn = ""
fam[v] = f
drv[v] = dn
rest[v] = q
if f != "":
any = true
if not any:
continue
_skins.append(sk)
var seen := {}
for t in range(0, idx.size(), 3):
for pair in [[idx[t], idx[t + 1]], [idx[t + 1], idx[t + 2]],
[idx[t + 2], idx[t]]]:
var a: int = mini(pair[0], pair[1])
var b: int = maxi(pair[0], pair[1])
if String(fam[a]) == "" or String(fam[b]) == "":
continue
var key := "%d_%d_%d" % [_skins.size(), a, b]
if seen.has(key):
continue
seen[key] = true
var L := rest[a].distance_to(rest[b])
if L < 0.0005:
continue
_edges.append([_skins.size() - 1, a, b, L,
String(fam[a]), String(fam[b]),
"%s -> %s" % [drv[a], drv[b]]])
var seams := 0
for e in _edges:
if e[4] != e[5]:
seams += 1
print("tracking %d cloth edges, %d of them across a panel seam" % [
_edges.size(), seams])
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if not _ready:
_build(skel)
_ready = true
if _edges.is_empty():
return true
return false
var phase: int = clampi((_frames - 9) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
_model.set_locomotion(0.0, 1.0, 0.0)
_state = "%s %.0f" % [SWEEP[phase][0], SWEEP[phase][1]]
_measure(skel)
if _frames > 9 + FRAMES_PER_STATE * SWEEP.size():
_report()
return true
return false
func _measure(skel: Skeleton3D) -> void:
# Skin every cloth vertex once, then walk the edges.
var posed: Array = []
for sk in _skins:
var skin: Skin = sk[1]
var bone_of: Dictionary = sk[2]
var verts: PackedVector3Array = sk[3]
var bones: PackedInt32Array = sk[4]
var weights: PackedFloat32Array = sk[5]
var per: int = sk[6]
var out := PackedVector3Array()
out.resize(verts.size())
for v in verts.size():
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bind: int = bones[v * per + k]
var bi: int = bone_of[bind]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_pose(bi) * skin.get_bind_pose(bind)
* verts[v]) * w
out[v] = q
posed.append(out)
for e in _edges:
var p: PackedVector3Array = posed[e[0]]
var L: float = p[e[1]].distance_to(p[e[2]])
var grow: float = L - float(e[3])
if grow <= 0.0:
continue
var key: String = "%s | %s" % [e[4], e[5]] if e[4] != e[5] else "%s (inside)" % e[4]
var cur: Array = _worst.get(key, [0.0, 0.0])
if grow > cur[1]:
_worst[key] = [L / float(e[3]), grow, e[6], float(e[3]), _state]
if grow > _peak:
_peak = grow
_peak_edge = [e[0], e[1], e[2], float(e[3]), _state]
func _report() -> void:
print("\n=== worst cloth EDGE STRETCH over the sweep ===")
print(" a growing seam is the skirt tearing open between two panels;")
print(" growth inside one panel is the panel itself being stretched.\n")
var keys := _worst.keys()
keys.sort_custom(func(a, b): return _worst[a][1] > _worst[b][1])
var shown := 0
for k in keys:
var w: Array = _worst[k]
if w[1] * 1000.0 < REPORT_MM:
break
print(" %-30s x%6.2f +%6.1f mm rest %5.1f mm %-42s %s" % [
k, w[0], w[1] * 1000.0, w[3] * 1000.0, w[2], w[4]])
shown += 1
if shown >= 24:
break
if shown == 0:
print(" nothing grew by more than %.0f mm" % REPORT_MM)
_dissect()
print("")
## Everything that drives the two ends of the single worst edge. A rigid bone
## cannot change the distance between two points, so an edge that grew while
## both ends report the same DOMINANT bone is being pulled by something else in
## their influence lists — which is the only way to find out what.
func _dissect() -> void:
if _peak_edge.is_empty():
return
var skel: Skeleton3D = _model.skeleton
var sk: Array = _skins[_peak_edge[0]]
var skin: Skin = sk[1]
var bone_of: Dictionary = sk[2]
var bones: PackedInt32Array = sk[4]
var weights: PackedFloat32Array = sk[5]
var per: int = sk[6]
print("
worst single edge: rest %.1f mm, grew %.1f mm, during %s" % [
_peak_edge[3] * 1000.0, _peak * 1000.0, _peak_edge[4]])
for which in [1, 2]:
var v: int = _peak_edge[which]
var line := " vertex %d:" % v
for k in per:
var w: float = weights[v * per + k]
if w <= 0.0001:
continue
var bi: int = bone_of[bones[v * per + k]]
line += " %s %.2f" % [
skel.get_bone_name(bi) if bi >= 0 else "?", w]
print(line)
+1
View File
@@ -0,0 +1 @@
uid://ccx4eh7wgfwrh
+79
View File
@@ -0,0 +1,79 @@
extends SceneTree
## Dev tool: does the cloth actually JITTER when the character is standing still?
##
## godot --path . --windowed --resolution 900x900 \
## -s res://debug/idle_jitter_check.gd -- <out_dir>
##
## debug/cloth_settle_check.gd answers this in degrees per frame of LOCAL bone
## rotation, and that number is inflated for a chain: correcting a panel root
## shows up as an equal and opposite delta on its segments, so a hem that has not
## moved at all in world space can report ten degrees. It has misled before.
##
## This renders consecutive frames of a still idle from a fixed camera and saves
## them; comparing neighbouring PNGs gives the only number that matters, which is
## whether anything on screen moved.
var _frames := 0
var _out := "."
var _model: SkinnedPlayerModel = null
var _cam: Camera3D = null
var _shots := 0
const SHOTS := 12
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
_out = args[0] if args.size() > 0 else "."
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.3
env.environment = e
scene.add_child(env)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, 35, 0)
scene.add_child(sun)
_model = SkinnedPlayerModel.new()
_model.model_path = "res://assets/characters/skins/taila.glb"
scene.add_child(_model)
_cam = Camera3D.new()
_cam.fov = 28.0
scene.add_child(_cam)
_cam.current = true
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 10 or not _model.loaded:
return false
_model.update_state("ground", 0.0, false)
_model.set_locomotion(0.0, 0.0, 0.0)
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
# 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.08, -0.9)
_cam.look_at(Vector3(0, hips - 0.14, 0), Vector3.UP)
# Let the chains settle before recording — the first second is the model
# dropping into its hanging pose, which is not jitter.
if _frames > 130 and _shots < SHOTS:
root.get_texture().get_image().save_png("%s/idle_%02d.png" % [_out, _shots])
_shots += 1
if _shots == SHOTS:
print("saved %d idle frames" % SHOTS)
return true
return _frames > 400
+1
View File
@@ -0,0 +1 @@
uid://6qrwda6ux54f
+138
View File
@@ -0,0 +1,138 @@
extends SceneTree
## Dev tool: how well do the sidecar's leg capsules actually enclose the leg?
##
## godot --headless --path . -s res://debug/leg_radius_check.gd -- [skin_glb]
##
## tools/retarget.py sizes each capsule from the MEDIAN distance of the limb's
## own vertices, which by construction leaves half the leg's surface outside the
## collider. Cloth is then pushed out to a shape narrower than the leg it is
## meant to clear, so the solver reports the panel as clear while the thigh is
## visibly through it in the render — the metric and the eye disagree, and the
## eye is right.
##
## Prints, per capsule end, the percentile spread of the real vertex distances
## next to the radius actually shipped.
const PCTS := [0.5, 0.75, 0.85, 0.95, 1.0]
var _frames := 0
var _model: SkinnedPlayerModel = null
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
var info = JSON.parse_string(FileAccess.get_file_as_string(
_model.model_path.get_basename() + ".rig.json"))
if typeof(info) != TYPE_DICTIONARY:
print("no sidecar")
return true
# Every vertex, tagged with the bone that dominates it.
var owned := {} # bone index -> PackedVector3Array of rest positions
for mi in _model.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
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]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
for v in verts.size():
var bw := 0.0
var bind := -1
var q := Vector3.ZERO
for k in per:
var w: float = weights[v * per + k]
var bi: int = bone_of[bones[v * per + k]]
if bi < 0 or w <= 0.0:
continue
q += (skel.get_bone_global_rest(bi)
* skin.get_bind_pose(bones[v * per + k]) * verts[v]) * w
if w > bw:
bw = w
bind = bi
if bind < 0 or bw < 0.5:
continue
if not owned.has(bind):
owned[bind] = PackedVector3Array()
owned[bind].append(q)
for c in info.get("colliders", []):
var a_i := skel.find_bone(String(c.get("bone", "")))
var b_i := skel.find_bone(String(c.get("child", "")))
if a_i < 0 or b_i < 0:
continue
var a := skel.get_bone_global_rest(a_i).origin
var b := skel.get_bone_global_rest(b_i).origin
var ab := b - a
var d2 := ab.length_squared()
# The limb is this bone plus any twist segment hanging off it — the same
# grouping tools/retarget.py uses when it sizes the capsule.
var base := String(c.get("bone", ""))
var pts := PackedVector3Array()
for bi in owned:
var n := skel.get_bone_name(bi)
if n == base or n.begins_with(base + "."):
pts.append_array(owned[bi])
# Per-tenth of the limb, so the real taper is visible instead of two
# lumps. The head band is where a thigh stops being a thigh and becomes
# the hip, and that is exactly the band a two-point capsule has to guess.
var bands: Array = []
for _b in 10:
bands.append(PackedFloat32Array())
for p in pts:
var t: float = 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
bands[clampi(int(t * 10.0), 0, 9)].append(p.distance_to(a + ab * t))
var rh := float(c.get("radius_head", 0.0))
var rt := float(c.get("radius_tail", 0.0))
print("%s (%d verts) shipped head %.4f tail %.4f" % [
base, pts.size(), rh, rt])
for band in 10:
var v: PackedFloat32Array = bands[band]
if v.is_empty():
continue
v.sort()
var i: int = clampi(int(0.88 * (v.size() - 1)), 0, v.size() - 1)
var mid: int = v.size() / 2
var t := (float(band) + 0.5) / 10.0
print(" t %.2f n%-5d p50 %.4f p88 %.4f p100 %.4f capsule %.4f" % [
t, v.size(), v[mid], v[i], v[v.size() - 1], lerpf(rh, rt, t)])
return true
func _spread(v: PackedFloat32Array) -> String:
if v.is_empty():
return "(none)"
var out := ""
for p in PCTS:
var i: int = clampi(int(p * (v.size() - 1)), 0, v.size() - 1)
out += "p%02d %.4f " % [int(p * 100.0), v[i]]
return out
+1
View File
@@ -0,0 +1 @@
uid://co25textiw4vq
+316
View File
@@ -0,0 +1,316 @@
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
+1
View File
@@ -0,0 +1 @@
uid://4skd3rvnpm4s
+91
View File
@@ -0,0 +1,91 @@
extends SceneTree
## Dev tool: close-up of the hip/thigh region through a run cycle.
##
## godot --path . --windowed --resolution 900x900 -s res://debug/skirt_closeup.gd -- <out_dir> [skin]
##
## debug/anim_capture.gd frames the whole character in 1280x720, which is far
## too small to judge whether a thigh is poking through a skirt — the question
## this exists to answer. Camera sits ~0.9 m from the hips at hip height and
## saves front and side views at several points across the stride.
var _frames := 0
var _out := "."
var _model: SkinnedPlayerModel = null
var _cam: Camera3D = null
var _shots := 0
const SHOT_EVERY := 7
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.15, 0.15, 0.2)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(1, 1, 1)
e.ambient_light_energy = 1.6
env.environment = e
scene.add_child(env)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-40, 35, 0)
sun.light_energy = 1.4
scene.add_child(sun)
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
_cam = Camera3D.new()
_cam.fov = 26.0
scene.add_child(_cam)
_cam.current = true
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 10 or not _model.loaded:
return false
_model.update_state("ground", 9.0, false)
_model.set_locomotion(0.0, 1.0, 0.0)
# Frame the hips: that is where a skirt meets a thigh.
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
# Position on one frame, capture on the next: awaiting inside _process turns
# it into a coroutine and the SceneTree stops driving it.
if _frames > 20 and _shots < 8:
var phase := _frames % SHOT_EVERY
if phase == 0:
var side := (_shots % 2) == 1
if side:
_cam.position = Vector3(0.85, hips - 0.02, 0.0)
else:
# 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.02, -0.85)
_cam.look_at(Vector3(0, hips - 0.16, 0), Vector3.UP)
elif phase == 1:
var tag := "side" if (_shots % 2) == 1 else "front"
root.get_texture().get_image().save_png(
"%s/skirt_%s_%d.png" % [_out, tag, _shots])
print("saved skirt_%s_%d.png" % [tag, _shots])
_shots += 1
if _shots >= 8:
return true
if _frames > 200:
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://5mxt3sn3sq1v
+145
View File
@@ -0,0 +1,145 @@
extends SceneTree
## Dev tool: WHY is the thigh inside the skirt?
##
## godot --headless --path . -s res://debug/skirt_probe.gd
##
## debug/cloth_clip_check.gd says how deep the leg is inside the cloth;
## this says which part of the solver let it in. For every cloth bone it reports,
## at the worst moment of a run cycle:
##
## TRUE how far the deepest hull point is inside the REAL leg capsule
## SEEN how far the solver thinks it is inside — i.e. after the per-point
## rest-clearance allowance in SpringBones._rest_clearances
## t where along the capsule that point sits (0 = hip joint, 1 = knee)
##
## TRUE >> SEEN means the allowance is the leak: the collision is satisfied while
## the leg is still visibly through the cloth. TRUE ~= SEEN means the solver sees
## the penetration and cannot correct it, which is a degree-of-freedom problem.
##
## Measured from an observer SkeletonModifier3D added AFTER SpringBones — see the
## rig-pipeline notes: _process runs before the modifier stack (rest pose) and
## force_update_all_bone_transforms() re-runs it (double-steps the solver).
const SWEEP := [["ground", 9.0], ["ground", 3.0], ["air", 6.0],
["air", -8.0], ["slide", 10.0], ["dash", 14.0]]
const FRAMES_PER_STATE := 40
var _frames := 0
var _model: SkinnedPlayerModel = null
var _probe: Node = null
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8 or not _model.loaded:
return false
var skel: Skeleton3D = _model.skeleton
if skel == null:
return true
if _probe == null:
var spring := skel.get_node_or_null("SpringBones")
if spring == null:
print("no SpringBones on this model — nothing to probe")
return true
_probe = Probe.new()
_probe.name = "SkirtProbe"
_probe.spring = spring
skel.add_child(_probe) # AFTER SpringBones, so it sees the final pose
return false
var phase: int = clampi((_frames - 9) / FRAMES_PER_STATE, 0, SWEEP.size() - 1)
_model.update_state(SWEEP[phase][0], SWEEP[phase][1], false)
_model.set_locomotion(0.0, 1.0, 0.0)
if _frames > 9 + FRAMES_PER_STATE * SWEEP.size():
_probe.effort = _model.skeleton.get_node("SpringBones").debug_effort_report()
_probe.report()
return true
return false
## Reads the solver's own chain/collider tables and re-tests them against the
## final pose, so the numbers are the ones the solver actually acted on.
class Probe extends SkeletonModifier3D:
var spring: Node = null
var worst: Dictionary = {} # bone name -> [true_pen, seen_pen, t, class]
var effort: Dictionary = {}
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or spring == null:
return
var to_world := skel.global_transform
var cols: Array = spring._colliders
for chain in spring._chains:
var bones: PackedInt32Array = chain["bones"]
var tips: PackedVector3Array = chain["tips"]
var hulls: Array = chain["hulls"]
var radii: Array = chain["radii"]
if radii.is_empty():
continue # hair — does not collide with the legs by design
for i in bones.size():
var bone: int = bones[i]
var posed: Transform3D = to_world * skel.get_bone_global_pose(bone)
var pts := SpringBones._sample_points(
posed, posed.origin, posed * tips[i], hulls[i])
var rec: Dictionary = radii[i]
var caps: PackedFloat32Array = rec.get("cap", PackedFloat32Array())
var name := skel.get_bone_name(bone)
for c in cols.size():
var col: Dictionary = cols[c]
# Same `from` offset the solver uses, or this reports overlap with
# a part of the leg nothing is being asked to clear.
var ends: Array = spring._capsule(skel, to_world, col)
var a: Vector3 = ends[0]
var b: Vector3 = ends[1]
var ab := b - a
var d2 := ab.length_squared()
for j in pts.size():
var p: Vector3 = pts[j]
var t := 0.0 if d2 < 0.000001 \
else clampf((p - a).dot(ab) / d2, 0.0, 1.0)
var dist := p.distance_to(a + ab * t)
var r_true: float = lerpf(float(col["rh"]), float(col["rt"]), t)
var idx := c * pts.size() + j
var r_seen: float = minf(r_true,
caps[idx] if idx < caps.size() else r_true)
var pen := r_true - dist
if pen > float(worst.get(name, [0.0])[0]):
# `reach` is the lever the solver has on this point:
# a collision is resolved by ROTATING the bone, so a
# point sitting almost on the bone's own head cannot
# be moved by it at all, however deep it is.
# `above` is its height over the hip joint — anything
# positive is inside the fictional sphere the capsule
# puts at the top of the thigh, not inside the leg.
worst[name] = [pen, r_seen - dist, t,
(p - posed.origin).length(), p.y - a.y]
func report() -> void:
var skel := get_skeleton()
print("\n=== deepest leg-in-cloth per bone (worst over the sweep) ===")
print(" %-26s %8s %8s %6s %8s %9s %8s %8s" % [
"bone", "TRUE mm", "SEEN mm", "t", "reach mm", "above mm", "fix deg", "left mm"])
var keys := worst.keys()
keys.sort_custom(func(a, b): return worst[a][0] > worst[b][0])
for k in keys:
var w: Array = worst[k]
if w[0] <= 0.0005:
continue
var ef: Array = effort.get(skel.find_bone(k), [0.0, 0.0])
print(" %-26s %8.1f %8.1f %6.2f %8.1f %9.1f %8.1f %8.1f" % [
k, w[0] * 1000.0, w[1] * 1000.0, w[2], w[3] * 1000.0, w[4] * 1000.0,
ef[0], ef[1]])
print("")
+1
View File
@@ -0,0 +1 @@
uid://b6wvfdpt1whek
+129
View File
@@ -0,0 +1,129 @@
extends SceneTree
## Fixed simulation step — see _lock_timestep.
const STEP := 1.0 / 60.0
## Dev tool: the front of the skirt through a whole run cycle, as a player sees it.
##
## godot --path . --windowed --resolution 1280x960 \
## -s res://debug/skirt_run_view.gd -- <out_dir> [skin]
##
## Every other check in debug/ looks at this from 0.9 m with flat colours or from
## a solver's point of view. Those answer "is a bone inside a capsule" and "did
## the mesh tear", and both can read clean while the render is obviously wrong —
## a material that does not occlude, an outline shell drawn over the cloth, or
## simply a pose none of the sampled frames happened to catch.
##
## So: the shipped materials, a straight-on front camera at normal viewing
## distance, and EVERY frame of the run cycle rather than eight samples of it.
const FIRST := 40 # let the chains settle before recording
const FRAMES := 48 # a full stride at 60 fps and then some
var _frames := 0
var _out := "."
var _model: SkinnedPlayerModel = null
var _cam: Camera3D = null
var _shots := 0
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.16, 0.16, 0.2)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(1, 1, 1)
e.ambient_light_energy = 1.5
env.environment = e
scene.add_child(env)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-35, 25, 0)
sun.light_energy = 1.3
scene.add_child(sun)
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
_cam = Camera3D.new()
_cam.fov = 38.0
scene.add_child(_cam)
_cam.current = true
## 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 _frames == 10:
_lock_timestep()
# `nospring` renders the same cycle with the cloth solver removed, so a
# change can be told from no change at all.
var a := OS.get_cmdline_user_args()
if a.size() > 2 and String(a[2]) == "nospring":
var sp := _model.skeleton.get_node_or_null("SpringBones")
if sp:
sp.queue_free()
print("spring solver REMOVED")
_model.update_state("ground", 9.0, 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
# Waist to knee, dead in front, from about where a third-person camera sits.
#
# NEGATIVE Z. SkinnedPlayerModel spins the imported scene 180 degrees
# (`facing_flip`: glTF forward is +Z, players face -Z), so a camera on +Z is
# looking at the character's BACK. Every earlier tool in here sat on +Z and
# every "front" render judged from them was the back of the skirt.
_cam.position = Vector3(0.0, hips - 0.05, -1.5)
_cam.look_at(Vector3(0, hips - 0.20, 0), Vector3.UP)
if _frames >= FIRST and _shots < FRAMES:
root.get_texture().get_image().save_png(
"%s/run_%02d.png" % [_out, _shots])
_shots += 1
if _shots == FRAMES:
print("saved %d run frames" % FRAMES)
return true
return _frames > FIRST + FRAMES * 3
+1
View File
@@ -0,0 +1 @@
uid://cxoj83cl5nnso
+81
View File
@@ -0,0 +1,81 @@
extends SceneTree
## Dev tool: how abruptly does the model change posture when you start running?
##
## godot --headless --path . -s res://debug/transition_check.gd -- [skin_glb]
##
## Accelerates from a standstill the way the movement code does, then stops, and
## logs the clip in play plus the body's forward lean each frame. Reports the
## worst single-frame change in lean and how long the lean took to arrive.
##
## Written for "idle to running snaps the character leaning forward". The lean
## is procedural (ShooterPoseModifier), so it does NOT come from the clip
## crossfade and is not visible in an animation-blend graph — it was driven by a
## normalised input direction that steps 0 -> 1 the instant a key goes down,
## planting a full run posture in ~0.1 s while the Idle->Run crossfade still had
## 0.4 s to run.
const ACCEL := 18.0 # m/s^2, roughly the controller's ground acceleration
const TOP := 9.0 # m/s
const DT := 1.0 / 60.0
var _t := 0.0
var _frames := 0
var _model: SkinnedPlayerModel = null
var _speed := 0.0
var _prev_lean := 0.0
var _worst_step := 0.0
var _clip_changes: Array = []
var _last_clip := ""
var _t_10 := -1.0
var _t_90 := -1.0
var _peak := 0.0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := Node3D.new()
root.add_child(scene)
current_scene = scene
_model = SkinnedPlayerModel.new()
_model.model_path = path
scene.add_child(_model)
func _process(_delta: float) -> bool:
_frames += 1
if _frames < 8:
return false
if not _model.loaded:
return false
# Accelerate for 1.5 s, then coast at top speed to 2.5 s.
_t += DT
_speed = minf(TOP, _speed + ACCEL * DT) if _t < 2.5 else 0.0
_model.update_state("ground", _speed, false)
_model.set_locomotion(0.0, 1.0 if _speed > 0.01 else 0.0, 0.0)
var lean: float = _model.get_lean_debug()
if _last_clip != _model.current_clip_debug():
_last_clip = _model.current_clip_debug()
_clip_changes.append("%.2fs %s @ %.1f m/s" % [_t, _last_clip, _speed])
if _t < 2.5:
_peak = maxf(_peak, lean)
if _t_10 < 0.0 and lean > 0.1:
_t_10 = _t
if _t_90 < 0.0 and lean > 0.9:
_t_90 = _t
_worst_step = maxf(_worst_step, absf(lean - _prev_lean))
_prev_lean = lean
if _t > 3.5:
print("\n=== idle -> run transition ===")
print(" clip changes: " + ", ".join(_clip_changes))
print(" lean reached 10%% at %.2fs, 90%% at %.2fs (peak %.2f)" % [_t_10, _t_90, _peak])
print(" worst single-frame lean change: %.4f (%.2f per second at 60fps)"
% [_worst_step, _worst_step * 60.0])
print(" a snap looks like ~0.10s to 90%%; a blended move is ~0.6s or more\n")
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://deeo5pasohk5g