Room scale: collider, origin and turning
Executed head/body alignment without double movement and limitations at walls.
Room scale adds physical user movement to game locomotion. These are separate sources of motion. With a camera under an origin inside CharacterBody3D, the physical pose moves the camera relative to the body. The body may stay at the room center, testing collisions somewhere other than the head.
This guide implements one scheme: place the collider under the head’s horizontal projection and compensate body movement on the origin. It approximates torso position; it is not body tracking.
Hierarchy and prerequisites
Player (CharacterBody3D)
├── CollisionShape3D (CapsuleShape3D)
└── XROrigin3D
├── XRCamera3D
├── LeftHand (XRController3D)
└── RightHand (XRController3D)Use an upright body, an unscaled rig and world Y as up. The example does not implement arbitrary gravity, scaling, height adaptation, steps, moving platforms or a complete locomotion controller.
Place the collider relative to the Player root: a height-1.8 capsule can have its center at 0.9 when the root represents the feet. These are fixture dimensions, not mandatory player sizes. Handle crouching and different user heights separately, checking that resizing does not place the shape inside an obstacle.
Current tracking data must be available before calling. A numeric transform, HasFocus or a visible controller model alone does not establish pose validity. The helper accepts transforms already obtained; it neither starts XR nor determines tracking availability. See XR setup and terminology.
Synchronize without double movement
For the physical step:
- Calculate world-space head/body position difference on XZ only.
- Move CharacterBody3D toward the head using MoveAndCollide to account for its collider.
- Restore the origin’s previous world transform. Otherwise parent movement shifts the camera and hands a second time.
- Check the remaining head/body gap: an obstacle may have stopped the collider short of its target.
Save the helper as RoomScaleBody.cs. It is a static utility, not an attachable Entity script.
using System;
using Reckfell;
public static class RoomScaleBody
{
// Upright, unscaled rig with world Y as up. Does not change the tracked pose.
// true means a horizontal gap remains after trying to move the body.
public static bool FollowHead(CharacterBody3D body, XROrigin3D origin, Entity3D head)
{
ValidateRig(body, origin, head);
Vector3 requested = head.GlobalPosition - body.GlobalPosition;
requested.Y = 0;
if (!requested.IsFinite()) throw new InvalidOperationException("Invalid tracked position.");
if (requested.LengthSquared() < 0.00000001f) return false;
Transform3D fixedOrigin = origin.GlobalTransform;
try
{
using var collision = body.MoveAndCollide(requested);
}
finally
{
origin.GlobalTransform = fixedOrigin;
}
Vector3 remaining = head.GlobalPosition - body.GlobalPosition;
remaining.Y = 0;
return remaining.LengthSquared() > 0.000001f;
}
// Keeps head position while changing game yaw. No rotational collision sweep.
public static void TurnAroundHead(
CharacterBody3D body, XROrigin3D origin, Entity3D head, float yawRadians)
{
ValidateRig(body, origin, head);
if (!float.IsFinite(yawRadians)) throw new ArgumentOutOfRangeException(nameof(yawRadians));
Vector3 before = head.GlobalPosition;
if (!before.IsFinite()) throw new InvalidOperationException("Invalid tracked position.");
body.RotateY(yawRadians);
origin.GlobalPosition += before - head.GlobalPosition;
}
private static void ValidateRig(CharacterBody3D body, XROrigin3D origin, Entity3D head)
{
ArgumentNullException.ThrowIfNull(body);
ArgumentNullException.ThrowIfNull(origin);
ArgumentNullException.ThrowIfNull(head);
if (!body.IsInsideTree() || !origin.IsInsideTree() || !head.IsInsideTree())
throw new InvalidOperationException("The rig must be inside the tree.");
if (!body.IsAncestorOf(origin) || !origin.IsAncestorOf(head))
throw new ArgumentException("Expected body -> origin -> tracked head hierarchy.");
}
}Call RoomScaleBody.FollowHead(player, origin, camera) from your controller in a coordinated physics step after obtaining a current pose. Motion passed to MoveAndCollide is already a displacement rather than velocity: do not multiply it by delta again.
A true result means the remaining XZ gap exceeds 0.001 game units after the movement attempt. It does not report every contact: small residual gaps below tolerance are not treated as blocked, while an invalid hierarchy throws. Do not combine this synchronization with other code simultaneously moving the same body/origin or overwriting the pose.
Game locomotion and turning
After compensating physical movement, ordinary game movement of the body should carry the rig with it. Do not preserve the old origin for this step: compensation applies only to physical head movement that has already happened.
When the user is offset from the origin, simply turning around its center moves the head along an arc. TurnAroundHead applies body Y rotation and compensates origin position to preserve the head’s world position. The angle uses radians: pass Mathf.DegToRad(30) for 30°. World view orientation changes without overwriting the local tracked pose.
This is not a rotational collision sweep. The example assumes an upright symmetric capsule; arbitrary bodies need separate rotation-collision checks. Choose and device-test comfortable turning angles, smoothing and frequency rather than inferring them from successful helper compilation.
An alternative origin-centered scheme can keep the body outside the origin hierarchy and apply transfers explicitly. Do not mix the two schemes: this helper requires the body to be an ancestor of the origin and checks that condition.
Body stopped, head still moving
Physical movement does not obey the game collider. In the check, the capsule stopped before a wall while the synthetic head position lay beyond it. The helper preserved that position and reported a remaining gap. It neither pushes the tracked camera backward nor stops a person.
Handle this state in game policy: stop unwanted interaction, constrain game movement and provide suitable visual feedback or fading while preserving tracking. The helper does not implement those reactions; they require device testing. Game geometry boundaries do not replace information about the physical room.
Diagnose problems
| Symptom | Check |
|---|---|
| Physical step moves the image twice | Origin restored after body movement; no other script reapplies the same offset |
| Head follows an arc while turning | Turn compensates head position rather than only the origin |
| Collider stays far from the head | Current pose used; layers/masks and obstacles do not block the move |
| Head beyond wall, body before it | Possible physical-motion outcome; separate game response required |
| Crouching does not resize the collider | This example changes horizontal position only; height is separate |
| Hierarchy exception | Body must be an ancestor of origin, origin of head; all must be inside the tree |
Free movement, repeat calls, ordinary body translation, position-preserving turning, actual capsule/static-wall collision and invalid hierarchy were checked. XR was disabled; the head was an ordinary Entity3D with a synthetic transform. Real pose updates, room boundaries, comfort, stereo output, collider-height behavior and moving platforms were not tested.