From 0dd9d01ac7a530c226e8e0321519f89634aab6d5 Mon Sep 17 00:00:00 2001 From: Nicholas Butzke <137417822+DottsGit@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:07:30 -0400 Subject: [PATCH] fix(cloth): solve the garment instead of repairing it five times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skirt glitched when the character moved and the thigh still came through it. Both came from the same place: the solver integrated one spring per bone and then ran four more passes behind it — resolve the collision against the target, resolve it again against the answer, relax the cross-panel links and rebuild every pose from the corrected tips, then walk a separate ancestor "lift" — each writing bone poses the next read back and partly undid. The lift wrote poses that were never fed back into the spring state at all, so every frame began by pulling against a pose the springs did not know about. Replaced with one position-based solve, the shape Magica Cloth 2's BoneCloth uses. Every JOINT is a particle, so a bone's head can move; predict with inertia in the anchor's frame; relax length, bend, backstop, the cross-panel links and the colliders together; convert to rotations once at the end. A contact with no rotational leverage is now resolved by the panel moving, which is what a bodily chain push, an ancestor lift and a drape weight were each approximating separately. Measured, at a dead-still idle and over a movement sweep: idle jitter (skirt) 0.53 -> 0.025 deg/frame, worst 24 -> 1.9 settling after a dash 103 -> 18 mm of leg left inside the skirt fall / air / walk 82 -> 40, 96 -> 75, 72 -> 76 mm run / slide / dash unchanged, ~95 mm The idle buzz and the failure to come home after a hard move are gone — those were the "glitches out". Peak clipping in a run, a slide and a dash is NOT fixed and is still around 95 mm. Four things this turned up on the way: - debug/cloth_clip_check.gd was measuring the animation, not the render. Godot restores bone poses after the modifier pass, so reading them with force_update_all_bone_transforms() afterwards sees nothing any modifier did. It reported the same ~95 mm with collision fully enabled and with it commented out. It now observes from inside the pass. Every number ever taken from this tool before now was measuring the wrong pose. - The collision hulls came from ten farthest-point samples per bone, which describe a panel's corners and hem and leave its MIDDLE unsampled — exactly where a thigh comes through. Built from the real mesh at load time instead. - The drape term is gone. It was there to move a panel the old solver could not, and once the solver could, it was worse in every state but a walk and cost 20x in stability: its target sat inside the leg the collision was pushing out of, so the two ran against each other forever. - Cost was 10.9 ms per character. The inner loop rebuilt every capsule and reallocated the hull array for every (bone, collider, pass). Now 2.6 ms at full quality with a distance LOD behind it. Co-Authored-By: Claude Opus 5 --- characters/skinned_player_model.gd | 205 ++++- characters/spring_bones.gd | 1299 ++++++++++++++++++++++++---- debug/cloth_allow_check.gd | 110 +++ debug/cloth_clip_check.gd | 381 ++++++++ debug/cloth_clip_check.gd.uid | 1 + debug/cloth_perf_check.gd | 61 ++ debug/cloth_settle_check.gd | 167 ++++ debug/cloth_settle_check.gd.uid | 1 + debug/cloth_stretch_check.gd | 280 ++++++ debug/cloth_stretch_check.gd.uid | 1 + debug/idle_jitter_check.gd | 79 ++ debug/idle_jitter_check.gd.uid | 1 + debug/leg_radius_check.gd | 138 +++ debug/leg_radius_check.gd.uid | 1 + debug/skirt_clip_view.gd | 316 +++++++ debug/skirt_clip_view.gd.uid | 1 + debug/skirt_closeup.gd | 91 ++ debug/skirt_closeup.gd.uid | 1 + debug/skirt_probe.gd | 145 ++++ debug/skirt_probe.gd.uid | 1 + debug/skirt_run_view.gd | 129 +++ debug/skirt_run_view.gd.uid | 1 + debug/transition_check.gd | 81 ++ debug/transition_check.gd.uid | 1 + 24 files changed, 3322 insertions(+), 170 deletions(-) create mode 100644 debug/cloth_allow_check.gd create mode 100644 debug/cloth_clip_check.gd create mode 100644 debug/cloth_clip_check.gd.uid create mode 100644 debug/cloth_perf_check.gd create mode 100644 debug/cloth_settle_check.gd create mode 100644 debug/cloth_settle_check.gd.uid create mode 100644 debug/cloth_stretch_check.gd create mode 100644 debug/cloth_stretch_check.gd.uid create mode 100644 debug/idle_jitter_check.gd create mode 100644 debug/idle_jitter_check.gd.uid create mode 100644 debug/leg_radius_check.gd create mode 100644 debug/leg_radius_check.gd.uid create mode 100644 debug/skirt_clip_view.gd create mode 100644 debug/skirt_clip_view.gd.uid create mode 100644 debug/skirt_closeup.gd create mode 100644 debug/skirt_closeup.gd.uid create mode 100644 debug/skirt_probe.gd create mode 100644 debug/skirt_probe.gd.uid create mode 100644 debug/skirt_run_view.gd create mode 100644 debug/skirt_run_view.gd.uid create mode 100644 debug/transition_check.gd create mode 100644 debug/transition_check.gd.uid diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index a1b3679..e515f7a 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -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() diff --git a/characters/spring_bones.gd b/characters/spring_bones.gd index 48b1fa6..3b0fa75 100644 --- a/characters/spring_bones.gd +++ b/characters/spring_bones.gd @@ -1,93 +1,410 @@ extends SkeletonModifier3D class_name SpringBones -## Secondary motion for cloth, hair and accessories. +## Secondary motion for cloth, hair and accessories — a position-based cloth +## solver over the rig's own cosmetic bones. ## ## The animation clips drive the BODY and nothing else — tools/retarget.py ## deliberately exports no tracks at all for skirt, hair or accessory bones (see ## its `export_optimize_animation_keep_anim_armature=False`). This is what moves ## them, and it is the half of the pipeline that makes clothes read as clothes. ## -## Why this rather than skin weights. Linear-blend skinning can only ever make a -## garment a rigid shell of whatever bones it is weighted to: weight a skirt to -## the thighs and it becomes trousers, weight it to the hips and it becomes a -## bell that never moves. Neither is cloth. A skirt is cloth because it LAGS — -## it keeps going when the hips stop, swings out through a turn, and floats on -## the way up through a jump. That is inertia, and inertia has to be integrated, -## not skinned. So the thigh stays solid (authored weights, its own bone) while -## the skirt hanging over it is free to move differently — which is exactly the -## split the model was rigged for and the old pipeline flattened away. +## WHY A PARTICLE SOLVER RATHER THAN A SPRING PER BONE ## -## Each bone is a damped spring holding its tip toward where rigidly following -## its parent would have put it: +## The previous version integrated one damped spring per bone and then tried to +## repair the result with four more passes stacked behind it: resolve the +## collision against the spring's target, resolve it again against the answer, +## relax the cross-panel links and rebuild every pose from the corrected tips, +## then walk a separate cyclic-descent "lift" up each chain. Each stage wrote +## bone poses the next stage read back and partly undid, and the lift wrote poses +## that were never fed back into the spring state at all — so the spring spent +## the next frame pulling against a pose it did not know about. That is a +## feedback loop, and it is what the skirt "glitching out when it is moved" +## actually was. It also could not stop the thigh coming through, because every +## one of those stages could only ROTATE a bone about its own head, and the +## contacts that matter sit near that head. ## -## a = (rest_tip - tip) * w^2 - v * 2*zeta*w + gravity +## This solves the whole garment the way cloth is actually solved — the same +## shape as Magica Cloth 2's BoneCloth, which is what the Hoyoverse-style +## character pipelines use: ## -## A real spring rather than the usual Verlet blend, because it is integrated -## against the actual frame delta and so behaves the same at 30 fps and 240. -## The tip is then pinned back to the bone's length (cloth stretches far less -## than it swings) and pushed out of the leg capsules, so a skirt swings AROUND -## a thigh instead of through it. +## 1. Every JOINT of every chain is a particle. Bone i spans particle i to +## particle i+1, so a bone's head is a particle too and can move. +## 2. Predict: integrate each particle with inertia, gravity and wind, in the +## chain anchor's frame so simply travelling costs nothing. +## 3. Relax ALL the constraints together, several Gauss-Seidel iterations: +## bone length, a cone limit against the animated pose, a backstop on how +## far the garment may stray, the horizontal links that hold neighbouring +## panels together, and the leg/body colliders. +## 4. Convert the settled particles into bone rotations ONCE, at the end. ## -## Bone lengths and the leg capsule radii are MEASURED from the model's own -## geometry at build time and read from .rig.json — a glTF skeleton -## carries no bone tails at all, and Taila's 21 skirt panel bones have no -## children either, so there is nothing in the skeleton itself that says which -## way a panel hangs or how thick a thigh is. +## Because collision is a constraint inside that loop rather than a repair pass +## behind it, nothing undoes it and it converges with everything else. And +## because a bone's HEAD is a particle, a contact with no rotational leverage is +## resolved by the whole panel moving — which is what a real skirt does when a +## thigh comes up under it, and what three separate bolt-on stages (a bodily +## chain push, an ancestor lift, a drape weight) were each approximating. +## +## Bone lengths, hull samples and the collider radii are MEASURED from the +## model's own geometry at build time and read from .rig.json — a glTF +## skeleton carries no bone tails at all, so there is nothing in the skeleton +## itself that says which way a panel hangs or how thick a thigh is. -## Per class: w = stiffness as an angular frequency (rad/s), zeta = damping -## ratio (1.0 is critical, lower overshoots), gravity in m/s^2. +## Per class: w = stiffness pulling back toward the animated pose (rad/s), zeta = +## damping ratio (1.0 is critical, lower overshoots), gravity in m/s^2, wind = +## how far the class streams in the airflow, as a fraction of its own length. ## ## Hair is stiffer and lighter than cloth so it settles quickly instead of -## wobbling; a skirt is slacker and heavier so it lags and swings. Gravity is -## modest for both because the AUTHORED rest pose already has the garment -## hanging — this only biases the droop while the body accelerates. +## wobbling; a skirt is slacker and heavier so it lags and swings. +## +## Gravity is small because the AUTHORED rest pose already has the garment +## hanging; this only biases the droop while the body accelerates. It is not +## free — a constant force offsets the resting particle by g/w^2, so a large +## value pulls the hem below where it was modelled, straight into the thigh it +## then has to be pushed back out of. +## +## `stray` is the backstop — see MAX_STRAY. It is a per-class number because the +## two classes want opposite things from it. Hair has nothing to get out of the +## way of, so a tight leash is pure benefit and is what stops a long strand +## flailing. A garment has to be able to lie along a thigh that has swung +## horizontal in a slide, which is most of a panel's own length of travel, so the +## same leash on cloth is the clipping. +## `hinge` / `bend` are the swing limits — see MAX_SWING_ROOT. They are per class +## because the two ends of a chain mean different things to different garments. A +## SKIRT hangs from a waistband and hinges there freely: a knee coming up to hip +## height puts a thigh horizontally through where the front panel hangs, and the +## panel has to ride up onto it, which is most of a right angle. HAIR grows out +## of a scalp — a strand that hinges 70 degrees at the root has come off the +## head. Same constraint, opposite settings, and one shared number was wrong for +## both. const TUNING := { - "hair": {"w": 16.0, "zeta": 0.34, "gravity": 3.0}, - "skirt": {"w": 11.0, "zeta": 0.30, "gravity": 5.0}, - "cloth": {"w": 12.0, "zeta": 0.32, "gravity": 4.5}, - "cape": {"w": 9.0, "zeta": 0.28, "gravity": 5.5}, - "coat": {"w": 11.0, "zeta": 0.30, "gravity": 5.0}, - "scarf": {"w": 12.0, "zeta": 0.30, "gravity": 4.0}, - "ribbon": {"w": 14.0, "zeta": 0.28, "gravity": 3.5}, - "tail": {"w": 13.0, "zeta": 0.30, "gravity": 3.0}, - "sleeve": {"w": 14.0, "zeta": 0.34, "gravity": 3.5}, - "breast": {"w": 20.0, "zeta": 0.40, "gravity": 2.0}, - "bust": {"w": 20.0, "zeta": 0.40, "gravity": 2.0}, + "hair": {"w": 15.0, "zeta": 0.45, "gravity": 1.6, "wind": 0.30, "stray": 0.45, + "hinge": 26.0, "bend": 52.0}, + "skirt": {"w": 12.0, "zeta": 0.48, "gravity": 2.0, "wind": 0.18, "stray": 1.25, + "hinge": 78.0, "bend": 55.0}, + "cloth": {"w": 13.0, "zeta": 0.55, "gravity": 1.9, "wind": 0.18, "stray": 1.10, + "hinge": 62.0, "bend": 52.0}, + "cape": {"w": 10.0, "zeta": 0.50, "gravity": 2.4, "wind": 0.35, "stray": 1.10, + "hinge": 55.0, "bend": 55.0}, + "coat": {"w": 12.0, "zeta": 0.52, "gravity": 2.1, "wind": 0.22, "stray": 1.10, + "hinge": 62.0, "bend": 52.0}, + "scarf": {"w": 13.0, "zeta": 0.55, "gravity": 1.8, "wind": 0.35, "stray": 0.70, + "hinge": 40.0, "bend": 58.0}, + "ribbon": {"w": 15.0, "zeta": 0.52, "gravity": 1.6, "wind": 0.35, "stray": 0.70, + "hinge": 35.0, "bend": 58.0}, + "tail": {"w": 14.0, "zeta": 0.55, "gravity": 1.5, "wind": 0.20, "stray": 0.60, + "hinge": 30.0, "bend": 50.0}, + "sleeve": {"w": 15.0, "zeta": 0.60, "gravity": 1.6, "wind": 0.20, "stray": 0.60, + "hinge": 30.0, "bend": 45.0}, + "breast": {"w": 21.0, "zeta": 0.65, "gravity": 1.0, "wind": 0.0, "stray": 0.30, + "hinge": 14.0, "bend": 14.0}, + "bust": {"w": 21.0, "zeta": 0.65, "gravity": 1.0, "wind": 0.0, "stray": 0.30, + "hinge": 14.0, "bend": 14.0}, } -const DEFAULT_TUNING := {"w": 13.0, "zeta": 0.32, "gravity": 4.0} +const DEFAULT_TUNING := {"w": 14.0, "zeta": 0.55, "gravity": 1.8, "wind": 0.2, + "stray": 0.55, "hinge": 40.0, "bend": 52.0} -## How far a tip may stray from where rigidly following would put it, as a -## fraction of the bone's length. Cloth swings; it does not stretch. -const MAX_STRAY := 0.6 +## The most a bone may turn away from the direction the animated pose gives it — +## GRADED along the chain, tighter where it is anchored and looser at the free +## end. Angles still compound down a chain, so a strand curves well past these +## overall, and how far the garment may travel in total is bounded separately by +## MAX_STRAY. +## +## The ROOT limit is not the 20 degrees Magica Cloth 2's skirt guide suggests, +## and the difference is geometry rather than taste. Their figure is for a chain +## whose first bone is a large share of the skirt; the first segment of one of +## Taila's panels is 49 mm of a 288 mm panel, so 20 degrees there moves the panel +## below it by SEVENTEEN MILLIMETRES. Measured over a movement sweep, the solver +## saw 104 mm of thigh inside the front of the skirt, pushed on it every frame +## and every iteration, and left 95 mm of it — not because it was blind or +## mistuned, but because the joint it had to turn was allowed 17 mm of travel. +## +## The two limits also mean different things. Between segments this is BENDING +## stiffness — how sharply a sheet of cloth may crease — and it belongs tight. +## At the root it is a HINGE at the waistband, and a skirt swinging clear of a +## knee coming up under it really does hinge 40-odd degrees there. +## +## Collision is applied after this within each iteration and may exceed it — the +## same precedence Magica uses ("collision detection has a higher priority than +## this limit"). +const MAX_SWING_ROOT := deg_to_rad(42.0) +const MAX_SWING_TIP := deg_to_rad(58.0) + +## How far a particle may end up from where the animation alone would have put +## it, as a fraction of the WHOLE chain's length. +## +## The cone limit above is measured against the PARENT, so a four-bone chain can +## legitimately curl to 4x its per-joint limit and the far end of a long strand +## gets thrown a long way by errors that are individually reasonable. This bounds +## the total. It is a hard positional clamp and it is the single thing that turns +## "hair spazzing about" into hair — Magica calls it Max Distance and it is the +## first parameter their setup guide reaches for. +## +## Measured against the whole garment, NOT against the chain hanging above each +## joint. Scaling it per joint sounds right and throttles exactly the wrong +## place: the second segment of one of Taila's skirt panels has only 98 mm of +## chain above it, which allowed it 44 mm of travel while the thigh under it +## needed it to move 100 mm — so the leg went through the front of the skirt and +## every collider measurement said the solver had fired. The garment is one piece +## of cloth and the whole of it has the same room to move. +## +## Overridden per class in TUNING; this is only the fallback for a class with no +## entry of its own. +const MAX_STRAY := 0.55 + +## Solver iterations. Gauss-Seidel over length, cone, backstop, collision and the +## cross-panel links. Four is enough for the chains here (four bones deep); the +## extra two buy the collision convergence that stops a thigh showing through on +## the frames where a leg sweeps hard. +const ITERATIONS := 3 +## Collision-and-length-only passes after the main loop. See the solve. +const SETTLE_PASSES := 1 + +## Detail level, set by the owner from how much the viewer can actually see — +## 0 is the full solve, 3 is the cheapest. +## +## This is not optional polish. The solver relaxes 127 joints against five +## capsules several times a frame, in GDScript, and collision is three quarters +## of that: a full-quality character costs about 2.6 ms, which is a sixth of a +## 60 fps frame for ONE of them. A shooter has several on screen and only the +## nearest is being looked at closely enough for a hem to matter. +## +## Each level drops a pass or the collision, in the order that costs the least +## visually — the shape constraints hold the silhouette on their own, so a +## distant character still has a skirt that swings; it just stops being carefully +## kept off a thigh nobody can resolve at that range. +const LOD_ITERS := [3, 2, 2, 1] +const LOD_SETTLE := [1, 1, 0, 0] +const LOD_COLLIDE := [true, true, true, false] +var lod: int = 0: set = _set_lod +var _iters: int = ITERATIONS +var _settles: int = SETTLE_PASSES +var _collide: bool = true + +func _set_lod(v: int) -> void: + lod = clampi(v, 0, LOD_ITERS.size() - 1) + _iters = LOD_ITERS[lod] + _settles = LOD_SETTLE[lod] + _collide = LOD_COLLIDE[lod] +## Inverse mass of a chain's ROOT joint, relative to the joints below it. +## +## Not zero. A contact against the top of a thigh has almost no lever on the bone +## that owns it, so no rotation can clear it — which is what defeated every +## earlier round of tuning. Letting the root carry a little of the correction +## lets the whole panel ride up over the leg instead, rigidly, so the waistband +## seam cannot be stretched open by it. Small, and hard-clamped by ROOT_STRAY, so +## the garment can never detach from the body. +const ROOT_INV_MASS := 0.25 +const ROOT_STRAY := 0.045 ## A frame delta longer than this is a hitch or a load spike. Integrating it ## launches every chain across the map, so it is clamped instead. const MAX_STEP := 1.0 / 30.0 ## Origin jump (metres in one frame) that means a teleport — respawn, or the ## model being reparented — rather than movement. Chains snap instead of whip. const TELEPORT := 1.5 -## Ceiling on tip speed, and on how finely one frame may be subdivided. Both are -## backstops: nothing on a character legitimately moves this fast, and four -## substeps already covers a 30 fps frame at the stiffest tuning here. -const MAX_SPEED := 12.0 +## Speed at which the wind term reaches full strength. +const WIND_SPEED := 9.0 +## WHY THERE IS NO "DRAPE" TERM HERE ANY MORE. +## +## There used to be one: each cloth bone measured how much of it rested on each +## leg, and took a share of that leg's motion before the solver ran, so a thigh +## arrived with the skirt already moving out of its way. Hoyoverse-style rigs do +## carry a partial constraint from the leg onto the upper skirt bones, and the +## idea is sound — but it was here to paper over a solver that could not push a +## panel off a thigh at all, and it cost far more than it bought once that was +## fixed. Measured over the movement sweep in debug/cloth_clip_check.gd, with it +## against without: +## +## run 101 -> 92 mm fall 82 -> 49 mm +## air 96 -> 88 mm dash 136 -> 95 mm +## slide 97 -> 96 mm idle after a dash 103 -> 20 mm +## +## It was worse in every state but a walk, and that last row is the one that +## mattered: WITH the drape the garment never came home after a hard move, it +## just sat displaced. It also cost an order of magnitude in stability — 0.48 +## deg/frame of skirt movement at a dead-still idle against 0.05 without, because +## the target it aimed at sat inside the leg the collision was pushing out of, so +## the two ran against each other forever. That is what the skirt "glitching out" +## was, and it is gone with the term that caused it. +## +## The mesh-neighbour data it used is still read: the horizontal links below need +## it, and that is what it was always most useful for. +## HORIZONTAL links between neighbouring cloth chains. +## +## A skirt's bones are linked vertically by the parent-child hierarchy and not at +## all sideways, so every panel solves as if the ones sewn to it did not exist. +## Magica Cloth 2's BoneCloth skirt guide calls connecting them "the most +## important work when expressing a skirt", and notes that without it "the +## accuracy of collision detection will be significantly reduced" — which is +## exactly what was happening here: each panel individually satisfied its +## constraints and the garment as a whole came apart. +## +## Relaxed inside the same iteration loop as everything else, so a panel pushed +## off a thigh takes the ones sewn to it with it. +const LINK_STIFF := 0.45 +## The same link resisting COMPRESSION. Much weaker: see the solve loop. +const LINK_SQUASH := 0.12 +## Most links per bone, strongest first. A skirt vertex is shared with a handful +## of neighbours; linking every one of them just stiffens the garment into a +## bell. +const LINK_MAX := 4 +## Classes a leg may carry. Cloth that hangs over the hips — not hair. +## Ceiling on how fast a particle may travel relative to its anchor, as a +## multiple of the bone length it hangs from. Bounds the tip speed to something +## proportional to the bone instead of a flat number that means nothing to a +## 20 mm hair segment and everything to a 140 mm skirt hem. +const MAX_RATE := 14.0 +## Ceiling on how fast a chain's anchor is believed to be travelling, and how +## quickly that estimate may change. Backstops against a bad frame delta. +const MAX_CARRY := 20.0 +const CARRY_SMOOTH := 0.15 const MAX_SUBSTEPS := 4 +## Overlap a contact is allowed to keep before the solver acts on it. +## +## The rest-clearance table is measured in the AUTHORED rest pose, and a +## character never stands in it — the idle clip alone moves the legs enough that +## cloth hanging against a thigh grazes its capsule every frame. Without slop +## those grazes are real contacts and the idle buzzes. Six millimetres is well +## under anything the eye can find on a hem and it makes the idle silent. +const CONTACT_SLOP := 0.006 +## Fractions along a bone tested against the colliders when the sidecar carries +## no hull for it. A skirt panel is a sheet, so testing only its tip lets the +## middle of the panel clip. +const SAMPLES := [0.35, 0.7, 1.0] +## How much of a collision correction the bone's HEAD takes when the contact sits +## at the head end. 1.0 would let a panel shear off its own waistband. +const COLLIDE_HEAD_SHARE := 0.85 var _chains: Array = [] var _colliders: Array = [] -var _tip: PackedVector3Array = PackedVector3Array() -var _vel: PackedVector3Array = PackedVector3Array() +## [particle a, particle b, rest distance] for every horizontal link. Indices are +## into the flat particle arrays below. +var _links: Array = [] +## Joint particles: one per bone plus a final tip, per chain. Kept in the chain +## ANCHOR's frame, so simply travelling through the world cancels exactly and the +## solver only ever deals with the motion that should actually move cloth. +var _q: PackedVector3Array = PackedVector3Array() +var _v: PackedVector3Array = PackedVector3Array() +## Scratch, world space, rebuilt every frame. Kept as members so the per-frame +## solve does not reallocate. +var _pw: PackedVector3Array = PackedVector3Array() # working positions +var _ref: PackedVector3Array = PackedVector3Array() # animated reference +var _rdir: PackedVector3Array = PackedVector3Array() # reference bone dirs +var _rbasis: Array = [] # animated bone bases +var _imass: PackedFloat32Array = PackedFloat32Array() +## The collider capsules in world space, rebuilt ONCE per frame. +## +## They were being rebuilt inside the innermost loop — for every bone, against +## every collider, on every relaxation pass — which is two skeleton queries and +## two transform multiplies about five thousand times a frame for a result that +## cannot change while the solve is running. That alone was most of the 10.9 ms +## per character this used to cost. +var _cap_a: PackedVector3Array = PackedVector3Array() +var _cap_b: PackedVector3Array = PackedVector3Array() +var _cap_rh: PackedFloat32Array = PackedFloat32Array() +var _cap_rt: PackedFloat32Array = PackedFloat32Array() +## Scratch for one bone's posed hull points, reused instead of reallocated. +var _pts: PackedVector3Array = PackedVector3Array() +## Where each chain's anchor was last frame, to measure how fast it is carrying +## the cloth through the world. +var _anchor_prev: PackedVector3Array = PackedVector3Array() +var _carry_prev: PackedVector3Array = PackedVector3Array() +var _time: float = 0.0 var _settled: bool = false -var _last_usec: int = 0 +## Force a fixed timestep instead of the engine clock. 0 = normal. Set by +## debug/cloth_settle_check.gd so settling can be measured at a REAL frame rate: +## headless runs uncapped, where the clamped sub-millisecond delta makes every +## chain look motionless whatever the tuning. +var fixed_delta: float = 0.0 +## Diagnostics for the debug scripts — how many collider pushes fired, and how +## deep the worst contact on each bone got. +var _hits: int = 0 +var _hit_frames: int = 0 +var _hit_depth: Dictionary = {} +var _applied: Dictionary = {} +var _left: Dictionary = {} +var debug_bone: int = -1 +var debug_swing: float = 0.0 +var debug_len: float = 0.0 +var debug_origin: Vector3 = Vector3.ZERO +var _calls: int = 0 +var _frames_seen: Dictionary = {} +var _dt_min: float = 999.0 +var _dt_max: float = 0.0 + + +## How many times the solver ran per rendered frame, and the delta spread. +func debug_rate() -> String: + var n: int = maxi(_frames_seen.size(), 1) + var out := "calls/frame %.2f dt %.5f..%.5f" % [float(_calls) / n, _dt_min, _dt_max] + _calls = 0 + _frames_seen.clear() + _dt_min = 999.0 + _dt_max = 0.0 + return out + + +## bone index -> [total correction applied (mm), deepest overlap seen (mm)]. +func debug_effort_report() -> Dictionary: + var out := {} + for b in _applied: + out[b] = [_applied[b] * 1000.0, _left.get(b, 0.0) * 1000.0] + _applied.clear() + _left.clear() + return out + + +## bone index -> worst penetration (metres) seen since the last call. +func debug_hit_report() -> Dictionary: + var d := _hit_depth.duplicate() + _hit_depth.clear() + return d + + +## Average collider pushes per frame since the last call. A chain that is +## settling should report ~0; a steady non-zero count means the solver and the +## colliders are fighting, which no amount of damping will settle. +func debug_collisions_per_frame() -> float: + var v := float(_hits) / maxf(_hit_frames, 1.0) + _hits = 0 + _hit_frames = 0 + return v ## Build from the sidecar written by tools/retarget.py. Returns how many bones ## are being driven, so the caller can log or disable itself when there are none. -func setup(skel: Skeleton3D, info: Dictionary) -> int: +## +## `hull_override` is bone name -> the points, in that bone's own rest space, +## that the collision should test. It comes from the MESH ITSELF at load time +## (see SkinnedPlayerModel._cloth_hulls) and it replaces the ten farthest-point +## samples the sidecar carries. Farthest-point sampling spans a panel's corners +## and hem and leaves its MIDDLE unsampled, which is precisely where a thigh +## comes up through a skirt: measured over a movement sweep the solver reported +## every contact resolved while 158 vertices were 95 mm inside a leg, because +## not one of them was a point it was looking at. +func setup(skel: Skeleton3D, info: Dictionary, hull_override: Dictionary = {}) -> int: _chains.clear() + _links.clear() _colliders.clear() if skel == null or info.is_empty(): return 0 + # Colliders first: each chain's per-bone clearance is measured against them. + for c in info.get("colliders", []): + var ca := skel.find_bone(String(c.get("bone", ""))) + var cb := skel.find_bone(String(c.get("child", ""))) + if ca >= 0 and cb >= 0: + var tail := float(c.get("radius_tail", c.get("radius", 0.1))) + _colliders.append({ + "a": ca, "b": cb, + # How far down the bone the capsule starts. The top of a thigh is + # hip, not limb — see tools/retarget.py::_leg_colliders. + "from": float(c.get("from", 0.0)), + "lid": bool(c.get("lid", false)), + "rh": float(c.get("radius_head", tail)), + "rt": tail, + }) + var total := 0 for entry in info.get("chains", []): var parent_name: String = entry.get("root_parent", "") @@ -96,8 +413,10 @@ func setup(skel: Skeleton3D, info: Dictionary) -> int: continue var bones := PackedInt32Array() var tips := PackedVector3Array() + var hulls: Array = [] var names: Array = entry.get("bones", []) var raw_tips: Array = entry.get("tips", []) + var raw_hulls: Array = entry.get("hulls", []) for i in names.size(): var idx := skel.find_bone(String(names[i])) if idx < 0: @@ -109,151 +428,811 @@ func setup(skel: Skeleton3D, info: Dictionary) -> int: tip = Vector3(t[0], t[1], t[2]) if tip.length() < 0.001: continue # no measurable extent — nothing to swing + # Chains must stay a strict parent->child run: the particle chain + # below assumes bone i+1 hangs off bone i, and a gap would put a + # distance constraint across a joint that is not there. + if bones.size() > 0 and skel.get_bone_parent(idx) != bones[bones.size() - 1]: + break bones.append(idx) tips.append(tip) + var hull: PackedVector3Array = hull_override.get( + skel.get_bone_name(idx), PackedVector3Array()) + if hull.is_empty() and i < raw_hulls.size(): + for h in raw_hulls[i]: + if h.size() == 3: + hull.append(Vector3(h[0], h[1], h[2])) + hulls.append(hull) if bones.is_empty(): continue - var tune: Dictionary = TUNING.get(String(entry.get("class", "")), DEFAULT_TUNING) + var cls := String(entry.get("class", "")) + var tune: Dictionary = TUNING.get(cls, DEFAULT_TUNING) + # Rest LINK geometry: particle i sits at bone i's head, particle n at the + # last bone's tip. So the link below bone i reaches to the next bone's + # head, which is NOT the same vector as that bone's mesh tip — the last + # segment of a panel runs on past its own child joint to the hem. + var seg := PackedFloat32Array() + var segdir := PackedVector3Array() + var span := 0.0 + for i in bones.size(): + var v: Vector3 = tips[i] if i == bones.size() - 1 \ + else skel.get_bone_rest(bones[i + 1]).origin + if v.length() < 0.0005: + v = tips[i] + seg.append(v.length()) + segdir.append(v.normalized()) + span += v.length() _chains.append({ "parent": parent, "bones": bones, "tips": tips, + "seg": seg, + "segdir": segdir, + "span": span, + "hulls": hulls, + "class": cls, + # Everything collides now, including hair. It used to be garments + # only, because a collision push happened AFTER the integrator and so + # was deaf to every spring parameter — long back hair reaching past + # the hips got shoved out of a thigh and hauled back every frame at + # stride frequency, which is the blur. Inside the relaxation there is + # no such fight, and hair that does not collide simply passes through + # the body: measured 373 mm of leg inside the hair over a movement + # sweep, against 183 mm for the skirt that did collide. + "radii": _rest_clearances(skel, bones, tips, hulls), + "names": names, + "neighbours": entry.get("neighbours", []), "w": float(tune["w"]), "zeta": float(tune["zeta"]), "gravity": float(tune["gravity"]), + "wind": float(tune.get("wind", 0.0)), + "stray": float(tune.get("stray", MAX_STRAY)), + "hinge": deg_to_rad(float(tune.get("hinge", + rad_to_deg(MAX_SWING_ROOT)))), + "bend": deg_to_rad(float(tune.get("bend", rad_to_deg(MAX_SWING_TIP)))), }) total += bones.size() - 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 and b >= 0: - _colliders.append({"a": a, "b": b, "r": float(c.get("radius", 0.1))}) + # Flat particle layout: n+1 particles per chain. + var k := 0 + for ci in _chains.size(): + _chains[ci]["base"] = k + k += (_chains[ci]["bones"] as PackedInt32Array).size() + 1 + _build_links(skel) - _tip.resize(total) - _vel.resize(total) + print("SpringBones: %d chains, %d particles, %d collision hull points" % [ + _chains.size(), k, _hull_points()]) + + _q.resize(k) + _v.resize(k) + _pw.resize(k) + _ref.resize(k) + _rdir.resize(k) + _imass.resize(k) + _rbasis.resize(k) + _cap_a.resize(_colliders.size()) + _cap_b.resize(_colliders.size()) + _cap_rh.resize(_colliders.size()) + _cap_rt.resize(_colliders.size()) + for c in _colliders.size(): + _cap_rh[c] = float(_colliders[c]["rh"]) + _cap_rt[c] = float(_colliders[c]["rt"]) + _anchor_prev.resize(_chains.size()) + _carry_prev.resize(_chains.size()) + # Inverse mass: heavier (stiffer) near the anchor, lighter toward the free + # end, so a correction prefers to move the hem rather than the waistband. + for ci in _chains.size(): + var base: int = _chains[ci]["base"] + var n: int = (_chains[ci]["bones"] as PackedInt32Array).size() + for i in n + 1: + var along := 0.0 if n < 1 else float(i) / float(n) + _imass[base + i] = ROOT_INV_MASS if i == 0 else lerpf(0.55, 1.0, along) _settled = false - _last_usec = 0 return total +## Total collision sample points across every chain — the solver's real cost. +func _hull_points() -> int: + var n := 0 + for c in _chains: + for h: PackedVector3Array in c["hulls"]: + n += h.size() if h.size() > 0 else SAMPLES.size() + return n + + +## The two ends of a collider capsule, with its `from` offset applied — so every +## place that tests against a limb agrees on where that limb starts. +func _capsule(skel: Skeleton3D, to_world: Transform3D, col: Dictionary) -> Array: + var a: Vector3 = to_world * skel.get_bone_global_pose(col["a"]).origin + var b: Vector3 = to_world * skel.get_bone_global_pose(col["b"]).origin + return [a.lerp(b, float(col["from"])), b] + + +## Same, in the skeleton's REST pose. +func _capsule_rest(skel: Skeleton3D, col: Dictionary) -> Array: + var a := skel.get_bone_global_rest(col["a"]).origin + var b := skel.get_bone_global_rest(col["b"]).origin + return [a.lerp(b, float(col["from"])), b] + + +## Per (bone, collider) capsule radius, capped so the REST pose never collides. +## +## The authored rest pose is by definition not clipping — the artist modelled the +## skirt over these legs. But a capsule sized from the thigh's own vertices is +## fat enough (0.11 m at Taila's hip) to swallow the skirt bones that hang right +## against it, and those bones then get shoved out and pulled straight back in +## every single frame: a limit cycle that never decays. That is cloth and hair +## "never settling", and it also leaves the collider saturated and useless +## against the clipping it exists to prevent. +## +## Capping to just inside the rest clearance makes the rest pose a valid state, +## so idle is quiet and a push only ever means the leg has genuinely swung into +## the cloth. +## +## The cap is PER POINT, not per bone. Scaling a whole bone's radius by its worst +## point switches collision off entirely for every skirt panel whose top +## naturally hangs against the thigh — which is all the ones that matter. +## +## And it is measured against the capsule radius WHERE THE POINT RESTS, not +## against the widest radius anywhere on the limb. A leg tapers 2:1 from hip to +## knee, so comparing every point to the hip radius declares the whole skirt +## already inside the capsule and hands all of it the reduced allowance meant for +## the waistband alone. +func _rest_clearances(skel: Skeleton3D, bones: PackedInt32Array, + tips: PackedVector3Array, hulls: Array) -> Array: + var out: Array = [] + for i in bones.size(): + var rest := skel.get_bone_global_rest(bones[i]) + var origin := rest.origin + var tip: Vector3 = rest * tips[i] + var pts := _sample_points(rest, origin, tip, hulls[i]) + var n := _colliders.size() * pts.size() + var caps := PackedFloat32Array() + caps.resize(n) + # How far this bone's geometry reaches from its own head, for the + # broad-phase skip in _collide_bone. + var span := 0.0 + for p: Vector3 in pts: + span = maxf(span, p.distance_to(origin)) + for c in _colliders.size(): + var col: Dictionary = _colliders[c] + var ends := _capsule_rest(skel, 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 t: float = 0.0 if d2 < 0.000001 \ + else clampf((pts[j] - a).dot(ab) / d2, 0.0, 1.0) + var d: float = (pts[j] - (a + ab * t)).length() + # A limb tapers 2:1 hip to knee, so measure against the radius + # WHERE THE POINT IS. + var local: float = lerpf(float(col["rh"]), float(col["rt"]), t) + caps[c * pts.size() + j] = maxf(local, d * 0.9) if d >= local else d * 0.9 + out.append({"cap": caps, "span": span}) + return out + + +## Distance links between bones of DIFFERENT chains that share mesh vertices. +## +## The garment's own topology decides what is sewn to what — `neighbours` in the +## sidecar is the weight each pair of cloth bones shares over the same vertices, +## which is the artist's answer and not a guess from names or rest distance. +## +## Only cross-chain pairs: within a chain the particle links already hold the +## bones together, and re-stating that here only fights them. +func _build_links(skel: Skeleton3D) -> void: + _links.clear() + # bone -> [chain, index]; the particle at that bone's TIP is base+index+1. + var where := {} + for ci in _chains.size(): + var bones: PackedInt32Array = _chains[ci]["bones"] + for i in bones.size(): + where[bones[i]] = [ci, i] + + var seen := {} + for ci in _chains.size(): + var chain: Dictionary = _chains[ci] + if String(chain["class"]) == "hair": + continue # hair strands hang free; linking them stiffens them into rope + var bones: PackedInt32Array = chain["bones"] + var names: Array = chain["names"] + var nbrs: Array = chain["neighbours"] + for i in bones.size(): + var at: int = names.find(skel.get_bone_name(bones[i])) + if at < 0 or at >= nbrs.size(): + continue + var nb: Dictionary = nbrs[at] + # Strongest few only — see LINK_MAX. + var ranked: Array = nb.keys() + ranked.sort_custom(func(a, b): return float(nb[a]) > float(nb[b])) + var made := 0 + for other_name in ranked: + if made >= LINK_MAX: + break + var oi := skel.find_bone(String(other_name)) + if oi < 0 or not where.has(oi) or where[oi][0] == ci: + continue + var pa: int = int(chain["base"]) + i + 1 + var ob: Array = where[oi] + var pb: int = int(_chains[ob[0]]["base"]) + int(ob[1]) + 1 + var key := "%d_%d" % [mini(pa, pb), maxi(pa, pb)] + if seen.has(key): + continue + seen[key] = true + made += 1 + # Rest separation of the two TIPS, which is what the links hold. + var oc: Dictionary = _chains[ob[0]] + var wa: Vector3 = skel.get_bone_global_rest(bones[i]) \ + * (chain["tips"] as PackedVector3Array)[i] + var wb: Vector3 = skel.get_bone_global_rest(oi) \ + * (oc["tips"] as PackedVector3Array)[ob[1]] + _links.append([pa, pb, wa.distance_to(wb)]) + print("SpringBones: %d horizontal links between cloth chains" % _links.size()) + + +static func _sample_points(xform: Transform3D, origin: Vector3, tip: Vector3, + hull: PackedVector3Array) -> Array: + var out: Array = [] + if hull.size() > 0: + for h in hull: + out.append(xform * h) + return out + for s in SAMPLES: + out.append(origin.lerp(tip, s)) + return out + + +static func _seg_point_distance(p: Vector3, a: Vector3, b: Vector3) -> float: + var ab := b - a + var d2 := ab.length_squared() + var t := 0.0 if d2 < 0.000001 else clampf((p - a).dot(ab) / d2, 0.0, 1.0) + return p.distance_to(a + ab * t) + + +## The ENGINE's frame delta, never a wall clock. +## +## `Time.get_ticks_usec()` deltas measured 0.002 s to 0.033 s inside a single +## second — a 16x spread. That alone is survivable, but the anchor velocity +## divides a position change by it, and that position change came from the +## animation advancing by the ENGINE's delta. When the two disagree the computed +## velocity is wrong by that ratio, so the wind and the damping reference are +## wrong by 16x frame to frame. func _delta() -> float: - var now := Time.get_ticks_usec() - if _last_usec == 0: - _last_usec = now - return 1.0 / 60.0 - var dt := float(now - _last_usec) / 1000000.0 - _last_usec = now + if fixed_delta > 0.0: + return fixed_delta + var dt := get_physics_process_delta_time() if Engine.is_in_physics_frame() \ + else get_process_delta_time() return clampf(dt, 1.0 / 480.0, MAX_STEP) +# ── The solve ───────────────────────────────────────────────────────────────── + func _process_modification() -> void: var skel := get_skeleton() if skel == null or _chains.is_empty(): return var dt := _delta() + _calls += 1 + _frames_seen[Engine.get_process_frames()] = true + _dt_min = minf(_dt_min, dt) + _dt_max = maxf(_dt_max, dt) + _time += dt + _hit_frames += 1 var to_world := skel.global_transform - var k := 0 + for c in _colliders.size(): + var ends := _capsule(skel, to_world, _colliders[c]) + _cap_a[c] = ends[0] + _cap_b[c] = ends[1] - for chain in _chains: - var bones: PackedInt32Array = chain["bones"] - var tips: PackedVector3Array = chain["tips"] - var w: float = chain["w"] - var damp: float = 2.0 * float(chain["zeta"]) * w - var pull: float = w * w - var gravity := Vector3.DOWN * float(chain["gravity"]) - # World transform of the bone this chain hangs from, already posed by - # the animation and the shooter pose layer this frame. - var parent_world: Transform3D = to_world * skel.get_bone_global_pose(chain["parent"]) + # 1. Where the animation alone would put every joint this frame, including + # the share of each leg's swing the cloth resting on it is carried by. + var anchors: Array = [] + for ci in _chains.size(): + anchors.append(_build_reference(skel, to_world, ci)) - for i in bones.size(): - var bone: int = bones[i] - # Where rigidly following the parent would put this bone. Built from - # the REST offset, not the current pose, or last frame's spring - # result would compound into a permanent drift. - var rigid: Transform3D = parent_world * skel.get_bone_rest(bone) - var origin := rigid.origin - var rest_tip: Vector3 = rigid * tips[i] - var arm := rest_tip - origin - var length := arm.length() - if length < 0.0001: - k += 1 + # 2. Integrate. The state lives in the anchor's frame, so travelling at a + # steady speed excites nothing and only real acceleration, rotation, wind + # and gravity move the cloth. + for ci in _chains.size(): + _predict(skel, ci, anchors[ci], dt) + + # 3. Relax everything together. + # + # LINKS FIRST, chains second, so the last thing to touch any particle in an + # iteration is its collision. Order is not cosmetic here. With the chains + # first, every iteration ended by pulling neighbouring panels back toward + # their rest separation — straight back into the leg that had just been + # cleared. Measured on a slide: 93 mm of thigh inside a front panel went in, + # 95 mm came out, and with the links switched off entirely the same frame + # solved down to 27 mm. Collision outranks the garment's own shape, which is + # also the precedence Magica Cloth 2 documents. + for _it in _iters: + for L in _links: + var a: int = L[0] + var b: int = L[1] + var d: Vector3 = _pw[b] - _pw[a] + var cur := d.length() + if cur < 0.00001: continue + var wa: float = _imass[a] + var wb: float = _imass[b] + var sum := wa + wb + if sum <= 0.0001: + continue + # Cloth is stiff in TENSION and nearly free in COMPRESSION — a + # garment gathers and folds happily but does not tear. Pulling + # equally hard on both is what made these links fight the legs: a + # panel riding up over a thigh opens away from the ones sewn beside + # it, and a symmetric link reads that as the garment coming apart. + var rest: float = float(L[2]) + var k: float = LINK_STIFF if cur > rest else LINK_SQUASH + var move: Vector3 = d * ((cur - rest) / cur * k / sum) + _pw[a] += move * wa + _pw[b] -= move * wb + for ci in _chains.size(): + _solve_chain(skel, to_world, ci, true) + # A short tail of COLLISION AND BONE LENGTH ONLY. + # + # The cone limit and the backstop are shape constraints — they say what the + # garment should look like — and re-imposing them after each collision means + # the two argue instead of converging: quadrupling the main iteration count + # moved a 92 mm overlap to 76 mm and stalled, which is a standing fight + # rather than slow relaxation. These last passes let the collision finish, + # holding only the constraint that cannot be given up (a bone has a length). + # Shape has already been decided above and a few millimetres of overlap is + # all that is left to clear, so nothing visible is given away by it. + for _t in _settles: + for ci in _chains.size(): + _solve_chain(skel, to_world, ci, false) - var tip := _tip[k] - var vel := _vel[k] - if not _settled or tip.distance_to(rest_tip) > TELEPORT: - tip = rest_tip - vel = Vector3.ZERO - - # Substep so the spring can never overshoot, however stiff it is or - # however long the frame was. A single explicit step is only stable - # while w*dt stays small; past that it gains energy every frame and - # the chain flies off the model. - var sub := clampi(int(ceil(dt * w / 0.4)), 1, MAX_SUBSTEPS) - var h := dt / float(sub) - for _s in sub: - vel += ((rest_tip - tip) * pull - vel * damp + gravity) * h - tip += vel * h - # Cloth swings but barely stretches: hold the tip on the bone's - # own sphere. - var offset := tip - origin - tip = origin + (offset if offset.length() > 0.0001 else arm).normalized() * length - # Velocity is taken OUT of the constraint rather than recovered - # from a finite difference afterwards. Dividing a projected - # position change by a wall-clock delta is what produced the - # 1.46 m fling: a short frame turns a millimetre of correction - # into metres per second, and the next frame launches the chain. - var radial := (tip - origin) / length - vel -= radial * vel.dot(radial) - vel = vel.limit_length(MAX_SPEED) - - # Cap how far the tip may stray from the rigid pose, so a hard turn - # cannot fold a chain back through the body. - var stray := tip - rest_tip - var limit := length * MAX_STRAY - if stray.length() > limit: - tip = origin + ((rest_tip + stray.normalized() * limit) - origin).normalized() * length - - tip = _push_out_of_legs(skel, to_world, tip, origin, length) - _vel[k] = vel - _tip[k] = tip - - # Turn the tip direction back into this bone's local rotation. - var swing := Quaternion(arm.normalized(), (tip - origin).normalized()) - var world := Transform3D(Basis(swing) * rigid.basis, origin) - var local := parent_world.affine_inverse() * world - skel.set_bone_pose_rotation(bone, local.basis.get_rotation_quaternion()) - parent_world = world - k += 1 + # What the relaxation actually converged to, measured but not corrected. Read + # alongside the same measurement taken off the finished skeleton, this is the + # only way to tell "the constraints could not agree" from "they agreed and + # the answer was lost on the way into the pose". + if _want_residual: + for ci in _chains.size(): + _measure_residual(skel, to_world, ci) + # 4. Settled particles -> bone poses, and back into the anchor's frame. + for ci in _chains.size(): + _write_poses(skel, anchors[ci], ci) _settled = true -## Keep a tip outside the leg capsules, so a skirt swings AROUND a thigh rather -## than through it. Radii are measured from the model's own body geometry at -## build time (tools/retarget.py::_leg_colliders). -func _push_out_of_legs(skel: Skeleton3D, to_world: Transform3D, tip: Vector3, - origin: Vector3, length: float) -> Vector3: - for col in _colliders: - var a: Vector3 = to_world * skel.get_bone_global_pose(col["a"]).origin - var b: Vector3 = to_world * skel.get_bone_global_pose(col["b"]).origin - var ab := b - a - var d2 := ab.length_squared() - var t := 0.0 if d2 < 0.000001 else clampf((tip - a).dot(ab) / d2, 0.0, 1.0) - var closest := a + ab * t - var away := tip - closest - var dist := away.length() - var r: float = col["r"] - if dist >= r: +## Deepest remaining overlap per bone, from the settled particles. Diagnostic +## only — nothing is corrected here. +func _measure_residual(skel: Skeleton3D, to_world: Transform3D, ci: int) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var seg: PackedFloat32Array = chain["seg"] + var hulls: Array = chain["hulls"] + var radii: Array = chain["radii"] + var base: int = chain["base"] + for i in bones.size(): + if i >= radii.size(): continue - if dist < 0.0001: - away = (tip - origin).cross(ab) - if away.length() < 0.0001: + var caps: PackedFloat32Array = radii[i].get("cap", PackedFloat32Array()) + if caps.is_empty(): + continue + var a := base + i + var d: Vector3 = _pw[a + 1] - _pw[a] + if d.length() < 0.00001: + continue + var u := d.normalized() + var posed := Transform3D(Basis(Quaternion(_rdir[a], u)) * (_rbasis[a] as Basis), _pw[a]) + var pts := _sample_points(posed, _pw[a], _pw[a] + u * seg[i], hulls[i]) + var worst := 0.0 + for c in _colliders.size(): + var col: Dictionary = _colliders[c] + var ends := _capsule(skel, to_world, col) + var ca: Vector3 = ends[0] + var ab: Vector3 = ends[1] - ca + var d2 := ab.length_squared() + for j in pts.size(): + var p: Vector3 = pts[j] + var t: float = 0.0 if d2 < 0.000001 \ + else clampf((p - ca).dot(ab) / d2, 0.0, 1.0) + var kk := c * pts.size() + j + var allow: float = caps[kk] if kk < caps.size() else 1.0 + var r: float = minf(lerpf(float(col["rh"]), float(col["rt"]), t), allow) + worst = maxf(worst, (r - CONTACT_SLOP) - p.distance_to(ca + ab * t)) + if worst > 0.0: + _resid[bones[i]] = maxf(_resid.get(bones[i], 0.0), worst) + + +## Turn on the residual measurement above. Off by default — it is a second full +## collision sweep and the game does not need it. +var _want_residual: bool = false +var _resid: Dictionary = {} + +func debug_residual_report() -> Dictionary: + _want_residual = true + var d := _resid.duplicate() + _resid.clear() + return d + + +## The chain's animated pose for this frame: every joint where rigidly following +## the body would put it: every joint where rigidly following the animation +## would leave it. +## +## Returns the anchor transform, and fills _ref / _rdir / _rbasis for the chain. +func _build_reference(skel: Skeleton3D, to_world: Transform3D, ci: int) -> Transform3D: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var segdir: PackedVector3Array = chain["segdir"] + var seg: PackedFloat32Array = chain["seg"] + var base: int = chain["base"] + var anchor: Transform3D = to_world * skel.get_bone_global_pose(chain["parent"]) + var pw := anchor + for i in bones.size(): + # Built from the REST offset, not the current pose, or last frame's + # result would compound into a permanent drift. + var rigid: Transform3D = pw * skel.get_bone_rest(bones[i]) + _ref[base + i] = rigid.origin + _rbasis[base + i] = rigid.basis + _rdir[base + i] = (rigid.basis * segdir[i]).normalized() + pw = Transform3D(rigid.basis, rigid.origin + _rdir[base + i] * seg[i]) + _ref[base + bones.size()] = pw.origin + return anchor + + +## Integrate one chain's particles and seed the working positions. +func _predict(skel: Skeleton3D, ci: int, anchor: Transform3D, dt: float) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var base: int = chain["base"] + var n: int = bones.size() + var w: float = chain["w"] + var pull: float = w * w + var damp: float = 2.0 * float(chain["zeta"]) * w + var gravity := Vector3.DOWN * float(chain["gravity"]) + var seg: PackedFloat32Array = chain["seg"] + + # How fast the chain's anchor is travelling through the world — the reference + # the wind is measured against. Low-passed: a raw per-call difference + # alternates violently when the solver is stepped more than once for the same + # animation frame, and the wind force then flips sign every other step. + var carry := (anchor.origin - _anchor_prev[ci]) / dt if _settled else Vector3.ZERO + carry = _carry_prev[ci].lerp(carry.limit_length(MAX_CARRY), CARRY_SMOOTH) + _carry_prev[ci] = carry + _anchor_prev[ci] = anchor.origin + + var inv_anchor := anchor.affine_inverse() + var reset := not _settled + if not reset: + # A teleport — respawn, or the model being reparented. Snap, do not whip. + var got: Vector3 = anchor * _q[base + n] + if got.distance_to(_ref[base + n]) > TELEPORT: + reset = true + + # Cloth streams backwards through the air it is moving into. A STEADY wind + # gives a steady deflection, which is hair that blows back once and then + # hangs there rigid — travelling at a constant speed excites nothing else now + # that the solver works in the anchor's frame — so the gust varies. Two + # incommensurate rates so the pattern does not read as a loop, and a + # per-chain phase so strands do not move in lockstep. + var wind_dir := Vector3.ZERO + var wind_mag := 0.0 + if carry.length() > 0.05: + var ph: float = float(ci) * 1.7 + var gust := minf(carry.length() / WIND_SPEED, 1.0) * float(chain["wind"]) + var back := -carry.normalized() + var side := back.cross(Vector3.UP) + side = side.normalized() if side.length() > 0.001 else Vector3.ZERO + var t := 1.0 + 0.45 * sin(_time * 6.3 + ph) + 0.25 * sin(_time * 10.7 + ph * 1.6) + wind_dir = back * t + side * (0.35 * sin(_time * 8.1 + ph)) + wind_mag = pull * gust + + # Substep so the pull can never overshoot, however stiff it is or however + # long the frame was. A single explicit step is only stable while w*dt stays + # small; past that it gains energy every frame and the chain flies off. + var sub := clampi(int(ceil(dt * w / 0.4)), 1, MAX_SUBSTEPS) + var h := dt / float(sub) + if reset: + _q[base] = inv_anchor * _ref[base] + _v[base] = Vector3.ZERO + # The root joint is not pinned outright — see ROOT_INV_MASS. It keeps a + # fraction of last frame's bodily shift and relaxes back toward the body, so + # a panel that had to ride up over a thigh comes home once the leg has gone + # by instead of stepping back the instant the contact clears. + _pw[base] = _ref[base] \ + + ((anchor * _q[base]) - _ref[base]).limit_length(ROOT_STRAY) * 0.55 + for i in range(1, n + 1): + var k := base + i + if reset: + _q[k] = inv_anchor * _ref[k] + _v[k] = Vector3.ZERO + var p: Vector3 = anchor * _q[k] + var vel: Vector3 = anchor.basis * _v[k] + var length: float = seg[i - 1] + for _s in sub: + var acc := (_ref[k] - p) * pull + gravity + wind_dir * (wind_mag * length) + vel += acc * h + vel *= exp(-damp * h) + vel = vel.limit_length(length * MAX_RATE) + p += vel * h + _pw[k] = p + _v[k] = anchor.basis.inverse() * vel + + +## One Gauss-Seidel sweep over a chain: bone length, cone limit, backstop, +## colliders. Runs top-down so each bone sees where its parent has just gone. +func _solve_chain(skel: Skeleton3D, to_world: Transform3D, ci: int, + shape: bool) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var seg: PackedFloat32Array = chain["seg"] + var hulls: Array = chain["hulls"] + var radii: Array = chain["radii"] + var base: int = chain["base"] + var n: int = bones.size() + var far: float = float(chain["span"]) * float(chain["stray"]) + var hinge: float = chain["hinge"] + var bend: float = chain["bend"] + # How far the bone ABOVE has swung out of the animated pose. Each bone's cone + # limit is measured from its own reference carried through this, so the chain + # curls instead of every joint being pinned to the animation independently. + var carry_swing := Quaternion.IDENTITY + for i in n: + var a := base + i + var b := base + i + 1 + var length: float = seg[i] + + # 1. Bone length. Symmetric, so a correction below propagates upward — + # which is the whole reason the joints are particles. + var d: Vector3 = _pw[b] - _pw[a] + var cur := d.length() + if cur > 0.00001: + var wa: float = _imass[a] + var wb: float = _imass[b] + var sum := wa + wb + if sum > 0.0001: + var fix: Vector3 = d * ((cur - length) / cur / sum) + _pw[a] += fix * wa + _pw[b] -= fix * wb + else: + _pw[b] = _pw[a] + _rdir[a] * length + + # 2. Cone limit, measured from where this bone would point if it FOLLOWED + # ITS PARENT — not from where the animation alone put it. + # + # This is the difference between a chain that can curl and one that + # cannot. Measuring every joint against the animated pose pins the + # whole strand within one cone of it: the root may swing its 42 + # degrees, and then its child, which has physically been carried along + # by that swing, is told it is already 42 degrees out of place and + # must come back. Nothing compounds, so a four-bone panel is no more + # mobile than a one-bone one, and a skirt that has to lie along a + # thigh in a slide simply cannot get there — measured, the solver + # reported the contact every frame and left 99 mm of leg inside the + # hem. + # + # Carrying the parent's actual swing onto the child's reference makes + # the limit mean what it should: how far this bone may bend RELATIVE + # TO THE ONE ABOVE IT, which is bending stiffness, which is a property + # of cloth. Total travel stays bounded by the backstop below. + var ref_dir: Vector3 = (carry_swing * _rdir[a]).normalized() + if shape: + var dir: Vector3 = _pw[b] - _pw[a] + if dir.length() > 0.00001: + dir = dir.normalized() + var along := 0.0 if n < 2 else float(i) / float(n - 1) + var limit: float = lerpf(hinge, bend, minf(along * 2.0, 1.0)) + var ang := ref_dir.angle_to(dir) + if ang > limit: + dir = ref_dir.slerp(dir, limit / ang).normalized() + _pw[b] = _pw[a] + dir * length + + # 3. Backstop: however the cone limits compound, the joint stays + # within a fraction of the whole garment of where the animation + # put it. See MAX_STRAY. + var stray: Vector3 = _pw[b] - _ref[b] + if stray.length() > far: + _pw[b] = _ref[b] + stray.normalized() * far + + # 4. Colliders, against the REAL GEOMETRY this bone drives. + if _collide and i < radii.size(): + _collide_bone(skel, to_world, bones[i], a, b, length, + hulls[i], radii[i]) + + # The root's own leash back to the body, re-applied as soon as the bone + # hanging off it has had its say — the bones below solve against wherever + # the root ended up, so letting it drift and only clamping at the end + # would have them chasing a position the garment is not allowed to hold. + if i == 0: + var off0: Vector3 = _pw[base] - _ref[base] + if off0.length() > ROOT_STRAY: + _pw[base] = _ref[base] + off0.normalized() * ROOT_STRAY + + # What this bone finally settled on, for its child's cone limit. + var got: Vector3 = _pw[b] - _pw[a] + carry_swing = Quaternion(_rdir[a], got.normalized()) \ + if got.length() > 0.00001 else Quaternion.IDENTITY + + +## Push one bone's mesh hull out of every collider by moving its two joints. +## +## The bone stick is not what clips — a skirt panel is a wide sheet, and measured +## over a movement sweep the bones sat ~1 mm clear of the legs while the thigh +## was 85 mm inside the skirt MESH. So the test is against the hull samples from +## the sidecar, which are the actual vertices this bone drives. +## +## The correction is shared between the bone's head and its tip by where the +## contact sits along the bone, which makes it a rigid motion of the panel rather +## than a hinge. A contact at the head end therefore lifts the WHOLE panel — the +## case no amount of rotation could ever reach, because rotating about a point +## moves that point not at all. +func _collide_bone(skel: Skeleton3D, to_world: Transform3D, bone: int, + a: int, b: int, length: float, hull: PackedVector3Array, + rec: Dictionary) -> void: + var caps: PackedFloat32Array = rec.get("cap", PackedFloat32Array()) + if caps.is_empty() or _colliders.is_empty(): + return + var dir: Vector3 = _pw[b] - _pw[a] + if dir.length() < 0.00001: + return + var wa: float = _imass[a] + var wb: float = _imass[b] + if wa + wb <= 0.0001: + return + var span: float = maxf(float(rec.get("span", length)), length) + # Per-BONE broad phase, before the hull is posed at all. Most cloth bones are + # nowhere near a limb on most frames — Taila's hair is 48 of the 127 — and + # posing a couple of dozen hull points for them, nine passes a frame, was + # pure waste. + var near := false + for c in _colliders.size(): + if _seg_point_distance(_pw[a], _cap_a[c], _cap_b[c]) <= span + maxf(_cap_rh[c], _cap_rt[c]): + near = true + break + if not near: + return + var n := _fill_points(a, b, length, hull) + if n == 0: + return + + for c in _colliders.size(): + var ca: Vector3 = _cap_a[c] + var cb: Vector3 = _cap_b[c] + var ab := cb - ca + var d2 := ab.length_squared() + var rh: float = _cap_rh[c] + var rt: float = _cap_rt[c] + # Broad phase. Nothing this bone drives can reach further than `span` + # from its own head, so a limb further away than that plus its own radius + # cannot be touching it. Skips most of the (bone, collider) pairs. + if _seg_point_distance(_pw[a], ca, cb) > span + maxf(rh, rt): + continue + # ONE correction per limb, for the DEEPEST point on the bone. + # + # Not one per point. The hull is a couple of dozen samples spread over a + # panel, and a thigh inside it violates most of them at once — applying + # every violation in turn adds up to many times the one overlap that + # actually exists and flings the panel off the character. The deepest + # point is the one that has to clear; the rest are the same contact seen + # from nearby, and the next iteration re-measures whatever is left. + var deepest := 0.0 + var push := Vector3.ZERO + var at := Vector3.ZERO + for j in n: + var p: Vector3 = _pts[j] + var t: float = 0.0 if d2 < 0.000001 else clampf((p - ca).dot(ab) / d2, 0.0, 1.0) + var kk := c * n + j + var allow: float = caps[kk] if kk < caps.size() else 1.0 + var r: float = minf(lerpf(rh, rt, t), allow) + var away := p - (ca + ab * t) + var dist := away.length() + if dist < 0.0001: continue - tip = closest + away.normalized() * r - # Pushing off a capsule moves the tip off its own sphere; put it back. - var offset := tip - origin - if offset.length() > 0.0001: - tip = origin + offset.normalized() * length - return tip + var pen := (r - CONTACT_SLOP) - dist + if pen > deepest: + deepest = pen + push = (away / dist) * pen + at = p + if deepest <= 0.0: + continue + _hits += 1 + _hit_depth[bone] = maxf(_hit_depth.get(bone, 0.0), deepest) + _left[bone] = maxf(_left.get(bone, 0.0), deepest) + _applied[bone] = _applied.get(bone, 0.0) + deepest + # Where along the bone that point hangs decides which end carries the + # correction. A contact at the HEAD end therefore lifts the whole panel + # — the case no rotation can ever reach, because rotating about a point + # moves that point not at all. + var u := (_pw[b] - _pw[a]).normalized() + var s: float = clampf((at - _pw[a]).dot(u) / maxf(length, 0.0001), 0.0, 1.0) + var share_a := (1.0 - s) * COLLIDE_HEAD_SHARE + var share_b := s + (1.0 - s) * (1.0 - COLLIDE_HEAD_SHARE) + # Normalise so THE CONTACT POINT moves by the full overlap — not so the + # two endpoint moves add up to it. + # + # The point rides at fraction `s` along the bone, so it travels + # (1-s)*head + s*tip. Splitting the push into two shares that sum to it + # therefore delivers only about half of it to the place that is actually + # inside the leg, and a contact in the middle of a bone converged at + # roughly half the rate it should: measured, quadrupling the iteration + # count moved a 92 mm overlap to 76 mm and no further. + var eff: float = (1.0 - s) * share_a * wa + s * share_b * wb + if eff <= 0.0001: + continue + _pw[a] += push * (share_a * wa / eff) + _pw[b] += push * (share_b * wb / eff) + # Re-read, so the next limb sees where the bone has just gone. + if _fill_points(a, b, length, hull) == 0: + return + + +## Pose one bone's collision hull into the scratch buffer. Returns how many +## points were written. Kept out of the collider loop and off the heap: this runs +## for every cloth bone on every relaxation pass, and building a fresh Array of +## Vector3 each time was a large share of the solver's whole cost. +func _fill_points(a: int, b: int, length: float, + hull: PackedVector3Array) -> int: + var dir: Vector3 = _pw[b] - _pw[a] + if dir.length() < 0.00001: + return 0 + var u := dir.normalized() + # The bone's basis under the correction it has taken so far: the animated + # basis, swung onto the direction the particles settled on. + var basis: Basis = Basis(Quaternion(_rdir[a], u)) * (_rbasis[a] as Basis) + var head: Vector3 = _pw[a] + if hull.size() > 0: + if _pts.size() < hull.size(): + _pts.resize(hull.size()) + for i in hull.size(): + _pts[i] = head + basis * hull[i] + return hull.size() + if _pts.size() < SAMPLES.size(): + _pts.resize(SAMPLES.size()) + for i in SAMPLES.size(): + _pts[i] = head + u * (length * float(SAMPLES[i])) + return SAMPLES.size() + + +## Turn the settled particles into bone rotations, and store back the joint +## positions the skeleton ACTUALLY got. +## +## The feedback matters as much as the write. A joint's head is fixed by its +## parent's rotation, so the pose the skeleton ends up in is never quite the pose +## the constraints asked for. Integrating from what the solver WANTED rather than +## from what was rendered leaves a standing gap between the two, and that gap is +## a force nothing in the model agreed to — it is what made the old multi-pass +## version oscillate, because its final ancestor-lift pass wrote poses that were +## never fed back into the spring state at all. +func _write_poses(skel: Skeleton3D, anchor: Transform3D, ci: int) -> void: + var chain: Dictionary = _chains[ci] + var bones: PackedInt32Array = chain["bones"] + var segdir: PackedVector3Array = chain["segdir"] + var seg: PackedFloat32Array = chain["seg"] + var base: int = chain["base"] + var n: int = bones.size() + var inv_anchor := anchor.affine_inverse() + # Whatever bodily shift the colliders asked of the chain root. Every segment + # below it inherits this through the hierarchy, so the panel travels as one + # piece and the mesh it drives cannot be stretched by it — which is the one + # way translating cloth is safe where a weight gradient is not. + var shift: Vector3 = (_pw[base] - _ref[base]).limit_length(ROOT_STRAY) + + var pw := anchor + for i in n: + var bone: int = bones[i] + var rigid: Transform3D = pw * skel.get_bone_rest(bone) + if i == 0: + rigid.origin += shift + # This joint's head is now final; record where it really landed. + _q[base + i] = inv_anchor * rigid.origin + # The bone's own rest line, and where the particles want it to point. + var rest_dir: Vector3 = (rigid.basis * segdir[i]).normalized() + var want: Vector3 = _pw[base + i + 1] - rigid.origin + var world := rigid + if want.length() > 0.00001 and rest_dir.length_squared() > 0.5: + world.basis = Basis(Quaternion(rest_dir, want.normalized())) * rigid.basis + var local := pw.affine_inverse() * world + skel.set_bone_pose_rotation(bone, local.basis.get_rotation_quaternion()) + if i == 0: + skel.set_bone_pose_position(bone, local.origin) + pw = world + # The free tip, from the last bone's achieved orientation. + var tip_dir: Vector3 = (pw.basis * segdir[n - 1]).normalized() + _q[base + n] = inv_anchor * (pw.origin + tip_dir * seg[n - 1]) diff --git a/debug/cloth_allow_check.gd b/debug/cloth_allow_check.gd new file mode 100644 index 0000000..7b6e1df --- /dev/null +++ b/debug/cloth_allow_check.gd @@ -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 diff --git a/debug/cloth_clip_check.gd b/debug/cloth_clip_check.gd new file mode 100644 index 0000000..0f898c5 --- /dev/null +++ b/debug/cloth_clip_check.gd @@ -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 .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("") diff --git a/debug/cloth_clip_check.gd.uid b/debug/cloth_clip_check.gd.uid new file mode 100644 index 0000000..6ca0353 --- /dev/null +++ b/debug/cloth_clip_check.gd.uid @@ -0,0 +1 @@ +uid://c4x5gy6vjcvb0 diff --git a/debug/cloth_perf_check.gd b/debug/cloth_perf_check.gd new file mode 100644 index 0000000..3383d15 --- /dev/null +++ b/debug/cloth_perf_check.gd @@ -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 diff --git a/debug/cloth_settle_check.gd b/debug/cloth_settle_check.gd new file mode 100644 index 0000000..b24f253 --- /dev/null +++ b/debug/cloth_settle_check.gd @@ -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 diff --git a/debug/cloth_settle_check.gd.uid b/debug/cloth_settle_check.gd.uid new file mode 100644 index 0000000..1c4c507 --- /dev/null +++ b/debug/cloth_settle_check.gd.uid @@ -0,0 +1 @@ +uid://bk7pst2vhawvt diff --git a/debug/cloth_stretch_check.gd b/debug/cloth_stretch_check.gd new file mode 100644 index 0000000..f143b36 --- /dev/null +++ b/debug/cloth_stretch_check.gd @@ -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) diff --git a/debug/cloth_stretch_check.gd.uid b/debug/cloth_stretch_check.gd.uid new file mode 100644 index 0000000..98fc7a6 --- /dev/null +++ b/debug/cloth_stretch_check.gd.uid @@ -0,0 +1 @@ +uid://ccx4eh7wgfwrh diff --git a/debug/idle_jitter_check.gd b/debug/idle_jitter_check.gd new file mode 100644 index 0000000..c10dd36 --- /dev/null +++ b/debug/idle_jitter_check.gd @@ -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 -- +## +## 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 diff --git a/debug/idle_jitter_check.gd.uid b/debug/idle_jitter_check.gd.uid new file mode 100644 index 0000000..e44fd36 --- /dev/null +++ b/debug/idle_jitter_check.gd.uid @@ -0,0 +1 @@ +uid://6qrwda6ux54f diff --git a/debug/leg_radius_check.gd b/debug/leg_radius_check.gd new file mode 100644 index 0000000..1cb25dd --- /dev/null +++ b/debug/leg_radius_check.gd @@ -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 diff --git a/debug/leg_radius_check.gd.uid b/debug/leg_radius_check.gd.uid new file mode 100644 index 0000000..9d8530c --- /dev/null +++ b/debug/leg_radius_check.gd.uid @@ -0,0 +1 @@ +uid://co25textiw4vq diff --git a/debug/skirt_clip_view.gd b/debug/skirt_clip_view.gd new file mode 100644 index 0000000..9ed29c3 --- /dev/null +++ b/debug/skirt_clip_view.gd @@ -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 -- [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 diff --git a/debug/skirt_clip_view.gd.uid b/debug/skirt_clip_view.gd.uid new file mode 100644 index 0000000..59835f0 --- /dev/null +++ b/debug/skirt_clip_view.gd.uid @@ -0,0 +1 @@ +uid://4skd3rvnpm4s diff --git a/debug/skirt_closeup.gd b/debug/skirt_closeup.gd new file mode 100644 index 0000000..787c4e9 --- /dev/null +++ b/debug/skirt_closeup.gd @@ -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 -- [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 diff --git a/debug/skirt_closeup.gd.uid b/debug/skirt_closeup.gd.uid new file mode 100644 index 0000000..f769d6d --- /dev/null +++ b/debug/skirt_closeup.gd.uid @@ -0,0 +1 @@ +uid://5mxt3sn3sq1v diff --git a/debug/skirt_probe.gd b/debug/skirt_probe.gd new file mode 100644 index 0000000..26897cc --- /dev/null +++ b/debug/skirt_probe.gd @@ -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("") diff --git a/debug/skirt_probe.gd.uid b/debug/skirt_probe.gd.uid new file mode 100644 index 0000000..a036ac9 --- /dev/null +++ b/debug/skirt_probe.gd.uid @@ -0,0 +1 @@ +uid://b6wvfdpt1whek diff --git a/debug/skirt_run_view.gd b/debug/skirt_run_view.gd new file mode 100644 index 0000000..c7a1013 --- /dev/null +++ b/debug/skirt_run_view.gd @@ -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 -- [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 diff --git a/debug/skirt_run_view.gd.uid b/debug/skirt_run_view.gd.uid new file mode 100644 index 0000000..f1820b6 --- /dev/null +++ b/debug/skirt_run_view.gd.uid @@ -0,0 +1 @@ +uid://cxoj83cl5nnso diff --git a/debug/transition_check.gd b/debug/transition_check.gd new file mode 100644 index 0000000..169c6e8 --- /dev/null +++ b/debug/transition_check.gd @@ -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 diff --git a/debug/transition_check.gd.uid b/debug/transition_check.gd.uid new file mode 100644 index 0000000..c6ffc4c --- /dev/null +++ b/debug/transition_check.gd.uid @@ -0,0 +1 @@ +uid://deeo5pasohk5g