Technical Guide to VR Arms IK and Weapon Logic

Overview

Lately, I’ve been working on my own VR demo project. I’ll share more about the project itself later. But one thing I’ve been focusing on is a specific problem many VR developers still struggle with: creating believable and responsive hand/arm movement systems.

Many games solve this by hiding the arms completely and only having floating hands. Indeed, that approach makes perfect sense. It avoids a huge number of technical and animation-related problems and keeps interactions responsive. But it’s not always the ideal solution, especially if you want players to feel like they truly exist inside a character.

I don’t claim to have a perfect solution for this, but during development, I ended up building a solution that worked well for me. Since I found the process interesting, I wanted to share some of the technical approach here for anyone who might be interested.


This document covers the full architecture of the VR hands, arms, IK pipeline and pistol interaction system. It’s built on top of Unity’s XR interaction Toolkit and uses a layered, procedural IK pipeline driven entirely by controller tracking. There is no motion-capture playback for arms during normal gameplay; every joint position is computed from the controller transform in real time, then filtered through several correction phases before reaching the IK solver.

For this demonstration, I created a simple blockout scene and made a 3D mirror asset. This allowed me to see the full character while recording and properly test the VR body system directly inside the game environment.



I also modelled a low-poly character. This represents the default “base-level” appearance intended for the project before cosmetics and additional clothing are added later on. So yes – the character is intentionally meant to look poor, empty and a little rough around the edges for now.

I rigged the character, keeping the rig system performance-efficient while still flexible enough to support future additions and more complex cosmetic systems later in development.


I needed a pistol. For that, I used the Peacemaker revolver as a reference, a classic Wild West icon.

After I complete the rig setup in Maya, I’ve created fundamental animations, such as walking or some hand/finger poses, which can’t come via the VR controller.

High-Level Architecture

The pipeline has three distinct conceptual layers:

[XR Tracking]
└── Controller transforms (right / left)
[IK Target Pipeline] all the interesting TA work lives here
├── VRControllerToIKTargets → produces RawOffset targets
├── VRArmBodyAvoidanceDual → corrects targets that clip the torso
├── VRArmReachClampDual → enforces arm-length limits + overhead guard
├── VRIKTargetRouter → selects between default and draw-override target
├── VRElbowHintDriverDual → drives elbow hint positions
└── VRElbowHintBodyAvoidanceDual→ prevents elbow hints from clipping body
[Skeleton / Animation]
├── Unity Animation Rigging → Two-Bone IK + Multi-Aim (solves per frame)
├── VRUpperBodyFollow → distributes head yaw/pitch into chest/neck/head bones
├── VRClavicleFollowDual → rotates clavicles as arms reach outward
├── VRForearmTwistDriver → redistributes wrist roll into forearm/elbow twist bones
├── HandPoseCopyDriver → copies finger rotations from a source rig into the live rig
└── VRVisualRecoil → additive local recoil on the gun mesh root

All scripts run in LateUpdate unless noted otherwise. Execution order attributes ([DefaultExecutionOrder(N)]) enforce a strict sequence so each layer reads the output of the previous one.


The IK Target Pipeline

Step 1 – Raw Target (VRControllerToIKTargets)

Script: VRControllerToIKTargets.cs Execution order: -1000 (runs first)

This script takes the live XR controller transforms and writes them into two world-space Transform objects, RightRawTarget and LeftRawTarget – applying inspector, tweakable local offsets for position and rotation. This is my primary calibration point: if the hand appears twisted or offset relative to the real controller, I tune RightRotOffsetEuler and RightPosOffsetLocal here.

The blue marker represents the live Right Controller transform. The yellow marker represents R_IKTarget_RawOffset, the calibrated raw IK target written by VRControllerToIKTargets. The green marker represents the final visible wrist/hand position, R_Wrist_JNT, after the IK system has solved the arm.

