Skip to content

Foot IK with pelvis lowering, ground tilt, and edge smoothing#742

Merged
robtfm merged 8 commits intomainfrom
feat/foot-ik
Apr 28, 2026
Merged

Foot IK with pelvis lowering, ground tilt, and edge smoothing#742
robtfm merged 8 commits intomainfrom
feat/foot-ik

Conversation

@robtfm
Copy link
Copy Markdown
Collaborator

@robtfm robtfm commented Apr 27, 2026

Summary

Adds an idle-only inverse-kinematics pass that plants avatar feet on the actual ground beneath them — slopes, short steps, uneven terrain — so the avatar doesn't visibly hover or clip when standing on non-flat surfaces.

Toggle in-game with `/footik` (off by default). Applies to the primary user, foreign players, and scene NPCs.

How it works

Runs in `PostUpdate`, after `PlayerUpdate` and before `AttachSync`, with a manual transform-propagate pass so attached items see post-IK bone positions:

  1. Bone discovery — recursively finds `avatar_hips` and the six leg bones by name on each `AvatarShape`, caches them in a `FootIkRig` component. Self-heals when wearables reload (any cached bone going stale invalidates the rig and rebuilds the next frame).
  2. Per-foot raycast — `cast_ray_nearest` (true ray, not capsule) downward through the scene colliders that contain the avatar. Captures the contact normal too.
  3. Pelvis lowering — for each leg, computes the hip drop required to make the foot reach its plant target (law of cosines). The greater of the two becomes a world-Y translation on `avatar_hips`, converted to the bone's parent-local frame via the parent's full affine inverse (the rig is imported with a ~0.01x cumulative scale, so a rotation-only conversion would produce a ~1cm-instead-of-1m delta).
  4. Two-bone IK — analytic solve for hip + knee rotations to plant each foot at its target, using the avatar's forward direction as a pole hint.
  5. Foot tilt — rotates each foot bone toward the contact normal, axis-angle-clamped to `max_foot_tilt_deg` (default 30°).
  6. Engagement / weight blending:
    • `w_anim` ramps toward 1.0 while the active emote is an idle pose, otherwise 0.0, at the rate set by the emote's declared `transition_seconds` (`SceneMovementAnim` overridable flag, or URN match for engine-default idle).
    • Per-leg `engaged` ramps toward `reach_ok ? 1 : 0` over `engage_transition_seconds`. Going up beyond `max_step_up` or down beyond `max_pelvis_drop` makes a leg disengage; rotation `r_hip`/`r_knee`/foot tilt slerp from identity by the per-leg weight.
    • Final per-leg weight is `min(w_anim, engaged)` — the gates clamp rather than compound.
  7. Velocity-limited final Y — the foot's final world Y is rate-limited at `target_velocity_limit` m/s while engaged. Smooths the case where the foot's xz sweeps across a cliff edge during a turn-in-place (raycast result jumps but the foot output doesn't). Snaps on the first engaged frame after a disengaged one. Invariant under continuously-moving platforms (avatar moves with them, so the relative offset doesn't change).

Scope

  • Primary user, foreign players, NPCs (anything with `AvatarShape`).
  • Idle only — fades out via `w_anim` once the avatar enters a non-idle emote.
  • All knobs live in `FootIkConfig` with documented purposes; not yet exposed via console subcommands.

Out of scope (could follow up)

  • Foot-phase walking IK (engage during foot plants in walk/jog cycles — useful for stairs).
  • Toe-joint bend.
  • Console subcommands for live tuning.
  • Visibility/distance-based culling on foreign avatars and NPCs.

🤖 Generated with Claude Code

robtfm and others added 8 commits April 27, 2026 11:09
Two-bone IK on each leg plus a pelvis-drop pass so the avatar plants
both feet when standing on uneven ground (slopes, short steps). Per-leg
binary engagement gate: foot disengages if the target sits more than
max_step_up above the player or would need more than max_pelvis_drop
of hip drop to reach. Idle-only by default — fades out with horizontal
speed. Toggle in console with `/footik`.

Runs in PostUpdate after PlayerUpdate and before AttachSync, with a
manual transform-propagate pass so attached items see the post-IK bone
positions in the same frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Wearable reloads despawn the old armature and rebuild it. The cached
FootIkRig kept entity references that silently went dead, so IK
appeared to switch off until the player toggled it twice. Each frame
we now sanity-check that the seven cached entities still resolve via
GlobalTransform; if any don't, we strip the component and the existing
rebuild pass picks up the new bones on the next frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The movement scene already declares per-emote transition_seconds, and
the engine tracks an idle flag on scene-driven anims (mirrored on
ActiveEmote.overridable). Use these directly: ramp IK weight toward 1
while an idle pose is active, toward 0 otherwise, at rate
1/transition_seconds. Triggered emotes (dances etc.) never count as
idle; engine-default idle is detected via the URN.

The animation ramp is applied after the per-leg binary reach cutoff,
so unreachable feet (cliff edge, tall step) stay disengaged regardless
of weight, and the pelvis drop scales with the ramp.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Each per-leg ray hit also gives a surface normal; align the animated
foot world rotation toward it (axis-angle clamped to max_foot_tilt_deg,
default 30°), then slerp by the per-leg IK weight. Writes the foot's
local rotation derived from the post-IK knee global. Disengaged legs
(cliff edge, ramp-out) leave the foot in animation pose.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
When the avatar turns in place over a cliff edge, the foot's world XZ
sweeps across the edge and the raycast result jumps discontinuously
between the cliff top and the floor below. Even with both sides
reachable (so engagement stays at 1), the leg snaps frame-to-frame.

Track per-leg `last_final_y` and rate-limit the foot's actual world Y
output (animated_y + (target_y - animated_y) * w) at a configurable
m/s. Snap on the first engaged frame after a disengaged one so re-
engagement doesn't creep from stale state. Pelvis drop is sized to the
rate-limited final_y, not the back-derived IK target — the latter sits
within physical reach by design (the IK math clamps l_at) and would
keep the pelvis still.

Engagement transition is no longer compounded with the animation
weight — they clamp via min() instead, so each gates independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replace the With<PrimaryUser> filter with Or<(PrimaryUser, ForeignPlayer)>
on both cache and apply queries. Per-avatar state (anim_w, per-leg
engagement, last_final_y) moves out of system Locals into a new
FootIkRuntime component, inserted alongside FootIkRig at cache time.

The apply system now loops over all avatars; each iteration scopes its
raycasts via ContainingScene::get_position(avatar_pos), so the per-frame
ray cost stays bounded to scenes that actually contain the iterating
avatar. Pole hint, idle detection, pelvis drop, and per-leg writes all
target the iterating avatar. Logs include the entity id.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
AvatarShape is the common marker on every in-world avatar — the primary
user, foreign players, and scene-spawned NPCs. NPCs already have the
prerequisites (AvatarDynamicState from update_npc_velocity, ActiveEmote
from animate.rs, the same Mixamo skeleton from the wearable pipeline),
so there's nothing else to wire up.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@robtfm robtfm merged commit 7b1e860 into main Apr 28, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant