Skip to content

[Feature] Reach IK: a target per chain, in world, model, shoulder or ground space - #1283

Merged
untoldengine merged 3 commits into
untoldengine:developfrom
miolabs:feature/mirror_reach_ik_upstream
Oct 3, 2026
Merged

untoldengine merged 3 commits into
untoldengine:developfrom
miolabs:feature/mirror_reach_ik_upstream

Conversation

@miogds

@miogds miogds commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

What

Reach IK bent every chain toward one shared world target, which suits a character grabbing at something and not limbs that each follow something of their own. This adds a target per chain.

  • setReachIKChainTargets(entityId:targets:weight:halflife:reach:targetHalflife:): one ReachIKChainTarget per chain, index-aligned with the chains. A chain whose entry is nil reaches for the shared target of setReachIKTarget, or keeps its pose when there is none, so existing callers are untouched.
  • A target is given in one of four spaces:
    • .world: a world position.
    • .model: a position in the entity's model space.
    • .shoulder: an offset from the chain's own shoulder along the model axes. The hand keeps its place relative to the body however the body moves that frame, with no frame of lag (hands driven by tracking).
    • .modelGround: a spot in model space whose height is ignored. The chain's end goes over it at the height the pose gives it, which is what a leg chain (hip, knee, ankle) needs to hold a foot where it stands while the pose lifts and lowers it.
  • Each chain's target is eased in its own space, and the easing halflife is a parameter (targetHalflife; 0 follows a source that is already filtered).

The chains are any two-bone chain, so the same solver holds legs as well as arms; nothing else in the pose pipeline changes.

Why

The CoolMirror demo (iPhone body capture plus the headset's own head and hands) drives both hands and both planted feet through it: the hands as shoulder-space targets, the feet as ground spots. The demo PR for UntoldArcade follows once this is in.

Tests

AnimationReachChainTargetTests: ten cases on a two-arm skeleton (each chain reaches its own target; the four spaces; a chain without a target keeps its pose or takes the shared one; target easing and its zero halflife; ease-out on release; full reach straightens the chain). The existing pose layer and reach tests are unchanged and pass.

Verified locally: 1524 engine unit tests (the external render-extension fixture excepted, a toolchain issue on my machine), a strict-concurrency build with no warning sites, and lint.

Docs

docs/API/UsingPoseLayers.md gains the per-chain targets.

Javier Segura added 2 commits October 2, 2026 09:06
…pace

Chains shared one world target, which suits a character grabbing at something and not hands that follow tracking. setReachIKChainTargets gives every chain its own; a shoulder-space target is an offset from the chain's shoulder, so the hand keeps its place on the body without a frame of lag. The easing of the targets is a parameter.
…ot where it stands

A target in the new modelGround space is a spot whose height is ignored: the chain's end goes over it at the height the pose gives it.
@miogds
miogds requested a review from untoldengine as a code owner October 2, 2026 07:21

@untoldengine untoldengine left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this — the per-chain target design (and the fallback-to-shared-target / fallback-to-pose semantics) is clean, and the test coverage across the four spaces is thorough.

One thing worth a doc note (not a bug, just a sharp edge): setReachIKTarget and setReachIKChainTargets write to the same underlying ReachIKState fields — weight, targetWeight, halflife, reach, and now targetHalflife too. If a caller drives some chains via setReachIKChainTargets (e.g. hands from tracking) and relies on the shared-target fallback via setReachIKTarget for other chains (e.g. a grab target) in the same session, whichever call runs last on a given frame silently overwrites the other's halflife/reach/targetHalflife. Given the fallback behavior explicitly invites mixing the two APIs, it'd be worth a line in UsingPoseLayers.md (or a code comment on the two functions) calling out that they share one blend/easing configuration per entity, so callers need to pick consistent values across both call sites rather than tuning them independently.

Everything else checks out — no correctness issues found in the easing state machine, the .modelGround/.shoulder space math, or the cleanup-on-release path.

… the shared influence is documented

setReachIKChainTargets set the same target easing the shared target
uses, so following tracked hands exactly made a shared grab target jump.
The chains' targets have their own halflife now. Weight, halflife and
reach stay one influence per entity, set by whichever of the two calls
runs last: said on both functions and in the guide.
@miogds

miogds commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, addressed in 3991416.

Weight, halflife and reach are one influence per entity, and that is now stated on both functions and in UsingPoseLayers.md, with per-chain differences pointed at setReachIKChainWeights.

The target easing was the one that could actually bite: following tracked hands exactly (targetHalflife: 0) also made a shared grab target jump instead of easing. The chains' targets have their own halflife now, so setReachIKChainTargets no longer touches how the shared target is eased.

Two tests cover the mix: exact per-chain following leaves the shared target eased, and the call made last sets the reach limit for every chain.

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.

2 participants