In this stage, the controller does not directly drive the wrist bone. Instead, the controller writes into the raw target first, giving the system a clean calibration point before later correction layers such as reach clamping and elbow hinting are applied.

// Core of UpdateRightTarget():
Vector3 baseWorldPos = RightController.position
+ RightController.rotation * RightPosOffsetLocal;
Quaternion baseWorldRot = RightController.rotation
* Quaternion.Euler(RightRotOffsetEuler);
RightRawTarget.position = baseWorldPos + baseWorldRot * rightCurrentPosOffset;
RightRawTarget.rotation = baseWorldRot * Quaternion.Euler(rightCurrentRotOffset);

The right hand also accumulates a recoil offset here, rightCurrentPosOffset / rightCurrentRotOffset. When AddRightHandRecoil() is called (by the pistol on every shot), it adds a kick value with randomised jitter. Both the target offset and the kick itself decay back to zero each frame using two Lerp speeds (rightRecoilOutSpeed, rightRecoilReturnSpeed). This produces the characteristic snap-out / ease-back feel of IK-driven recoil on the arm.

// Called by VRPistol on fire:
public void AddRightHandRecoil()
{
Vector3 posKick = rightRecoilPositionKick + Random jitter;
rightTargetPosOffset += posKick; // will decay to zero automatically
}

Step 2 – Body Avoidance (VRArmBodyAvoidanceDual)

Script: VRArmBodyAvoidanceDual.cs Execution order: 1100

Reads the raw target positions and clamps them so the arm can’t clip into the torso. Everything is computed in the body root’s local space, making it rotation-independent when the player turns.

Three constraints run in sequence per arm:

ConstraintWhat it prevents
innerSideLimitHand crossing too far into the torso center (e.g. left hand going right of spine)
chestForwardLimitTarget going behind the chest plane
shoulderBackLimitTarget going too far behind its own shoulder

The correction is exponential-smoothed (smooth = 20 by default) so the constraint engages gradually rather than popping.


Step 3 – Reach Clamp + Overhead Guard (VRArmReachClampDual)

Script: VRArmReachClampDual.cs Execution order: default

