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