Simulating physical properties on virtual objects can go a long way in enhancing the realism and fidelity of WebXR scenes. An immersive scene that obeys the rules of physics as we know them in the real world greatly strengthens the illusion of merging reality with virtuality for end users. As a developer, I personally never enjoyed implementing physical properties for individual objects, but the good news — and the reason why I am writing this post—is: adding physics to a WebXR scene has never been easier. In this post I will illustrate how you can easily get a physics engine up and running in your WebXR scene with a few lines of code, and will also walkthrough an extra demo to showcase physics with user interaction. All aboard the Physics Train!
I’ll start with a simple A-Frame scene with three RGB cubes next to each other. Note the positioning of these objects worked well for my working space, so feel free to adjust these values based on your requirements.
WebXR Physics with Hand Input
Now that we have our scene, we can simply add the aframe-physics-system
component, a lightweight physics engine for the web that is built on top of CANNON.js to the head of the page as such:
The library is now in, next we need to make sure physics is enabled in our scene:
Setting physics debug to true simply shows wireframes around objects that have physical properties. At this point we have none so we can’t see any wireframes yet (coming up next).
There are two main object types using this library:
dynamic-body
which is a freely moving object that collides with other objects.dynamic-body
objects fall down if gravity is enabled and have physical properties such as mass and bounciness.static-body
which is a fixed-position, non-moving object. Other objects can collide with them, butstatic-body
objects are not affected by gravity or collisions.
In our scene, we will need to specify all objects to be dynamic, with the exception of the plane that will be static to act as our virtual “floor/ground”. This will allow all other objects to fall on (and bounce off) the plane. Note that if we don’t specify the plane to be a static-body
, then all the dynamic objects will keep falling down (through the plane) into virtual gravity oblivion so we always need a static-body
somewhere to control/contain the dynamic objects within a scene.
. . .. . .
And now we have physics! It really is this simple to add physics to your A-Frame WebXR scene. You should also now see that all objects have wireframes on them signaling that they all have some kind of physical properties. Full script should look as follows:
WebXR Physics with Hand Input
Not that I want you to do this, but you can absolutely stop reading this post now if all you were looking for is —how to use the right physics library in A-Frame and get it up and running in your WebXR scene.
I wanted to showcase physics by doing something very simple as an extra in this post— grab objects (cubes) in our scene and stack them on top of each other, all while maintaining their physical properties. The main reason I am doing this is to showcase how controlling different physical states can lead to some cool experiences, especially when user interaction is involved. I usually like to dive deep into the Hand Input Module whenever I use hands for interaction as I did in previous posts — in this post however, I will be using A-Frame’s hand-grab-controls component for simplicity. We also get to cover an alternative, more A-Frame-y way, to use hand input which is cool.
Something worth mentioning here is that hand-grab-controls actually works perfectly out of the box without physics, so if you were to add two hands to the scene, and replace all the dynamic-body
and static-body
properties with grabbable
then you should be able to pinch and move objects around your environment using either hand since both are in our scene now. If we were to add grabbable
in addition to dynamic-body
and static-body
properties however, things can get a bit messy. This is what happens in my experience:
grabbable
withstatic-body
works as expected, but the impact ondynamic-body
objects would stay intact. For example if I pinch to move the plane, all the dynamic objects on the plane would fly/bounce of it.grabbable
withdynamic-body
would allow movement of the object upon pinching as well, but the object will snap back to its place after the pinch ends due to its dynamic physics properties.
To achieve my object stacking dream — I ideally want the two to work together, but for that we’ll need some additional logic to remove and append physical properties based on hand interaction events.
First thing to do is to add two hands to our scene:
. . .. . .
We should now have two hand models overlaying our real tracked hands in the scene.
So my simplified pseudo code thought process was as follows to get this working:
- I first need to listen to any collisions on any grabbable object, and save a reference of that object.
- Now that I know which object I am colliding with, I can then use pinch events to add or remove physics properties based on my pinching actions on the object I am colliding with (i.e. if a pinch is active then remove all physics properties and move/rotate with my hand, and once a pinch is released then re-attach dynamic physics properties so the object can fall down according to gravity and other physics properties.
First up I will add two components which we’ll fill up as we go:
. . .},
});
AFRAME.registerComponent('pinch-handler', {
init: function () {
};
});
. . .
A-Frame’s page on hand-grab-controls mentions obb-collider for debugging purposes, so I thought that may be a good option to use for detecting collisions, for that I’ll add two events in the collision-handler
component which will be later attached to every grabbable object:
. . .AFRAME.registerComponent('collision-handler', {
init: function () {
this.el.addEventListener('obbcollisionstarted', (e) => {
const pinchHandler = document.querySelector('[pinch-handler]').components['pinch-handler'];
if (!pinchHandler.grabbedObject) {
pinchHandler.collidingObject = this.el;
}
});
this.el.addEventListener('obbcollisionended', (e) => {
const pinchHandler = document.querySelector('[pinch-handler]').components['pinch-handler'];
if (!pinchHandler.grabbedObject || pinchHandler.grabbedObject === this.el) {
pinchHandler.collidingObject = null;
}
});
}
});
. . .
Essentially here we are listening for collisions on each object, if there is a collision with a given object we pass a reference of that object via collidingObject
to the pinch-handler
component. There are a couple of checks that I am doing here as well to avoid any unforeseen serious fights between collision and pinching events — when a collision starts, we set collidingObject
to the current object being collided with only if no other object is being grabbed by pinching (we will get that information from the pinch-handler
component). Similarly when a collision ends, we clear collidingObject
(i.e. set it to null) only if the collidingObject
is no longer being grabbed by a pinch.
Next up is adding pinch events to the pinch-handler
component:
. . .AFRAME.registerComponent('pinch-handler', {
init: function () {
this.collidingObject = null;
this.grabbedObject = null;
const rightHand = document.querySelector('#rightHand');
const leftHand = document.querySelector('#leftHand');
rightHand.addEventListener('pinchstarted', () => {
if (this.collidingObject && !this.grabbedObject) {
this.grabbedObject = this.collidingObject;
this.grabbedObject.removeAttribute('dynamic-body');
}
});
rightHand.addEventListener('pinchended', () => {
if (this.grabbedObject) {
this.grabbedObject.setAttribute('dynamic-body', 'mass: 1');
this.grabbedObject = null;
}
});
leftHand.addEventListener('pinchstarted', () => {
if (this.collidingObject && !this.grabbedObject) {
this.grabbedObject = this.collidingObject;
this.grabbedObject.removeAttribute('dynamic-body');
}
});
leftHand.addEventListener('pinchended', () => {
if (this.grabbedObject) {
this.grabbedObject.setAttribute('dynamic-body', 'mass: 1');
this.grabbedObject = null;
}
});
}
});
. . .
Here we add two pinching events for each hand in our scene. Starting a pinch removes dynamic-body
from the object being interacted with (which comes from the collision-handler
component) allowing it to move freely with your hand, and ending a pinch reinstates the dynamic-body
property with the default mass which makes the object fall down with gravity and collide with other objects as it would having physics. Again an important check here — grabbing an object with pinching can happen only if there is an active collision and no other object is being grabbed by pinching. When the pinch ends, we clear the reference to grabbedObject
(i.e. set it to null).
We should now have all the components to make my stacking cubes dream come true. We do however need to prep the objects to be grabbable and listened to by the collision-handler
component as follows. First we should add pinch-handler
component to the scene:
. . .. . .
Then add the grabbable
and collision-handler
properties to all the dynamic objects in our scene:
. . .. . .
Upon reviewing this post, I had a few points that I wanted to end on:
And that’s that for getting started with physics using A-Frame! I hope this was useful in showcasing what physics can do in WebXR.
Muadh out — Until next time! 🤜🤛
P.S.
The full script should now look like this. You can also grab the full script from the physics-hand-input-aframe GitHub repo:
WebXR Physics with Hand Input
this.el.addEventListener('obbcollisionended', (e) => {
const pinchHandler = document.querySelector('[pinch-handler]').components['pinch-handler'];
if (!pinchHandler.grabbedObject || pinchHandler.grabbedObject === this.el) {
pinchHandler.collidingObject = null;
}
});
}
});
AFRAME.registerComponent('pinch-handler', {
init: function () {
this.collidingObject = null;
this.grabbedObject = null;
const rightHand = document.querySelector('#rightHand');
const leftHand = document.querySelector('#leftHand');
rightHand.addEventListener('pinchstarted', () => {
if (this.collidingObject && !this.grabbedObject) {
this.grabbedObject = this.collidingObject;
this.grabbedObject.removeAttribute('dynamic-body');
}
});
rightHand.addEventListener('pinchended', () => {
if (this.grabbedObject) {
this.grabbedObject.setAttribute('dynamic-body', 'mass: 1');
this.grabbedObject = null;
}
});
leftHand.addEventListener('pinchstarted', () => {
if (this.collidingObject && !this.grabbedObject) {
this.grabbedObject = this.collidingObject;
this.grabbedObject.removeAttribute('dynamic-body');
}
});
leftHand.addEventListener('pinchended', () => {
if (this.grabbedObject) {
this.grabbedObject.setAttribute('dynamic-body', 'mass: 1');
this.grabbedObject = null;
}
});
}
});