A-Frame Adventures 01 - Smooth locomotion and snap turning
In VR one can move around by physically moving through the play space. However, virtual worlds easily span a size many times larger than the play space. In those cases an additional mode of locomotion is needed. Common approaches include teleportation and smooth locomotion. In this post we’ll be exploring implementing smooth locomotion in A-Frame. The goal is a simple component that can be used with a camera rig for smooth locomotion and snap turning.
The basics
When using A-Frame there is a common camera setup, where the <a-camera>
element is placed inside an entity that serves as a ‘rig’.
<a-entity id="rig" position="25 10 0">
<a-camera id="camera"></a-camera>
</a-entity>
To understand why this is needed, it’s important to know how A-Frame updates the position of the camera. Every tick the position of the VR headset (or AR device) is directly applied to the camera. Trying to change the position of the camera manually will not work, as it gets overriden straight away. Having the ‘rig’ entity works around this, since it can be moved around freely, indirectly moving the camera along with it. The camera is still getting its position set each tick, but this is now relative to the position of the rig.
These illustrations show both physical movement by the user and smooth locomotion. The user is represented by a red rectangle, and the play space is highlighted in pink. In the first figure the user physically moves through the play space, and by doing so moves through the virtual space as well. In the second figure the user moves using smooth locomotion. Notice how the play space shifts along with the user, as they remain stationary within that play space.
The implementation
Implementing this is pretty straightforward, so only the global steps will be described. For those interested in the code, my implementation is on GitHub and released on NPM.
The steps basically boils down to the following:
- Read user input
- Convert input into a direction
- Move the rig in that direction (world space)
Converting the input into a direction is trickier than it appears. The input from the user for smooth locomotion often comes from the thumbstick on one of the controllers. The position of the thumbstick is represented by two values ranging from -1 to 1. Each value corresponds to one of the axes. Converting this into a vector might not result in a unit vector. On top of that the vector ‘points’ in a direction relative to the controller and is 2D.
The following code will convert the 2D input direction vector into a 3D direction vector in world space:
// Get world rotation of the camera (= head)
const cameraWorldRotation = new THREE.Quaternion();
cameraEl.object3D.getWorldQuaternion(cameraWorldRotation);
// Convert the input direction into world direction
const worldDirection = new THREE.Vector3(inputDirection.x, 0, inputDirection.y);
worldDirection.applyQuaternion(cameraWorldRotation);
// Ignore vertical component
direction.y = 0;
direction.normalize();
const newPosition = rigEl.object3D.position.clone();
newPosition.addScaledVector(direction, /* walk speed */ 1.5 * dt / 1000);
The above code is for demonstration purposes only. Normally you’d want to avoid creating new Quaternion
and Vector3
instances.
Note that the smooth locomotion is relative to the camera. In other words, the head of the user. So moving ‘forward’, is moving in the viewing direction. This means that looking around while using smooth locomotion changes the direction the user is moving. Some people prefer to have the direction to be relative to the orientation of the controllers. This can easily be achieved by using the controller element for getting the world rotation, instead of the camera element.
Another consideration is the walk speed. In the above code the walk speed is fixed. Since the direction vector is normalized, it doesn’t matter if the thumbstick is pushed a little bit or all the way. If it’s desired to let this have an effect, determine the magnitude of the input direction vector and use that as a factor for the walk speed:
const inputMagnitude = Math.abs(inputDirection.x) + Math.abs(inputDirection.y);
const factor = Math.min(inputMagnitude, 1.0);
...
newPosition.addScaledVector(direction, /* walk speed */ factor * 1.5 * dt / 1000);
Snap turning
Now that smooth locomotion is done, all that is left is snap turning. With snap turning the user should be able to rotate their view in fixed increments, both clockwise and counterclockwise. Just like with the position, the rotation can’t be applied directly to the camera, as it will be overriden each time. With the camera rig in place, it’s simply a matter of rotation the rig:
Oops… that isn’t supposed to happen. As long as the user is at the origin of their play space, the rotation works fine. If the user is at an offset from the origin of the play space, rotating it will inadvertently also move the user through the virtual space. Simply rotating the ‘rig’ isn’t going to work. Instead, the rig needs to be moved as it’s being rotated in such a way that the user remains in the same spot in the virtual space. The following illustration shows the desired behaviour:
Notice how the center of the play space is moving with each iteration, but the user remains in the same spot.
The implementation
To achieve correct rotation, the desired target matrix for the user is computed (in world space). This is easy to do, since the user has to retain the same position and scale, just with a different rotation.
// Create a matrix that is rotated 15 degrees further than the current (world) rotation
const rotationMatrix = new THREE.Matrix4()
.makeRotationY(15 * Math.PI / 180)
.multiply(new THREE.Matrix4().extractRotation(cameraEl.object3D.matrixWorld));
// Copy over the scale and position from the original world matrix
const cameraTargetMatrix = rotationMatrix
.scale(new THREE.Vector3().setFromMatrixScale(cameraEl.object3D.matrixWorld))
.setPosition(new THREE.Vector3().setFromMatrixPosition(cameraEl.object3D.matrixWorld));
Now that the target matrix for the camera is know, the rig has to be rotated and moved to match it.
const inverseRigMatrix = rigEl.object3D.matrixWorld.clone().invert();
const cameraRelativeToRig = new THREE.Matrix4()
.multiplyMatrices(inverseRigMatrix, cameraEl.object3D.matrixWorld);
const newRigMatrix = new THREE.Matrix4()
.multiplyMatrices(cameraTargetMatrix, cameraRelativeToRig.invert());
// Decompose into position, rotation and scale
newRigMatrix.decompose(
rigEl.object3D.position,
rigEl.object3D.quaternion,
rigEl.object3D.scale);
And that’s it, snap turning works!
The aframe-locomotion
project
The reason I started looking into smooth locomotion and snap turning was because I needed it in a project I’m working on. Since smooth locomotion and snap turning are common forms of locomotion in VR, I created a separate project out of it, so others can use it as well. This project is available on NPM and the source is up on GitHub. You can also check out the online examples (requires VR headset and WebXR compatible browser).
I’m planning on expanding it with more modes of locomotion (teleportation, flying, dragging and smooth turning). Contributions, feedback, suggestions and donations are all more than welcome. :-)