From f74f4456d2d9eb4452c2f847d0fbfa7d0a342c8b Mon Sep 17 00:00:00 2001 From: blugart-dev Date: Wed, 12 Aug 2026 14:11:43 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20rig=20bodies=20are=20top=5Flevel=20?= =?UTF-8?q?=E2=80=94=20moving=20character=20roots=20froze=20the=20rig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A character root that writes its transform every physics frame (CharacterBody3D.move_and_slide, nav-driven NPCs) re-teleported every rig body to parent_xform * local each frame, silently discarding that frame's physics integration. The rig froze solid while the character moved (no animation showing through, no visible hit reactions) and the springs pumped clamp-level velocities into the locked bodies, which released all at once when the root stopped (e.g. on death) as an explosive ragdoll. Position springs already carry the rig along with the character (their targets move with the skeleton), so parent transform inheritance was never load-bearing. top_level applies to runtime-built and baked rigs. Numbers from the FPS integration that found it (walking nav zombie, LeftUpperLeg travel over 240 physics frames): animation 10.3 rad, rendered 0.012 rad before / 9.8 rad after; death-ragdoll peak body velocity 10.1 m/s (the clamp, released) before / 3.5 m/s after. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++ addons/kickback/physics_rig_builder.gd | 15 ++++++++++++ test/test_runtime_rig.gd | 32 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19bbf2..fea3ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ ## [Unreleased] ### Fixed +- **Rig bodies are now `top_level` (world-space)** — a character root that writes its + transform every physics frame (`CharacterBody3D.move_and_slide()`, nav-driven NPCs) + re-teleported every rig body to `parent_xform * local` each frame, silently discarding + that frame's physics integration. The visible result: the rig froze solid while the + character moved (no animation showing through, no visible hit reactions) and the + springs pumped clamp-level velocities into the locked bodies — which released all at + once the moment the root stopped moving (e.g. on death), as an explosive ragdoll. + Position springs already carry the rig along with the character (their targets move + with the skeleton), so parent transform inheritance was never load-bearing. Applies + to both runtime-built and baked (RigBaker) rigs. - **Hits no longer knock a PERSISTENT ragdoll back to RAGDOLL** — `apply_hit()` only guarded `State.RAGDOLL`, so a hit on a held-down body (a corpse, a knockdown) fell through to the stagger/ragdoll logic; the resulting RAGDOLL state's settle→recovery diff --git a/addons/kickback/physics_rig_builder.gd b/addons/kickback/physics_rig_builder.gd index cf7497e..9b6db7d 100644 --- a/addons/kickback/physics_rig_builder.gd +++ b/addons/kickback/physics_rig_builder.gd @@ -96,6 +96,11 @@ func _adopt_baked_rig() -> bool: push_warning("PhysicsRigBuilder: Baked rig is missing %d bones (%s) — falling back to runtime generation" % [missing.size(), ", ".join(missing)]) return false + # Baked rigs predate (or may have been saved without) the world-space + # flag — enforce it on adopt, same rationale as _create_body. + for body: RigidBody3D in baked_bodies.values(): + body.top_level = true + _bodies = baked_bodies _rig_to_bone = baked_bones return true @@ -118,6 +123,16 @@ func _create_body(bone_def: BoneDefinition, bone_global: Transform3D) -> RigidBo body.gravity_scale = _tuning.gravity_scale body.angular_damp = _tuning.angular_damp body.linear_damp = _tuning.linear_damp + # World-space, immune to ancestor transform writes. Without this, a + # character root that moves every physics frame (CharacterBody3D + # move_and_slide, nav-driven NPCs) re-teleports each body to + # parent_xform * local every frame, silently discarding that frame's + # integration: the rig visibly freezes while springs pump clamp-level + # velocities into it — which then release all at once the moment the + # root stops (death), as an explosive ragdoll. The springs are what + # carry the rig along with the character (their targets already move + # with the skeleton); parent inheritance was never load-bearing. + body.top_level = true # Shape is offset locally along the bone direction (toward child bone) var col_shape := SkeletonDetector.create_collision_shape(bone_def) diff --git a/test/test_runtime_rig.gd b/test/test_runtime_rig.gd index d1f6361..4e64902 100644 --- a/test/test_runtime_rig.gd +++ b/test/test_runtime_rig.gd @@ -99,6 +99,38 @@ func test_springs_hold_pose_against_gravity(): "hips stay upright — springs track the animation orientation") +func test_rig_survives_moving_character_root(): + # Regression: a character root that writes its transform every physics + # frame (CharacterBody3D.move_and_slide, nav-driven NPCs) used to + # re-teleport every rig body to parent_xform * local each frame, + # silently discarding that frame's integration — the rig froze solid + # while springs pumped clamp-level velocities into it, which released + # as an explosive ragdoll the moment the root stopped (e.g. on death). + # Bodies are top_level now, so ancestor writes must not touch them. + var h = await _spawn() + var leg: RigidBody3D = h.get_body("UpperLeg_L") + assert_true(leg.top_level, "rig bodies are top_level (world-space)") + + # Drive the root like a nav NPC (one transform write per physics frame) + # while commanding a constant angular velocity, as the spring does. The + # body must actually integrate it: pre-fix this measures ~0.000 rad. + var travelled := 0.0 + var prev := leg.global_basis.get_rotation_quaternion() + for i in 30: + h.global_position.x += 0.002 + leg.angular_velocity = Vector3(0, 0, 4.0) + await wait_physics_frames(1) + var q := leg.global_basis.get_rotation_quaternion() + travelled += q.angle_to(prev) + prev = q + # The active spring immediately lerps the commanded velocity back toward + # its own (near-zero-error) target, so only a fraction of it integrates — + # ~0.17 rad measured. The regression pins ~0.000 rad exactly, so any + # clearly-nonzero travel discriminates. + assert_gt(travelled, 0.05, + "body orientation integrates while the root moves every frame") + + # ── Hit handling (apply_hit) ──────────────────────────────────────────────── func test_apply_hit_reduces_bone_strength():