the symptom, in your words

"Why SetBoneTransform / SetIk silently do nothing"

✓ verified on 26.08.05
lane Rigging & animationposted updated

▸ SYMPTOM

  • You call SetBoneTransform / TryGetBoneTransform and nothing useful happens, or worse, tails wind through the floor / torso spazzes.
  • SetIk / ClearIk compile and run but hands/feet never reach the target.
  • No exception; silent failure or intermittent corruption.

▸ CAUSE

Two different APIs, two different traps:

SetIk is AnimGraph-gated

SkinnedModelRenderer.SetIk does nothing on a direct-sequence rig. Engine XML docstring (Sandbox.Engine.xml):

Sets an IK parameter. This sets 3 variables that should be set in the ANIMGRAPH: ik.{name}.enabled / position / rotation.

Working hand/foot IK needs a .vanmgrph with those params wired. If you play sequences directly (Sequence.Name = ..., no AnimGraph), SetIk / ClearIk are inert.

SetBoneTransform is unsound on clip-animated bones

The API exists (TryGetBoneTransform[Local], SetBoneTransform(in Bone, localTx), verified against Sandbox.Engine.dll) but behaves inconsistently:

  • Overrides persist on bones the active sequence does not key → reads return your own prior write → unbounded accumulation (e.g. tail through the floor).
  • Overrides get stomped on bones the sequence does key → a delta-backout then corrupts the clean pose → torso spazz through shared skin weights.

Read-modify-write against live bone state cannot be made safe from component code on a sequence-driven rig.

Why, and the probe that settles it: SetBoneTransform is the ragdoll write path. Read through the engine source, SkinnedModelRenderer.SetBoneTransform routes SceneModel.SetBoneOverrideCSceneAnimatableObject::SetPhysicsBone, the same call a ragdoll drives every physics bone with. Override three or four bones of a skeleton whose remaining bones are still animation-driven and one skin is fed two incompatible pose sources; on a humanoid arm chain the mesh shatters into flat polygon shards, and no value tunes it out.

The decisive test is a passthrough probe: read each bone's post-animation transform and write it straight back unchanged. That is a no-op by construction, yet it was recorded shattering identically, which pointed at the write itself, not the value being written, as the fault. Run it before spending a session tuning a procedural pose.

Dated correction (engine 26.08.05). A later spike re-ran that exact case (a partial-bone write over a body still driven by a full AnimGraph) and the mesh stayed intact. So "because it shatters" is build-dependent and no longer a claim to lean on unverified: retest the passthrough probe on the build in front of you before citing shatter as the reason. The architectural guidance below is unchanged regardless: prefer ModelPhysics because it drives the whole physics skeleton and you never hand-write bones one at a time, not merely "because the alternative shatters."

Three related calls, distinguished:

CallRoutes toWhat it is
SetBoneTransformSceneModel.SetBoneOverride (persists)object-relative override applied AFTER animation (takes a transform relative to the renderer's own object)
SetBoneWorldTransformSceneModel.SetBoneWorldTransformworld-space override; a silent no-op on a graph-driven rig when you write only a subset of bones
TryGetBoneTransformSceneModel.GetBoneWorldTransformthe FINAL transform, INCLUDING the override
TryGetBoneTransformAnimationSceneModel.GetWorldSpaceAnimationTransformafter animation, BEFORE overrides

Pairing the first two produces the unbounded accumulation above: a write feeds its own read. Reading only through the third removes that feedback and makes every write absolute rather than a delta, and the mesh still shatters, so escaping the feedback loop does not make the API usable on a partially clip-driven skeleton. Clearing goes through SceneModel.ClearBoneOverrides, which is coarse: it drops every override on the model, not just yours.

Dated correction (engine 26.08.05). Two later spikes corrected the coordinate space and the graph-on behavior of these calls. First: SetBoneTransform does not take world space. It takes a transform relative to the renderer's own object, and the engine composes the object's transform back on afterwards. SceneModel.SetBoneWorldTransform is the call that takes world. On a subject at the world origin with no rotation the two look identical, so this can survive a whole spike undetected; give the subject a rotation and anything drawn at a computed bone position sits a body's width from the joint it belongs to. Second, and worse: with the animation graph RUNNING, the two are not interchangeable. Writing a subset of bones (a hand's finger chain, not the whole body) through SetBoneWorldTransform becomes a silent no-op. The value stores, TryGetBoneTransform reads it back exact (gap 0.0000, same frame), and it never reaches the skinned mesh, so a twenty-unit displacement photographs identically to the write never having happened. Feed SetBoneTransform the object-relative transform instead (renderer.WorldTransform.ToLocal( world )) and the pose reaches the render under the same graph-on, partial-write conditions. Every numeric instrument agrees the write worked in both cases, so a test battery built on transform readbacks alone passes a pose feature that draws nothing. Only a rendered frame separates a call that draws from one that does not. Retest the exact graph on/off and full-or-partial-write condition in front of you before picking SetBoneWorldTransform on a graph-driven rig.

There is a newer Procedural Bones path (GetBoneObject(i) → move the bone GameObject → ReadBonesFromGameObjects()), distinct from SetBoneTransform, but the bone must be flagged Procedural (model/AnimGraph authoring), and RMW against clip-keyed bones is still unsound.

▸ FIX

Verdict for a sequence-only rig: no runtime hand IK without authoring an AnimGraph.

When hands must grab a moving world target on such a rig:

  1. Proxy props welded to the target in world space: small meshes parented to the character root (not a visual child that carries squash/flip). Cartoon-acceptable, zero rig work.
  2. Whole-visual motion: rotate/offset the visual GameObject; bake follow-through into clips.
  3. Commit to AnimGraph: additive layers, bone masks, real IK params (renderer.Set("param", value) / Parameters.Set / SetLookDirection / UseAnimGraph). Citizen's CitizenAnimationHelper is the reference caller pattern.

For ragdoll crumple, use ragdoll-scripted-rig-npc (ModelPhysics), not per-bone C# posing.

For smooth clip switches only, use crossfade-without-animgraph. Do not reach for SetBoneTransform.

▸ WHY IT WORKS

Sequence playback owns the pose every frame for keyed bones; writing over that from gameplay code fights the animator. SetIk was never a free IK solver; it only feeds AnimGraph parameters. Proxy props sidestep the skeleton entirely: the grab is a separate scene object, so there is no bone write to corrupt or ignore.

Verified on engine 26.08.05: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Dated correction (26.08.05): SetBoneTransform takes an object-relative transform, not world; SetBoneWorldTransform takes world. On a graph-driven rig, writing a subset of bones through SetBoneWorldTransform is a silent no-op that every readback reports as success. Added the SetBoneWorldTransform routing row and the correction.
  • Dated correction (26.08.05): a later spike ran the exact partial-bone-write-over-full-animgraph case and the mesh stayed intact, so 'it shatters' is no longer a claim to lean on unverified. The reason to prefer ModelPhysics is unchanged; retest the shatter behaviour on your own build before citing it.
  • Added the engine-source mechanism: SetBoneTransform routes to SetPhysicsBone (the ragdoll write path), so driving a subset of a partly clip-animated skeleton shatters the skin. Added the passthrough-probe test and the SetBoneTransform / TryGetBoneTransform / TryGetBoneTransformAnimation routing table.

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord