Integrating a physics engine in Studio

Onirix%20Fisicas In this tutorial, you will learn to integrate a physics engine in an experience using Onirix Studio, Onirix Embed SDK, and cannon.js. Although cannon.js is our physics engine of choice, the principles outlined in this guide should apply to most alternatives.

In physics-based experiences, elements follow realistic rules and will seamlessly blend into the world. Thanks to that, physics will boost the immersiveness of your experiences and broaden the spectrum of the compositions you can achieve using Onirix. You can check our Binball to see what we are talking about.

Get ready to raise your new work to the next level!

Getting started

Before you can create a physics project in Onirix Studio, you need an Onirix account. To get yours, go to the register page, then fill out the sign-up form. After submitting, you will receive further instructions on how to validate your account.

The first step you should follow after signing up is to create a project with a scene. You can accomplish this by clicking the "Create" button in the projects dashboard of your account. Follow the instructions on the creation dialog. After that, you will end up in the editor. The scene you are currently editing will be the one you will enrich using physics, so go on and add some elements! Through this article, we will reference Binball when providing examples. Because of this reason, it may be a good idea to try something similar for your first experience.

When your scene is ready, continue reading the next section.

Coordinate systems

The position of an element is determined by a three-dimensional vector and its rotation by a quaternion. However, both of these mean nothing without a coordinate system. A coordinate system is the mechanism used to translate these values into the actual position and rotation of the element. Note that the position and rotation of an element are unique but it can be expressed with infinitely many position vectors and rotation quaternions.

This may sound a bit abstract so let's see an example: taking London as reference Madrid is 11° to the south and 3.5° to the west but taking New York as reference Madrid is 0.5° to the south and 70° to the east. There is a single Madrid that is in one, and only one, position but its position can be expressed in many different ways depending on the coordinate system. Different coordinate systems are useful for different purposes, for example, a New Yorker may find it easier to take New York as reference and a Londoner may find it easier to take London as reference.

Onirix uses several coordinate systems and it is important to know the differences between them to implement a physics experience successfully:

  • World system: In preview mode, augmented reality mode marker scenes, and augmented reality mode QR scenes it is the same as the scene system. In augmented reality mode surface scenes, this system is generated by our low-level augmented reality algorithms. The absolute position vector of the elements in this coordinate system is an implementation detail and you should not rely on them. However, you can use it to compute the position vector of some element relative to some other element, for example, the position vector of the camera with respect to some scene. This coordinate system is required because in augmented reality mode several surface scenes may be placed simultaneously and the position vector of the camera must be independent from all of them. The ON_POSE event provides the global position vector and global rotation quaternion of the camera and the SCENE_LOAD_END event provides the global position vector and global rotation quaternion of the scene.
  • Scene system: Position vectors and rotation quaternions in this system are computed with respect to the position and rotation of the scene. For example, this system is used in translateTo y rotateTo actions.
  • Relative system: Position vector and rotation quaternions in this system are computed with respect to the position and rotation of the parent of the element. Changing the position of a parent will alter the actual position of its children (but not their position vector) . The same is true about rotation. Note that the scene is the parent of all root elements and, also, is its own parent. In the latest, the relative system is equivalent to the scene system. For example, this system is used in the Studio editor to specify the position of some element.

For brevity, from now on we will refer to the position vector as position and the rotation quaternion as rotation.

The physics library expects a coordinate system that uses absolute position vectors. Consequently, you can choose between the world system and the scene system most of the time. You cannot apply the scene system if you expect multiple scenes to be loaded at the same time. As a recommendation, you should use the scene system whenever you can because it requires less transformations between coordinate systems. If you depend on the camera position, such as Binball, you may use any of them since you will need transformations between coordinate systems anyway. Binball uses the global coordinate system.

When using the global coordinate system you need to transform the position and rotation before passing it onto most Embed SDK actions. For example, in Binball we translate from the global system to the scene system in the getBallTransform and checkScore methods using:

this.quaternion.inverse().vmult(this.ball.position.vsub(this.position));

Also, we use the opposite transform (although, a bit simplified since we are ignoring the rotation) in the buildGround, buidBall and buildBin methods.

Now that we understand the limitations of each coordinate system we can start talking about setting up the world of physics and updating it.

Setting up the world

In cannon.js, a World instance is the physical system simulated and contains every object in the scene affected by physics. In Binball, the physics world is set up in the buildWorld method:

buildWorld() {
    const world = new CANNON.World({
        gravity: new CANNON.Vec3(0, -9.82, 0), // m/s²
    });

    this.buildGround();
    world.addBody(this.ground);

    this.buildBall();
    world.addBody(this.ball);

    this.buildBin();
    world.addBody(this.bin.ring);
    world.addBody(this.bin.front);
    world.addBody(this.bin.right);
    world.addBody(this.bin.back);
    world.addBody(this.bin.left);

    this.world = world;
}

First, we create an instance of the world using a downward directed gravity of 9.82 m/s². You can set it lower to give a moon-like feeling or higher for it to feel heavier. Then, we add the physical objects in the scene: ground, ball and bin. We use buildGround, buildBall and buildBin methods to create the bodies of the objects. We will dig deeper into it in the next few paragraphs. And, finally, we save the world instance because we will need it later.

Each physical object in our scene will need a cannon.js body instance in the world. Body instances have a material, a shape, and a bunch of other properties

The material defines the friction and restitution of bodies. Friction refers to the resistance that a material has to sliding on other materials. For example, ice has a very low friction while rubber has a very high friction. Restitution is the ratio of velocity kept after collisions. For example, rubber has a high restitution but metal has a low restitution.

The shape defines the structure of the body and when it collides with other bodies. Keep in mind that most of the time the shape you define here is an approximation of the real shape of an object. For example, you may model an apple as a sphere because it is much more efficient than using a collider shape with the real shape of the apple and the results are mostly the same. There are multiple types of shape in cannon.js:

  • Plane: a flat surface.
  • Sphere: a ball or some kind of rounded object that fits into a sphere.
  • Box: a three-dimensional prism with depth, height and width. Depending on your use case, a chair could be a single box or two boxes (one for the seat and other for the backrest).
  • Trimesh: a mesh built using triangles. It can be used to make more complex shapes.
  • ConvexPolyhedron: a mesh built using polyhedrons and that satisfies some mathematical conditions. If cannon.js throws an error when using this shape, you probably need to use Trimesh.

Whenever possible you should try to use planes, boxes and spheres. You can approximate a single object using multiple shapes (see addShape function in Body). If you really need to use something more complex, use ConvexPolyhedron. And, when you have no other solution use Trimesh. ConvexPolyhedron and Trimesh have many compatibility problems with other shapes and, also, are less performant. For an example of building a complex object using simpler ones, check the buildBin method of Binball. In that method, we approximate the bin with a ring for the top opening and four boxes for the four sides.

buildBin() {
    // Bin ring
    const ringCollider = CANNON.Trimesh.createTorus(0.22, 0.02, 16, 16);
    const ringRigidBody = new CANNON.Body({ mass: 0, shape: ringCollider });
    ringRigidBody.position.set(0, 0.45, 0);
    ringRigidBody.position.vadd(this.position);
    ringRigidBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
    this.bin.ring = ringRigidBody;

    // Bin container
    // Right (+x)
    const rightCollider = new CANNON.Box(new CANNON.Vec3(0.01, 0.45, 0.22));
    const rightRigidBody = new CANNON.Body({ mass: 0 });
    rightRigidBody.addShape(rightCollider);
    rightRigidBody.position.set(0.22, 0, 0);
    rightRigidBody.position.vadd(this.position, rightRigidBody.position);
    this.bin.right = rightRigidBody;

    // Left (-x)
    const leftCollider = new CANNON.Box(new CANNON.Vec3(0.01, 0.45, 0.22));
    const leftRigidBody = new CANNON.Body({ mass: 0 });
    leftRigidBody.addShape(leftCollider);
    leftRigidBody.position.set(-0.22, 0, 0);
    leftRigidBody.position.vadd(this.position, leftRigidBody.position);
    this.bin.left = leftRigidBody;

    // Front (-z)
    const frontCollider = new CANNON.Box(new CANNON.Vec3(0.22, 0.45, 0.01));
    const frontRigidBody = new CANNON.Body({ mass: 0 });
    frontRigidBody.addShape(frontCollider);
    frontRigidBody.position.set(0, 0, -0.22);
    frontRigidBody.position.vadd(this.position, frontRigidBody.position);
    this.bin.front = frontRigidBody;

    // Back (+z)
    const backCollider = new CANNON.Box(new CANNON.Vec3(0.22, 0.45, 0.01));
    const backRigidBody = new CANNON.Body({ mass: 0 });
    backRigidBody.addShape(backCollider);
    backRigidBody.position.set(0, 0, 0.22);
    backRigidBody.position.vadd(this.position, backRigidBody.position);
    this.bin.back = backRigidBody;
}

The constructor of the Body class can receive a shape and a material that can be built as described before. Also, you can provide some other parameters such as:

  • mass: how much does it weigh (in kilograms).
  • linearDamping and angularDamping: how fast does linear/angular velocity decay over time. A very high value will make your object feel like moving in a honey-like fluid, a medium value in water and a low value in the atmosphere.
  • type: it can be Body.DYNAMIC for normal objects, Body.STATIC for objects that cannot move (you should not move these objects manually neither), and Body.KINEMATIC that move according to its speed but doesn't react to forces (they push other dynamic objects but are not affected by them).

For an example, you can check the buildBall method.

buildBall() {
    const material = new CANNON.Material('paper');
    material.friction = 0.9;
    material.restitution = 0.5;
    const collider = new CANNON.Sphere(0.16);
    const rigidBody = new CANNON.Body({
        mass: 0.05,
        shape: collider,
        material: material,
        linearDamping: 0.9,
        angularDamping: 0.9
    });
    rigidBody.position.set(0, 0.1, 0);
    rigidBody.position.vadd(this.position, rigidBody.position);
    rigidBody.quaternion.copy(this.quaternion);
    rigidBody.type = CANNON.Body.STATIC;
    this.ball = rigidBody;
}

Updating the world

In the previous section, we have built the first epoch of our physical world. However, this world will remain static unless we actively trigger updates on it. There are two ways of achieving it: applying a fixed step or interpolating multiple steps.

For a fixed step you can use the world.fixedStep() function. This advances the physics simulation by 1/60 seconds so you should aim at calling this function 60 times per second at a regular rate. If you call it less than that, your simulation will feel too slow and if you call it more, it will seem too fast. You can pass a different time step as a parameter of the function to target a different frame rate.

If the frame rate is irregular, using a fixed step will make the scene laggy. This can be solved by interpolating multiple steps. You can do this by means of the world.step(delta) function where delta is the seconds passed since the last step. This method will perform multiple 1/60 seconds steps to advance the simulation by delta. There is a limit of 10 interpolated steps per call to step so, if delta is bigger than ⅙ seconds your simulation will not advance as fast as real time.

In the Binball experience we use the first approach.

Applying motion

Right now, our simulation is only affected by gravity and collisions. However, we may need to interact with the simulation from the outside. There are two ways of carrying out this interaction: forces and impulses. Forces and impulses are influences that cause the object to change its velocity. The difference between them is that impulses are short-lived and a force applies for some time. For example, someone pushing a box is a force but someone kicking a ball is an impulse. Forces can be applied with the applyForce function. Keep in mind that forces must be applied before every step of the simulation while they are active. In Binball we use forces to simulate the wind. Impulses can be applied with the applyImpulse function. In Binball we use impulses to simulate the throw of the ball.

Results