using System.Collections;
using UnityEngine;
public class VRArmReachClampDual : MonoBehaviour
{
[System.Serializable]
public class Arm
{
public Transform shoulder;
public Transform elbow;
public Transform wrist;
public Transform rawTarget;
public Transform clampedTarget;
[Header("Side")]
public bool isRightArm = true;
[HideInInspector] public float upperLen;
[HideInInspector] public float lowerLen;
[HideInInspector] public bool inited;
}
[Header("Mode")]
public bool exactHandsMode = true;
[Header("Body Space")]
public Transform bodyRoot;
public Arm right;
public Arm left;
[Header("Far Reach Clamp")]
[Range(0.90f, 1.0f)] public float hardClampRatio = 0.98f;
[Header("Optional Soft Far Clamp")]
public bool useSoftFarClamp = false;
public float softZoneMeters = 0.12f;
public float softCurvePower = 4f;
[Header("Optional Near-Body Safety")]
public bool useNearBodySafety = false;
public float minShoulderDistance = 0.22f;
public float maxBehindShoulder = 0.03f;
public float maxInwardCrossFromShoulder = 0.12f;
public float inwardPushForwardBias = 0.03f;
[Header("Overhead Bend Guard")]
[Tooltip("Dot product of (arm direction · world up) where the guard begins engaging. " +
"0 = arm horizontal, 1 = arm straight up. 0.25 ≈ 15° above horizontal.")]
public float overheadGuardStartDot = 0.25f;
[Tooltip("Dot product at which the full overhead clamp ratio is reached.")]
public float overheadGuardFullDot = 0.65f;
[Range(0.75f, 0.96f)]
public float overheadClampRatio = 0.84f;
public float overheadSmooth = 28f;
[Header("Smoothing (non-overhead path)")]
public bool smoothOnlyWhenClamped = true;
public float positionSmooth = 18f;
public float rotationSmooth = 18f;
public bool smoothAlways = false;
IEnumerator Start()
{
yield return null;
InitArm(right);
InitArm(left);
SnapClampedToRaw(right);
SnapClampedToRaw(left);
}
void LateUpdate()
{
if (exactHandsMode)
{
ApplyOverheadGuard(right);
ApplyOverheadGuard(left);
return;
}
ClampArm(right);
ClampArm(left);
}
void InitArm(Arm arm)
{
if (!arm.shoulder || !arm.elbow || !arm.wrist) return;
arm.upperLen = Vector3.Distance(arm.shoulder.position, arm.elbow.position);
arm.lowerLen = Vector3.Distance(arm.elbow.position, arm.wrist.position);
arm.inited = true;
Debug.Log($"[{arm.shoulder.name}] upper={arm.upperLen:F3} lower={arm.lowerLen:F3} total={(arm.upperLen + arm.lowerLen):F3}");
}
void SnapClampedToRaw(Arm arm)
{
if (!arm.rawTarget || !arm.clampedTarget) return;
arm.clampedTarget.SetPositionAndRotation(arm.rawTarget.position, arm.rawTarget.rotation);
}
void ApplyOverheadGuard(Arm arm)
{
if (!arm.inited || !arm.shoulder || !arm.rawTarget || !arm.clampedTarget) return;
Vector3 shoulderPos = arm.shoulder.position;
Vector3 rawPos = arm.rawTarget.position;
Quaternion rawRot = arm.rawTarget.rotation;
Vector3 toHand = rawPos - shoulderPos;
float dist = toHand.magnitude;
if (dist < 0.0001f)
{
arm.clampedTarget.SetPositionAndRotation(rawPos, rawRot);
return;
}
float upDot = Vector3.Dot(toHand / dist, Vector3.up);
float guardWeight = Mathf.InverseLerp(overheadGuardStartDot, overheadGuardFullDot, upDot);
float armLen = arm.upperLen + arm.lowerLen;
float maxReach = armLen * Mathf.Lerp(1f, overheadClampRatio, guardWeight);
Vector3 desiredPos = dist > maxReach
? shoulderPos + (toHand / dist) * maxReach
: rawPos;
float effectiveSmooth = Mathf.Lerp(200f, overheadSmooth, guardWeight);
float alpha = 1f - Mathf.Exp(-effectiveSmooth * Time.deltaTime);
arm.clampedTarget.position = Vector3.Lerp(arm.clampedTarget.position, desiredPos, alpha);
arm.clampedTarget.rotation = Quaternion.Slerp(arm.clampedTarget.rotation, rawRot, alpha);
}
void ClampArm(Arm arm)
{
if (!arm.inited || !arm.shoulder || !arm.rawTarget || !arm.clampedTarget)
return;
Vector3 shoulderPos = arm.shoulder.position;
Vector3 rawPos = arm.rawTarget.position;
Quaternion rawRot = arm.rawTarget.rotation;
Vector3 desiredPos = rawPos;
bool wasClamped = false;
if (useNearBodySafety && bodyRoot != null)
{
Vector3 shoulderLocal = bodyRoot.InverseTransformPoint(shoulderPos);
Vector3 targetLocal = bodyRoot.InverseTransformPoint(desiredPos);
float sideSign = arm.isRightArm ? 1f : -1f;
bool clampedInward = false;
float inwardAmount = (shoulderLocal.x - targetLocal.x) * sideSign;
if (inwardAmount > maxInwardCrossFromShoulder)
{
targetLocal.x = shoulderLocal.x - sideSign * maxInwardCrossFromShoulder;
clampedInward = true;
wasClamped = true;
}
float minZ = shoulderLocal.z - maxBehindShoulder;
if (targetLocal.z < minZ)
{
targetLocal.z = minZ;
wasClamped = true;
}
if (clampedInward)
targetLocal.z += inwardPushForwardBias;
desiredPos = bodyRoot.TransformPoint(targetLocal);
}
if (useNearBodySafety)
{
Vector3 shoulderToDesired = desiredPos - shoulderPos;
float currentDist = shoulderToDesired.magnitude;
if (currentDist > 0.0001f && currentDist < minShoulderDistance)
{
desiredPos = shoulderPos + shoulderToDesired.normalized * minShoulderDistance;
wasClamped = true;
}
}
float maxReach = (arm.upperLen + arm.lowerLen) * hardClampRatio;
Vector3 toDesired = desiredPos - shoulderPos;
float dist = toDesired.magnitude;
if (dist > maxReach && dist > 0.0001f)
{
desiredPos = shoulderPos + (toDesired / dist) * maxReach;
wasClamped = true;
}
else if (useSoftFarClamp)
{
float softStart = Mathf.Max(0f, maxReach - softZoneMeters);
if (dist > softStart && dist > 0.0001f)
{
float t = Mathf.InverseLerp(softStart, maxReach, dist);
float eased = 1f - Mathf.Pow(1f - t, softCurvePower);
float allowed = Mathf.Lerp(dist, maxReach, eased);
desiredPos = shoulderPos + (toDesired / dist) * allowed;
wasClamped = true;
}
}
{
Vector3 toHand = desiredPos - shoulderPos;
float d = toHand.magnitude;
if (d > 0.0001f)
{
float upDot = Vector3.Dot(toHand / d, Vector3.up);
float guardWeight = Mathf.InverseLerp(overheadGuardStartDot, overheadGuardFullDot, upDot);
if (guardWeight > 0f)
{
float armLen = arm.upperLen + arm.lowerLen;
float guardMax = armLen * Mathf.Lerp(1f, overheadClampRatio, guardWeight);
if (d > guardMax)
{
desiredPos = shoulderPos + (toHand / d) * guardMax;
wasClamped = true;
}
}
}
}
float posAlpha = 1f - Mathf.Exp(-positionSmooth * Time.deltaTime);
float rotAlpha = 1f - Mathf.Exp(-rotationSmooth * Time.deltaTime);
if (!wasClamped)
{
if (smoothAlways)
{
arm.clampedTarget.position = Vector3.Lerp(arm.clampedTarget.position, rawPos, posAlpha);
arm.clampedTarget.rotation = Quaternion.Slerp(arm.clampedTarget.rotation, rawRot, rotAlpha);
}
else
{
arm.clampedTarget.SetPositionAndRotation(rawPos, rawRot);
}
return;
}
arm.clampedTarget.position = Vector3.Lerp(arm.clampedTarget.position, desiredPos, posAlpha);
arm.clampedTarget.rotation = Quaternion.Slerp(arm.clampedTarget.rotation, rawRot, rotAlpha);
}
}

This is the most complex single script in the IK pipeline. It operates in two modes:

1- exactHandsMode = true (default for local player)
Below the shoulder height of the clamped target snaps 1:1 to the raw target – zero lag. The overhead guard is still applied on top.

2-exactHandsMode = false
Full soft/hard reach clamping runs: the hand is limited to hardClampRatio x armLength from the shoulder, with an optional soft zone that eases the clamp in with a power curve before the hard wall.

The overhead guard runs in both modes. When the vector from the shoulder to the hand points more than 15 degrees above horizontal(overheadGuardStartDot = 0.25), the reach limit tightens towards overheadClampRatio. This forces a visible elbow bend when the arm reaches upward -without it, the Two-Bone IK solver would fully extend the arm and the elbow would appear locked straight.

Smoothing inside the overhead zone uses a blended speed that transitions from 200 (effectively instant) at the boundary down to overheadSmooth at the top, removing the pop that would otherwise appear at the threshold.

float upDot = Vector3.Dot(toHand / dist, Vector3.up);
float guardWeight = Mathf.InverseLerp(overheadGuardStartDot, overheadGuardFullDot, upDot);
float maxReach = armLen * Mathf.Lerp(1f, overheadClampRatio, guardWeight);

Arm lengths (upperLenlowerLen) are measured at runtime from the actual skeleton bones in Start() — they automatically match whatever rig you author.

Reach clamp / clamped target debug visualisation.
The blue marker represents the live right controller input. The yellow marker represents R_IKTarget_RawOffset, the raw target produced from the controller. The red marker represents R_IKTarget_Clamped, the corrected target after the reach clamp and overhead guard have been applied. The green marker represents R_Wrist_JNT, the final visible wrist/hand result.

This stage prevents the arm from blindly following impossible controller positions. When the hand reaches too far or moves overhead, the system adjusts the target so the arm keeps a more believable bend instead of fully locking straight.

Step 4 – IK Target Router (IKTargetRouter)

Script: VRIKTargetRouter.cs

A simple two-input blender that selects between the default clamped target (R_IKTarget_Clamped) and an override source (R_IKTarget_DrawRuntime). During a holster draw, the override routes the IK to a separate target so the arm can be posed by the draw animation without fighting the controller. When the draw completes the override is cleared via ForceClearNow().

// In LateUpdate:
float targetWeight = (useOverride && overrideSource != null) ? 1f : 0f;
overrideWeight = Mathf.MoveTowards(overrideWeight, targetWeight, blendSpeed * Time.deltaTime);
outputTarget.SetPositionAndRotation(
Vector3.Lerp(sourceA.position, sourceB.position, overrideWeight),
Quaternion.Slerp(sourceA.rotation, sourceB.rotation, overrideWeight)
);

The output of this router (R_IKTarget_Final) is what the Animation Rigging Two-Bone IK constraint reads as its tip target.

Step 5 – Elbow Hint Driving (VRElbowHintDriverDual + VRElbowHintBodyAvoidanceDual)

Scripts: VRElbowHintDriverDual.cs as base driver, VRElbowHintBodyAvoidanceDual.cs avoidance override.

The elbow hint is what tells the Two-Bone IK solver which direction the elbow should bend. Without a hint the solver picks an arbitrary plane, and the elbow flips unpredictably.

VRElbowHintDriverDual computes the hint position from first principles

  1. It defines a bend plane from the shoulder-to-hand vector crossed with world up.
  2. It blends to an outward-stable plane when the arm is overhead (UpDot detection), preventing the elbow from twisting inward over the head.
  3. The final hint is placed along the arm at alongArmFactor (default 0.45 meaning almost halfway) then pushed outward by hintOutMeters.
// Mode A: chest-based plane (stable for cross-body)
Vector3 planeA = Vector3.Cross(armDir, up);
// Mode B: outward-stable (for overhead)
Vector3 planeB = Vector3.ProjectOnPlane(outward, armDir);
float overhead = Mathf.InverseLerp(0.35f, 0.75f, upDot);
Vector3 plane = Vector3.Slerp(planeA, planeB, overhead);
Vector3 desired = shoulderPos + armDir * (armLen * alongArmFactor) + plane * hintOutMeters;

VRElbowHintBodyAvoidanceDual runs after 1200 and pushes the hint outward, backwards, and downward when the hand is very close to the torso. This prevents the elbow from plunging into the avatar’s body when the hand is held near the chest.

Upper Body Skeleton System

These scripts animate the spine and shoulder bones so the body “reacts” to the head and arm positions.

VRBodyHeadFollow
Follow the XR Origin’s horizontal position and extract the camera’s flat forward vector to smoothly rotate the body root in yaw. The body never pitches with the head only yaw is applied, within a configurable max turn speed to prevent the body spinning too fast on sharp head turns.

VRUpperBodyFollow
Distributes the camera’s yaw and pitch across chest, neck, and head bones using configurable weights

BoneYaw weightPitch weight
Chest0.250.10
Neck0.450.35
Head1.000.85

