Feat/outline thickness and tp weapon hold #22
@@ -136,7 +136,26 @@ var _cur_ads: float = 0.0
|
||||
var _cur_slide: float = 0.0
|
||||
var _cur_wall: float = 0.0
|
||||
var _owner_visible: bool = false
|
||||
## Horizontal speed from the last update_state, so the lean can scale with how
|
||||
## fast the character is really moving.
|
||||
var _speed: float = 0.0
|
||||
var _loco_tier: int = 0
|
||||
var _tier_age: float = 0.0
|
||||
const POSE_SMOOTH := 10.0
|
||||
## Separate, slower rate for the whole-body lean. Roughly a 0.36 s time constant,
|
||||
## so the posture arrives with the clip crossfade instead of a tenth of a second
|
||||
## ahead of it.
|
||||
const LEAN_SMOOTH := 4.5
|
||||
## Ground locomotion tiers, slowest first, and how far below the promoting
|
||||
## threshold the speed must fall before dropping back a tier.
|
||||
const LOCO_TIERS := ["Idle", "Walk", "Run", "Sprint"]
|
||||
const LOCO_HYSTERESIS := 0.78
|
||||
## A locomotion tier is held at least this long before another change is
|
||||
## allowed. Hard acceleration genuinely passes through walking pace in about a
|
||||
## fifth of a second, so without this Walk got 0.19 s — less than half of its own
|
||||
## 0.40 s crossfade — and was cut off mid-blend by Run. Multi-tier jumps still
|
||||
## happen in one step, so this delays nothing that was not already a blur.
|
||||
const MIN_TIER_DWELL := 0.22
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -200,7 +219,8 @@ func load_model(path: String) -> void:
|
||||
_spring_mod = SpringBones.new()
|
||||
_spring_mod.name = "SpringBones"
|
||||
skeleton.add_child(_spring_mod)
|
||||
var driven := _spring_mod.setup(skeleton, _rig_info)
|
||||
var driven := _spring_mod.setup(skeleton, _rig_info,
|
||||
_cloth_hulls(scene))
|
||||
if driven == 0:
|
||||
_spring_mod.queue_free()
|
||||
_spring_mod = null
|
||||
@@ -231,6 +251,101 @@ func load_model(path: String) -> void:
|
||||
_play_clip("Idle")
|
||||
|
||||
|
||||
## Collision hulls for the cloth solver, taken from the MESH rather than from
|
||||
## the sidecar: bone name -> the points that bone drives, in its own rest space.
|
||||
##
|
||||
## The sidecar carries ten farthest-point samples per cloth bone, which is a good
|
||||
## description of a panel's OUTLINE and a poor one of a panel. Farthest-point
|
||||
## sampling lands on corners, edges and the hem; a thigh comes up through the
|
||||
## MIDDLE of a panel, between every sample, and the solver reported each frame's
|
||||
## contacts fully resolved while 158 vertices sat 95 mm inside a leg.
|
||||
##
|
||||
## The runtime has the actual mesh, so it does not have to guess. Every vertex a
|
||||
## cloth bone dominates is binned into a ~16 mm grid and one representative per
|
||||
## cell is kept, which covers a panel evenly for a bounded number of points —
|
||||
## unlike keeping every vertex, which would be thousands of collision tests per
|
||||
## frame for no extra accuracy at the scale a limb is shaped.
|
||||
const HULL_CELL := 0.020
|
||||
const HULL_MAX := 14
|
||||
|
||||
func _cloth_hulls(scene: Node) -> Dictionary:
|
||||
var cloth := {}
|
||||
for c in _rig_info.get("chains", []):
|
||||
for n in c.get("bones", []):
|
||||
var bi := skeleton.find_bone(String(n))
|
||||
if bi >= 0:
|
||||
cloth[bi] = true
|
||||
if cloth.is_empty():
|
||||
return {}
|
||||
|
||||
# bone -> cell key -> the vertex nearest that cell's centre.
|
||||
var cells := {}
|
||||
for mi in scene.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh == null or mi.skin == null:
|
||||
continue
|
||||
var skin: Skin = mi.skin
|
||||
var bone_of := {}
|
||||
for b in skin.get_bind_count():
|
||||
var bi := skin.get_bind_bone(b)
|
||||
if bi < 0:
|
||||
bi = skeleton.find_bone(skin.get_bind_name(b))
|
||||
bone_of[b] = bi
|
||||
for s in mi.mesh.get_surface_count():
|
||||
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||
if bones.is_empty() or verts.is_empty():
|
||||
continue
|
||||
var per: int = bones.size() / verts.size()
|
||||
for v in verts.size():
|
||||
# A vertex belongs to whichever bone holds the largest share of
|
||||
# it — that is the bone whose motion actually decides where it
|
||||
# ends up, and so the bone that has to keep it out of a leg.
|
||||
var best := 0.0
|
||||
var bind := -1
|
||||
for k in per:
|
||||
var w: float = weights[v * per + k]
|
||||
if w > best:
|
||||
best = w
|
||||
bind = bones[v * per + k]
|
||||
if bind < 0 or best < 0.5:
|
||||
continue
|
||||
var bi: int = bone_of.get(bind, -1)
|
||||
if not cloth.has(bi):
|
||||
continue
|
||||
# The bind pose maps a vertex straight into its bone's rest
|
||||
# space, which is exactly the frame the solver poses hulls in.
|
||||
var local: Vector3 = skin.get_bind_pose(bind) * verts[v]
|
||||
var key := "%d_%d_%d" % [
|
||||
int(round(local.x / HULL_CELL)),
|
||||
int(round(local.y / HULL_CELL)),
|
||||
int(round(local.z / HULL_CELL))]
|
||||
if not cells.has(bi):
|
||||
cells[bi] = {}
|
||||
if not cells[bi].has(key):
|
||||
cells[bi][key] = local
|
||||
|
||||
var out := {}
|
||||
var total := 0
|
||||
for bi in cells:
|
||||
var pts: Array = cells[bi].values()
|
||||
if pts.size() > HULL_MAX:
|
||||
# Keep the OUTERMOST cells. What clips is the part of a panel
|
||||
# furthest from the bone it hangs on, and the grid has already made
|
||||
# sure those are spread over the whole sheet rather than clustered.
|
||||
pts.sort_custom(func(a, b): return a.length_squared() > b.length_squared())
|
||||
pts = pts.slice(0, HULL_MAX)
|
||||
var packed := PackedVector3Array()
|
||||
for p in pts:
|
||||
packed.append(p)
|
||||
out[skeleton.get_bone_name(bi)] = packed
|
||||
total += packed.size()
|
||||
print("SkinnedPlayerModel: cloth hulls from mesh — %d bones, %d points"
|
||||
% [out.size(), total])
|
||||
return out
|
||||
|
||||
|
||||
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
|
||||
##
|
||||
## Its presence is also the signal that this model kept its OWN skeleton and
|
||||
@@ -429,6 +544,7 @@ func add_gun_recoil(strength: float = 1.0) -> void:
|
||||
## Same contract as HumanoidModel.update_state(). Called by the movement
|
||||
## controller each frame with either local or network-synced state.
|
||||
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
|
||||
_speed = speed
|
||||
if not loaded or not animation_player:
|
||||
return
|
||||
|
||||
@@ -454,12 +570,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
||||
clip = "Dance"
|
||||
elif is_crouching:
|
||||
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
|
||||
elif speed > run_anim_reference_speed * 1.35:
|
||||
clip = "Sprint"
|
||||
elif speed > walk_anim_reference_speed * 1.2:
|
||||
clip = "Run"
|
||||
elif speed > 0.5:
|
||||
clip = "Walk"
|
||||
else:
|
||||
clip = _loco_clip(speed)
|
||||
# Armed idle uses the plain Idle clip — the rifle-hold pose layer
|
||||
# owns the arms, so the odd arms-crossed PistolIdle base reads worse.
|
||||
"air":
|
||||
@@ -529,17 +641,36 @@ func set_grapple_target(point_world: Vector3) -> void:
|
||||
_grapple_point_world = point_world
|
||||
|
||||
|
||||
## How far away each cloth detail level starts, in metres. See SpringBones.lod —
|
||||
## the solver is expensive enough that only the character being looked at can
|
||||
## afford the full thing.
|
||||
const CLOTH_LOD_RANGES := [6.0, 14.0, 28.0]
|
||||
var _lod_timer: float = 0.0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_update_cloth_lod(delta)
|
||||
if not _pose_mod:
|
||||
return
|
||||
var t := 1.0 - exp(-POSE_SMOOTH * delta)
|
||||
_cur_strafe = lerpf(_cur_strafe, _target_strafe, t)
|
||||
_cur_fwd = lerpf(_cur_fwd, _target_fwd, t)
|
||||
# The body lean gets its own, much slower rate, and is scaled by how fast the
|
||||
# character is ACTUALLY moving rather than by which key is held.
|
||||
#
|
||||
# The controller passes a normalised input direction, so `fwd` jumps 0 -> 1
|
||||
# the instant W is pressed. At the shared rate that planted the full forward
|
||||
# lean in about a tenth of a second while the Idle->Run crossfade was still
|
||||
# 0.4 s from finishing — the body snapped into a run posture ahead of the run
|
||||
# cycle. Tying it to speed means the lean now grows as the character
|
||||
# accelerates, and lands with the clip.
|
||||
var lean_t := 1.0 - exp(-LEAN_SMOOTH * delta)
|
||||
var drive: float = clampf(_speed / maxf(run_anim_reference_speed, 0.01), 0.0, 1.0)
|
||||
_cur_strafe = lerpf(_cur_strafe, _target_strafe * drive, lean_t)
|
||||
_cur_fwd = lerpf(_cur_fwd, _target_fwd * drive, lean_t)
|
||||
_cur_ads = lerpf(_cur_ads, _target_ads, t)
|
||||
var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0
|
||||
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
||||
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
||||
_cur_wall = lerpf(_cur_wall, wall_target, t)
|
||||
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
|
||||
_pose_mod.strafe = _cur_strafe
|
||||
_pose_mod.fwd = _cur_fwd
|
||||
_pose_mod.ads = _cur_ads
|
||||
@@ -611,6 +742,50 @@ func _process(delta: float) -> void:
|
||||
_pose_mod.reload_phase = rl_target
|
||||
|
||||
|
||||
## Pick the cloth solver's detail level from how far the camera is.
|
||||
##
|
||||
## Re-checked a few times a second rather than every frame: the answer changes
|
||||
## slowly, and the distance query is not free either.
|
||||
func _update_cloth_lod(delta: float) -> void:
|
||||
if _spring_mod == null:
|
||||
return
|
||||
_lod_timer -= delta
|
||||
if _lod_timer > 0.0:
|
||||
return
|
||||
_lod_timer = 0.25
|
||||
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
|
||||
if cam == null:
|
||||
return
|
||||
var d := cam.global_position.distance_to(global_position)
|
||||
var want := CLOTH_LOD_RANGES.size()
|
||||
for i in CLOTH_LOD_RANGES.size():
|
||||
if d < CLOTH_LOD_RANGES[i]:
|
||||
want = i
|
||||
break
|
||||
_spring_mod.lod = want
|
||||
|
||||
|
||||
## Locomotion clip for a ground speed, with HYSTERESIS.
|
||||
##
|
||||
## The thresholds used to be a bare elif chain, so a character accelerating from
|
||||
## a standstill crossed all three in under a second and each crossfade cut off
|
||||
## the one before it — and any speed hovering on a boundary flickered between
|
||||
## two clips forever. Dropping back down needs the speed to fall well under the
|
||||
## threshold that promoted it, so a tier, once entered, is committed to.
|
||||
func _loco_clip(speed: float) -> String:
|
||||
_tier_age += get_process_delta_time()
|
||||
var up := [0.5, walk_anim_reference_speed * 1.2, run_anim_reference_speed * 1.35]
|
||||
var want := _loco_tier
|
||||
while want < LOCO_TIERS.size() - 1 and speed > up[want]:
|
||||
want += 1
|
||||
while want > 0 and speed < up[want - 1] * LOCO_HYSTERESIS:
|
||||
want -= 1
|
||||
if want != _loco_tier and _tier_age >= MIN_TIER_DWELL:
|
||||
_loco_tier = want
|
||||
_tier_age = 0.0
|
||||
return LOCO_TIERS[_loco_tier]
|
||||
|
||||
|
||||
func _play_clip(canonical: String, restart: bool = false) -> void:
|
||||
if not _anim_tree or not _resolved_clips.has(canonical):
|
||||
return
|
||||
@@ -622,6 +797,16 @@ func _play_clip(canonical: String, restart: bool = false) -> void:
|
||||
_current_clip = clip_name
|
||||
|
||||
|
||||
## Current smoothed forward lean, 0..1. For debug/transition_check.gd.
|
||||
func get_lean_debug() -> float:
|
||||
return _cur_fwd
|
||||
|
||||
|
||||
## Clip currently playing. For debug/transition_check.gd.
|
||||
func current_clip_debug() -> String:
|
||||
return _current_clip
|
||||
|
||||
|
||||
## Vertical velocity of the body this model is attached to (0 if detached).
|
||||
func _vertical_speed() -> float:
|
||||
var p := get_parent()
|
||||
|
||||
+1137
-158
File diff suppressed because it is too large
Load Diff
@@ -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