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:
co-authored by
Claude Opus 5
parent
374d9f9822
commit
0dd9d01ac7
@@ -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
|
||||
@@ -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("")
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4x5gy6vjcvb0
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bk7pst2vhawvt
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ccx4eh7wgfwrh
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://6qrwda6ux54f
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://co25textiw4vq
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://4skd3rvnpm4s
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://5mxt3sn3sq1v
|
||||
@@ -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("")
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6wvfdpt1whek
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxoj83cl5nnso
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://deeo5pasohk5g
|
||||
Reference in New Issue
Block a user