This creates a natural S-curve look when the player looks up or to the side, rather than only the head bone rotating.

VRClavicleFollowDual
Arm length is measured from the actual skeleton at Start(). As the hand’s reach ratio (dist / armLen) crosses startAtReach01 (default 0.5), The clavicle begins rotating toward the hand direction. Full rotation is reached at fullAtReach01 (default 0.95). Yaw and pitch are computed in the clavicle parent’s local space for stability, then clamped to maxFollowDegrees.

float reach01 = dist / armLen;
float w = Mathf.InverseLerp(startAtReach01, fullAtReach01, reach01);
// ... then rotate clavicle by (yaw * w, pitch * w, 0)

VRForeArmTwistDriver

Reads the wrist bone’s X-axis rotation delta from its rest pose. That twist angle is redistributed across the elbow-twist and wrist-twist bones at configurable weights (elbowWeight = 0.25wristWeight = 0.5). Velocity is smoothed  SmoothDampAngle to prevent snapping. There are separate positive/negative degree limits to match the anatomical range of forearm pronation and supination.

Hand Pose System

The hand pose system is separate from the arm IK. It drives the finger bones of the visible hand mesh.

HandPoseCopyDriver
Runs at execution order 1600 – after all IK is solved. It holds references to a source rig (a hidden animator rig that plays hand-pose animations) and a live rig (the visible mesh skeleton). Every LateUpdate it copies the local rotation of each named finger bone from source to live, using a globalBlend and optional per-bone weight overrides.

The wrist (R_Wrist_JNT) has a reduced default weight of 0.35 because the IK system already controls wrist orientation — a full-weight copy would fight the IK result. All finger bones copy at full weight.

float alpha = alphaBase * boneWeight;
dst.localRotation = Quaternion.Slerp(dst.localRotation, src.localRotation, alpha);

SourceHandPoseAnimatorDriver + SourceHandPoseBridge

The source rig’s Animator is driven by a simple three-state machine: DefaultPreGrip (hand near holster), GripReady(pistol held). SourceHandPoseBridge translates two boolean flags — handInsideHolsterZone and pistolHeld — into which state to send to the Animator. The bridge is the single event receiver for both holster and pistol state changes.

handInsideHolsterZone=false, pistolHeld=false → SetDefault()
handInsideHolsterZone=true, pistolHeld=false → SetPreGrip()
pistolHeld=true (any) → SetGripReady()

Pistol Grab Flow

The pistol has five states: Holstered – Latched – Held – (Cocking) – Dropped.

State machine

Holstered ─── grip pressed inside holster zone ──→ Latched
Latched ─── grip released ──→ Holstered
Latched ─── pulled far enough (t >= 1) ──→ Held
Held ─── stick-down input ──→ Cocking → Held
Held ─── released near holster ──→ Holstered
Held ─── released far from holster ──→ Dropped

Draw Gesture (VRHolsterGrabZone)

A trigger collider on the holster zone detects when the right-hand transform enters. On grip-button press the pistol enters (Latched) and records latchHandStartPosition.

Each frame in LateUpdate, the hand delta vector is projected onto the draw direction:

Vector3 drawDir = (drawClearPoint.position - holsterAnchor.position).normalized;
float forwardPull = Vector3.Dot(handDelta, drawDir);
float usablePull = Mathf.Max(0f, forwardPull - movementDeadzone);
float t = Mathf.Clamp01(usablePull / drawDistance);
pistol.UpdateLatchedExtractionPose(t, holsterAnchor, drawClearPoint);

The pistol lerps from its holstered world position toward a draw-clear point as t goes 0→1. At t >= 1.0 CompleteGrab() is called.

CompleteGrab() – parenting the pistol to the hand

When setupTool is assigned (the common case), the gun is re-parented to handAttach and positioned using the authored heldLocalPosition / heldLocalEuler from VRWeaponSetupTool_v15.

