Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
24 changed files with 3322 additions and 170 deletions
Showing only changes of commit 0dd9d01ac7 - Show all commits
+195 -10
View File
@@ -136,7 +136,26 @@ var _cur_ads: float = 0.0
var _cur_slide: float = 0.0 var _cur_slide: float = 0.0
var _cur_wall: float = 0.0 var _cur_wall: float = 0.0
var _owner_visible: bool = false 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 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: func _ready() -> void:
@@ -200,7 +219,8 @@ func load_model(path: String) -> void:
_spring_mod = SpringBones.new() _spring_mod = SpringBones.new()
_spring_mod.name = "SpringBones" _spring_mod.name = "SpringBones"
skeleton.add_child(_spring_mod) 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: if driven == 0:
_spring_mod.queue_free() _spring_mod.queue_free()
_spring_mod = null _spring_mod = null
@@ -231,6 +251,101 @@ func load_model(path: String) -> void:
_play_clip("Idle") _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. ## 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 ## 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 ## Same contract as HumanoidModel.update_state(). Called by the movement
## controller each frame with either local or network-synced state. ## controller each frame with either local or network-synced state.
func update_state(state: String, speed: float, is_crouching: bool = false) -> void: func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
_speed = speed
if not loaded or not animation_player: if not loaded or not animation_player:
return return
@@ -454,12 +570,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
clip = "Dance" clip = "Dance"
elif is_crouching: elif is_crouching:
clip = "CrouchWalk" if speed > 0.5 else "Crouch" clip = "CrouchWalk" if speed > 0.5 else "Crouch"
elif speed > run_anim_reference_speed * 1.35: else:
clip = "Sprint" clip = _loco_clip(speed)
elif speed > walk_anim_reference_speed * 1.2:
clip = "Run"
elif speed > 0.5:
clip = "Walk"
# Armed idle uses the plain Idle clip — the rifle-hold pose layer # Armed idle uses the plain Idle clip — the rifle-hold pose layer
# owns the arms, so the odd arms-crossed PistolIdle base reads worse. # owns the arms, so the odd arms-crossed PistolIdle base reads worse.
"air": "air":
@@ -529,17 +641,36 @@ func set_grapple_target(point_world: Vector3) -> void:
_grapple_point_world = point_world _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: func _process(delta: float) -> void:
_update_cloth_lod(delta)
if not _pose_mod: if not _pose_mod:
return return
var t := 1.0 - exp(-POSE_SMOOTH * delta) var t := 1.0 - exp(-POSE_SMOOTH * delta)
_cur_strafe = lerpf(_cur_strafe, _target_strafe, t) # The body lean gets its own, much slower rate, and is scaled by how fast the
_cur_fwd = lerpf(_cur_fwd, _target_fwd, t) # 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) _cur_ads = lerpf(_cur_ads, _target_ads, t)
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0 var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
_cur_slide = lerpf(_cur_slide, slide_target, t) _cur_slide = lerpf(_cur_slide, slide_target, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0 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.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd _pose_mod.fwd = _cur_fwd
_pose_mod.ads = _cur_ads _pose_mod.ads = _cur_ads
@@ -611,6 +742,50 @@ func _process(delta: float) -> void:
_pose_mod.reload_phase = rl_target _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: func _play_clip(canonical: String, restart: bool = false) -> void:
if not _anim_tree or not _resolved_clips.has(canonical): if not _anim_tree or not _resolved_clips.has(canonical):
return return
@@ -622,6 +797,16 @@ func _play_clip(canonical: String, restart: bool = false) -> void:
_current_clip = clip_name _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). ## Vertical velocity of the body this model is attached to (0 if detached).
func _vertical_speed() -> float: func _vertical_speed() -> float:
var p := get_parent() 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