Physics
All WebGL does is rasterize geometry. It doesn't load models or images from disk. It doesn't handle mouse events. It doesn't perform animations. If we want these behaviors in a renderer, then we write the code to perform them ourselves. But there's one task that is too big for both WebGL and us novice graphics developers: making our models obey the laws of physics. Writing a physics engine requires intimate knowledge of physical laws, floating point arithmetic, and spatial data structures. Since there's no room for these things in an introductory course, we'll get some help from a third-party physics library.
Several famous physics engines are available for desktop graphics. The Havok engine from Microsoft is used in some well-established franchises, like Bioshock, Elder Scrolls, Civilization, and The Legend of Zelda. The PhysX engine from NVIDIA is integrated in the Unity and Unreal game engines. The Bullet engine is an open source C++ library.
There aren't as many options for physics on the web. Only a few libraries have emerged, and even fewer are still maintained. Many of these are translations of the Bullet engine into JavaScript. Cannon-es is one of the few that still claims to be maintained, so we'll use it. We install it just as we installed our other dependencies:
npm install cannon-es
npm install cannon-es
Then in our script we bring in all the classes and enums with one comprehensive import statement:
import * as Cannon from 'cannon-es';
import * as Cannon from 'cannon-es';
The documentation tells us how to use what we've just imported. Let's reference it as we work through how a simple ball simulation.
Dropping the Ball
In our renderer, we maintain a physics simulation that tracks the positions and rotations of all the models in the visual scene. We initialize that simulation as an empty World
in which gravity pulls objects downward at the standard acceleration:
let physics: Cannon.World;
function initialize() {
// ...
physics = new Cannon.World({
gravity: new Cannon.Vec3(0, -9.81, 0),
});
}
let physics: Cannon.World; function initialize() { // ... physics = new Cannon.World({ gravity: new Cannon.Vec3(0, -9.81, 0), }); }
Next we populate this simulation with a collider for each model. In Cannon terms, a collider is a Body
. The simulator moves these colliders according to their velocities and the forces present in the world. If a collider hits another collider it bounces or slows. Detecting collisions for complex shapes is expensive, so models are usually approximated by simple bounding shapes like spheres and boxes.
Consider this renderer, which drops a collection of randomly positioned spheres:
This code, which goes in initialize
, creates a collider for each sphere and adds it to the simulation:
sphereBodies = [];
for (let i = 0; i < 30; ++i) {
const body = new Cannon.Body({
mass: 5,
shape: new Cannon.Sphere(0.5),
position: new Cannon.Vec3(
Math.random() * 10 - 5,
Math.random() * 10 - 5,
Math.random() * 10 - 5,
),
});
sphereBodies.push(body);
physics.addBody(body);
}
sphereBodies = []; for (let i = 0; i < 30; ++i) { const body = new Cannon.Body({ mass: 5, shape: new Cannon.Sphere(0.5), position: new Cannon.Vec3( Math.random() * 10 - 5, Math.random() * 10 - 5, Math.random() * 10 - 5, ), }); sphereBodies.push(body); physics.addBody(body); }
Cannon doesn't do any rendering. We use our vertex array abstraction for that. That code is the same as it was in our earlier renderers, so it is not shown.
The spheres should keep falling, but they hit a ground plane that has a collider but no visual model. Its collider is added like this:
const groundBody = new Cannon.Body({
type: Cannon.Body.STATIC,
shape: new Cannon.Plane(),
position: new Cannon.Vec3(0, -5, 0),
});
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
physics.addBody(groundBody);
const groundBody = new Cannon.Body({ type: Cannon.Body.STATIC, shape: new Cannon.Plane(), position: new Cannon.Vec3(0, -5, 0), }); groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); physics.addBody(groundBody);
Note that its type
is set to STATIC
. A static collider doesn't move, but other colliders may collide with it. The plane is rotated 90 degrees around the x-axis so it lies flat.
The simulation does not advance through time on its own. We must add an animation loop that drives the simulation forward on each frame:
function animate() {
physics.fixedStep();
render();
requestAnimationFrame(animate);
}
function animate() { physics.fixedStep(); render(); requestAnimationFrame(animate); }
Calling fixedStep
adjusts the positions and rotations of all the non-static colliders in the simulation. We apply those transformations in render
using translation and rotation matrices:
function render() {
// ...
for (let sphereBody of sphereBodies) {
const translater = Matrix4.translate(
sphereBody.position.x,
sphereBody.position.y,
sphereBody.position.z
);
const rotater = Matrix4.fromQuaternion(new Quarternion(
sphereBody.quaternion.x,
sphereBody.quaternion.y,
sphereBody.quaternion.z,
sphereBody.quaternion.w,
));
const transform = translater.multiplyMatrix(rotater);
shader.setUniformMatrix4fv(
'worldFromModel',
transform.toFloats()
);
sphereVao.drawIndexed(gl.TRIANGLES);
}
// ...
}
function render() { // ... for (let sphereBody of sphereBodies) { const translater = Matrix4.translate( sphereBody.position.x, sphereBody.position.y, sphereBody.position.z ); const rotater = Matrix4.fromQuaternion(new Quarternion( sphereBody.quaternion.x, sphereBody.quaternion.y, sphereBody.quaternion.z, sphereBody.quaternion.w, )); const transform = translater.multiplyMatrix(rotater); shader.setUniformMatrix4fv( 'worldFromModel', transform.toFloats() ); sphereVao.drawIndexed(gl.TRIANGLES); } // ... }
Gravity shouldn't be the only force that moves things around. We want the user to be able to act on colliders too. For that, we use the applyForce
function, which operates on individual colliders. The vector that we pass it communicates the direction and magnitude of the force. Click on a sphere in this renderer to push it away with applyForce
:
This renderer incorporates the picking algorithm discussed earlier to determine the index of the sphere being clicked on. Then it untransforms the mouse position to determine the ray that shoots through the viewing volume. This ray is scaled up by a large number of Newtons and applied as a force to the appropriate collider with code like this:
const force = new Cannon.Vec3(ray.x, ray.y, ray.z).scale(15000);
sphereBodies[selectedIndex].applyForce(force);
const force = new Cannon.Vec3(ray.x, ray.y, ray.z).scale(15000); sphereBodies[selectedIndex].applyForce(force);