Without a setup tool the legacy path (AlignRootSoChildMatchesTarget) is used — it rotates and translates the gun root so that a child gripPoint transform exactly aligns with the handAttach transform.

// Legacy alignment:
Quaternion deltaRot = target.rotation * Quaternion.Inverse(childPoint.rotation);
root.rotation = deltaRot * root.rotation;
root.position += target.position - childPoint.position;

Auto Holster on Release

If the player opens their grip while the gun’s holsterPoint is within autoHolsterDistance (default 0.20 m) and autoHolsterAngle(default 35°) of the holsterAnchor, the gun automatically re-holsters. Outside that window it drops and physics takes over (Rigidbody kinematic→off, gravity enabled).

Pistol Cock and Fire

Cocking – PlayCockAction()

The cocking action is gated: the pistol must be in Held state, the hammer must not already be cocked, and the cylinder must be closed (ammoSystem.CanCock). The action plays a coroutine (CockRoutineFromHandTiming) that does three things simultaneously:

  1. Hand animator — fires the PlayHammerCock trigger on the right-hand pose Animator at hammerCockAnimatorSpeed.
  2. Hammer mesh — slerps the hammerPart from hammerRestLocalRotation to hammerCockedLocalRotation between normalized times 0.55 and 0.675 of the total coroutine.
  3. Gun root offset — the entire gun translates/rotates back along cockBackRootPositionOffset / cockBackRootEulerOffsetduring the first half, then returns on the second half. This is the tactile “pull-back” feel.

The coroutine duration is derived from the clip length: effectiveDuration = baseClipDuration / animatorSpeed.

// Timeline (normalized 0→1):
// 0.00 → 0.55 : gun root sweeps backward (EaseInOutCubic)
// 0.55 → 0.675 : hammer rotates to cocked position
// 0.675 → 1.00 : gun root returns forward (EaseInOutCubic)

A sound plays the first frame hammerT >= 1.0 via shotFXEmitter.PlayHammerCock(). State returns to Held and hammerIsCocked = true.

Cock input is triggered by thumbstick pushed down past cockStickDownThreshold (default -0.7), with rising-edge detection (only fires on the frame the threshold is first crossed).

Firing – TryFireShot()

Fire input is the trigger button rising edge (configurable to primary/secondary button). TryFireShot() is gated on triggerPart != null and !triggerIsBusy. It starts TriggerVisualRoutine(attemptShot: true).

The routine runs a two-phase animation:

Phase 1 — trigger pull (duration: triggerPullDuration, default 0.08 s)

  • Slerps trigger from triggerRestLocalRotation → triggerPulledLocalRotation with EaseInOutCubic.
  • Sends a haptic impulse to the right controller immediately.
  • Fires the PlayShoot animator trigger on the hand-pose Animator.

Phase 2 — consequence logic (runs between pull and return phases)

hammerWasCocked AND ammo > 0 = live shot
hammerWasCocked AND ammo = 0 = empty click (no bullet)
hammer not cocked = dry fire sound only

On a live shot:

  • hammerIsCocked = false → ApplyHammerCurrentMechanicalPose() snaps hammer to rest.
  • visualRecoil.PlayRecoil() — local mesh recoil on the gun root.
  • shotFXEmitter.PlayShotFX() — muzzle flash, sound.
  • ammoSystem.RegisterShot() — raycast + ammo decrement.
  • VRControllerToIKTargets.AddRightHandRecoil() — arm IK recoil kick.

Phase 3 — trigger return (duration: triggerReturnDuration, default 0.16 s)

  • Slerps trigger back to rest.
  • Clears triggerIsBusy.

Hit Detection

Fired from RegisterShot() each time a live round is expended. Uses a single Physics.Raycast from the muzzle transform forward, masked to bulletConfig.hitLayers, with QueryTriggerInteraction.Ignore.

