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