if (Physics.Raycast(muzzlePoint.position, muzzlePoint.forward,
out RaycastHit hit, bulletConfig.maxRange,
bulletConfig.hitLayers, QueryTriggerInteraction.Ignore))
{
if (hit.collider.TryGetComponent(out IDamageable damageable))
damageable.TakeDamage(bulletConfig.damage, hit.point, hit.normal);
}

Damage, range, hit layers, and debug visualisation are all data-driven via the PistolBulletConfig ScriptableObject. Ammo state is communicated outward via System.Action<int> onAmmoChanged and System.Action onAmmoEmpty events — nothing in the ammo system has a direct reference to UI or enemies.

Visual Recoil Layers

Three independent recoil layers stack:

LayerScriptWhat moves
Arm IK recoilVRControllerToIKTargetsThe IK target position/rotation — whole arm kicks back
IK target additive recoilVRIKTargetClampedRecoilThe clamped target in world space — secondary sway on the arm target
Mesh recoilVRVisualRecoilThe gun’s recoilRoot child transform — local slide/roll of the mesh itself

Each layer has its own kick vector, jitter, recovery speed, and magnitude clamp tuned independently. The mesh recoil stops writing the transform once it has fully recovered (using a squared-magnitude threshold) so it doesn’t fight the IK system at rest.

Holster Height

// Raycast straight down from the camera:
Physics.Raycast(new Ray(cameraTransform.position, Vector3.down), out hitInfo);
float headHeight = hitInfo.distance; // defaults to 1.8 m if no floor found
// Belt position as a fraction of head height:
float beltHeight = headHeight - (headHeight * beltHeightOffset);
belt.transform.localPosition = new Vector3(0, beltHeight, 0);

beltHeightOffset is a 0..1 fraction: 0 = belt at head height, 1 = belt at floor. Typical useful values are 0.3–0.5. Because this raycasts every frame, the holster adapts correctly to steps, ramps, and roomscale movement.

Execution Order Summary

Knowing the order is critical for debugging — if a script reads a value before the script that writes it has run, results will always be one frame behind.

-1000 VRControllerToIKTargets (writes RawOffset targets)
-900 VRArmBodyAvoidanceDual (corrects Raw targets in body space)
-800 VRArmReachClampDual (reads corrected Raw, writes Clamped)
-700 VRIKTargetRouter (selects default or draw override target)
-600 VRElbowHintDriverDual (reads final/clamped target, writes hint position)
-500 VRElbowHintBodyAvoidanceDual (corrects hint positions)
10 VRClavicleFollowDual (reads arm target, rotates clavicle)
1000 VRForearmTwistDriver (reads wrist bone, drives twist bones)
1600 HandPoseCopyDriver (copies finger rotations — last)

VRBodyHeadFollow and VRUpperBodyFollow run separately from the hand target pipeline. They only write body/head rotations, but their order should still be checked if clavicle or shoulder-space calculations depend on the final upper-body pose.

Final Thoughts

This system is still evolving, and I don’t see it as a universal solution for VR arms. Every VR project has different priorities: responsiveness, body presence, animation quality, comfort, performance, and interaction design all pull against each other. For this demo, my goal was to find a practical middle ground — keeping the controller-driven immediacy that VR needs, while adding enough procedural body behaviour to make the player feel more physically present inside the character.

What I found most interesting was how much of the final result came from small corrective layers rather than one large solution. The believable feeling did not come from IK alone, but from the combination of reach limits, elbow hints, body avoidance, clavicle response, twist correction, hand poses, recoil, and careful execution order. Each part is quite simple on its own, but together they create a system that feels more grounded and easier to extend.

There is still a lot I would like to improve, especially around two-handed interactions, animation blending, body calibration, and more natural full-body behaviour. But as a technical art exercise, this was a valuable example of the kind of work I enjoy most: building systems that sit between art, animation, code, and design, and turning a difficult production problem into something usable, readable, and scalable.

Discover more from Portfolio

Subscribe now to keep reading and get access to the full archive.

Continue reading