Physics Exploration

How to 3D

Chapter 9: Textures

Physics Exploration

Your next milestones involve driving around on a terrain with a physics system guiding movement. Adding physics to a world can be clumsy, given its complexity and novelty. Rather than watch me fumble through it, let's put you in the driver's seat with an exploratory lab. Follow these steps to get a car moving around a terrain with a third-person camera trailing it:

Clone the car-physics repository.
Run npm run install to pull down the dependencies, including cannon-es.
Copy in your Matrix4, Vector3, and Quaternion classes.
Run npm run start to see a terrain and red car floating above it. Fix any API discrepancies.
Create a new helper method named initializePhysics and call it after the models have been loaded. All initialization of the physics system described in later steps should be placed in this method.
Initialize the physics simulation with this code:
physics = new Cannon.World({
  gravity: new Cannon.Vec3(0, -9.81, 0),
});
physics.broadphase = new Cannon.SAPBroadphase(physics);
physics.defaultContactMaterial.friction = 0.0;
physics = new Cannon.World({
  gravity: new Cannon.Vec3(0, -9.81, 0),
});
physics.broadphase = new Cannon.SAPBroadphase(physics);
physics.defaultContactMaterial.friction = 0.0;
The simulation must resolve collisions. The naive algorithm (narrow phase) compares every collider with every other collider, which is \(O(n^2)\). This broadphase algorithm divides the scene into chunks and only considers collisions between neighbors.
Add a terrain collider with this code:
const groundMaterial = new Cannon.Material('ground');
terrainBody = new Cannon.Body({
  type: Cannon.Body.STATIC,
  shape: new Cannon.Heightfield(terrain.toArrays(), {
    elementSize: 1,
  }),
  position: new Cannon.Vec3(0, 0, 0),
  material: groundMaterial,
});
terrainBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
physics.addBody(terrainBody);
const groundMaterial = new Cannon.Material('ground');
terrainBody = new Cannon.Body({
  type: Cannon.Body.STATIC,
  shape: new Cannon.Heightfield(terrain.toArrays(), {
    elementSize: 1,
  }),
  position: new Cannon.Vec3(0, 0, 0),
  material: groundMaterial,
});
terrainBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
physics.addBody(terrainBody);
Define a bounding box that represents the car chassis in the physics simulation. It should have the same size as the model. This code does the trick:
let chassisDimensions = new Vector3(3.5, 1, 6);
const chassisShape = new Cannon.Box(new Cannon.Vec3(
  chassisDimensions.x * 0.5,
  chassisDimensions.y * 0.5,
  chassisDimensions.z * 0.5,
));
const chassisBody = new Cannon.Body({
  mass: 250,
  angularDamping: 0.8,
  linearDamping: 0.1,
  position: new Cannon.Vec3(15, 10, -20),
});
chassisBody.addShape(chassisShape);
let chassisDimensions = new Vector3(3.5, 1, 6);
const chassisShape = new Cannon.Box(new Cannon.Vec3(
  chassisDimensions.x * 0.5,
  chassisDimensions.y * 0.5,
  chassisDimensions.z * 0.5,
));
const chassisBody = new Cannon.Body({
  mass: 250,
  angularDamping: 0.8,
  linearDamping: 0.1,
  position: new Cannon.Vec3(15, 10, -20),
});
chassisBody.addShape(chassisShape);
Temporarily add the chassis body to the physics simulation with this call:
physics.addBody(chassisBody);
physics.addBody(chassisBody);
Make the physics simulation tick forward in animate with this call:
physics.fixedStep();
physics.fixedStep();
Apply the body's position and rotation to the chassis model in render:
const translater = Matrix4.translate(chassisBody.position.x, chassisBody.position.y, chassisBody.position.z);
const rotater = Matrix4.fromQuaternion(new Quaternion(chassisBody.quaternion.x, chassisBody.quaternion.y, chassisBody.quaternion.z, chassisBody.quaternion.w));
const translater = Matrix4.translate(chassisBody.position.x, chassisBody.position.y, chassisBody.position.z);
const rotater = Matrix4.fromQuaternion(new Quaternion(chassisBody.quaternion.x, chassisBody.quaternion.y, chassisBody.quaternion.z, chassisBody.quaternion.w));
Have a third-person camera follow behind the chassis as it moves and rotates. On every frame (in animate), we'll need to find a world-space vector that points backward, like this:
let back = new Cannon.Vec3(0, 0, 20);
back = chassisBody.quaternion.vmult(forward);
let back = new Cannon.Vec3(0, 0, 20);
back = chassisBody.quaternion.vmult(forward);
Then we elevate the vector so the camera sits higher:
back.y = 6;
back.y = 6;
Then we apply that vector to the chassis position:
const from = chassisBody.position.vadd(back);
const from = chassisBody.position.vadd(back);
That's all we need to make a first-person camera behave like a third-person camera:
camera = new FirstPersonCamera(
  new Vector3(from.x, from.y, from.z),
  new Vector3(chassisBody.position.x, chassisBody.position.y, chassisBody.positi
  new Vector3(0, 1, 0)
);
camera = new FirstPersonCamera(
  new Vector3(from.x, from.y, from.z),
  new Vector3(chassisBody.position.x, chassisBody.position.y, chassisBody.positi
  new Vector3(0, 1, 0)
);
Cannon has a RaycastVehicle abstraction that supports wheel mechanics. We're going to use that instead of individual colliders. Remove the addBody call for the chassis and create a vehicle instance instead:
vehicle = new Cannon.RaycastVehicle({
  chassisBody,
  indexRightAxis: 0,
  indexUpAxis: 1,
  indexForwardAxis: 2,
});
vehicle.addToWorld(physics);
vehicle = new Cannon.RaycastVehicle({
  chassisBody,
  indexRightAxis: 0,
  indexUpAxis: 1,
  indexForwardAxis: 2,
});
vehicle.addToWorld(physics);
The indices orient the car. We'll have the car move along its local z-axis.
Add four wheels to the vehicle using Cannon's API, like this:
const wheelOptions = {
  radius: 1,
  directionLocal: new Cannon.Vec3(0, -1, 0),
  suspensionStiffness: 30,
  suspensionRestLength: 0.3,
  frictionSlip: 1.4,
  dampingRelaxation: 2.3,
  dampingCompression: 4.4,
  maxSuspensionForce: 100000,
  rollInfluence: 0.00001,
  axleLocal: new Cannon.Vec3(1, 0, 0),
  chassisConnectionPointLocal: new Cannon.Vec3(),
  maxSuspensionTravel: 0.1,
  customSlidingRotationalSpeed: -10,
  useCustomSlidingRotationalSpeed: true,
};

// Front left is wheel 0
wheelOptions.chassisConnectionPointLocal.set(-2.5, 0, -2);
vehicle.addWheel(wheelOptions);

// Front right is wheel 1
wheelOptions.chassisConnectionPointLocal.set(2.5, 0, -2);
vehicle.addWheel(wheelOptions);

// Back left is wheel 2
wheelOptions.chassisConnectionPointLocal.set(-2.5, 0, 2);
vehicle.addWheel(wheelOptions);

// Back right is wheel 3
wheelOptions.chassisConnectionPointLocal.set(2.5, 0, 2);
vehicle.addWheel(wheelOptions);
const wheelOptions = {
  radius: 1,
  directionLocal: new Cannon.Vec3(0, -1, 0),
  suspensionStiffness: 30,
  suspensionRestLength: 0.3,
  frictionSlip: 1.4,
  dampingRelaxation: 2.3,
  dampingCompression: 4.4,
  maxSuspensionForce: 100000,
  rollInfluence: 0.00001,
  axleLocal: new Cannon.Vec3(1, 0, 0),
  chassisConnectionPointLocal: new Cannon.Vec3(),
  maxSuspensionTravel: 0.1,
  customSlidingRotationalSpeed: -10,
  useCustomSlidingRotationalSpeed: true,
};

// Front left is wheel 0
wheelOptions.chassisConnectionPointLocal.set(-2.5, 0, -2);
vehicle.addWheel(wheelOptions);

// Front right is wheel 1
wheelOptions.chassisConnectionPointLocal.set(2.5, 0, -2);
vehicle.addWheel(wheelOptions);

// Back left is wheel 2
wheelOptions.chassisConnectionPointLocal.set(-2.5, 0, 2);
vehicle.addWheel(wheelOptions);

// Back right is wheel 3
wheelOptions.chassisConnectionPointLocal.set(2.5, 0, 2);
vehicle.addWheel(wheelOptions);
The numbers in wheelOptions surely mean something to someone.
Draw the wheels in render much like we draw the chassis, but loop over the vehicle's wheel data:
for (let i = 0; i < 4; ++i) {
  const {position, quaternion} = vehicle.wheelInfos[i].worldTransform;
  const translater = Matrix4.translate(position.x, position.y, position.z);
  const rotater = Matrix4.fromQuaternion(new Quaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w));
  const transform = translater.multiplyMatrix(rotater);
  shader.setUniformMatrix4fv('worldFromModel', transform.toFloats());
  wheelVao.bind();
  wheelVao.drawIndexed(gl.TRIANGLES);
  wheelVao.unbind();
}
for (let i = 0; i < 4; ++i) {
  const {position, quaternion} = vehicle.wheelInfos[i].worldTransform;
  const translater = Matrix4.translate(position.x, position.y, position.z);
  const rotater = Matrix4.fromQuaternion(new Quaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w));
  const transform = translater.multiplyMatrix(rotater);
  shader.setUniformMatrix4fv('worldFromModel', transform.toFloats());
  wheelVao.bind();
  wheelVao.drawIndexed(gl.TRIANGLES);
  wheelVao.unbind();
}
Observe how the key listeners set the three globals move, turn, and brake. We want to use these values to apply forces to the wheels. move will affect the back wheels—giving us a rear-wheel drive vehicle. turn will affect the front wheels. brake will affect all the wheels. Apply forces to the wheels in animate according to these values like this:
vehicle.applyEngineForce(1000 * move, 2);
vehicle.applyEngineForce(1000 * move, 3);
vehicle.setSteeringValue(0.5 * turn, 0);
vehicle.setSteeringValue(0.5 * turn, 1);
vehicle.setBrake(100000 * brake, 0);
vehicle.setBrake(100000 * brake, 1);
vehicle.setBrake(100000 * brake, 2);
vehicle.setBrake(100000 * brake, 3);
vehicle.applyEngineForce(1000 * move, 2);
vehicle.applyEngineForce(1000 * move, 3);
vehicle.setSteeringValue(0.5 * turn, 0);
vehicle.setSteeringValue(0.5 * turn, 1);
vehicle.setBrake(100000 * brake, 0);
vehicle.setBrake(100000 * brake, 1);
vehicle.setBrake(100000 * brake, 2);
vehicle.setBrake(100000 * brake, 3);
My understanding of Cannon is that the wheels configured above are just notions. We also want them to have physical extent, so we create colliders to prevent other objects from passing through them. This code represents the wheel as cylinders that are the same size as the models:
const wheelBodies: Cannon.Body[] = [];
const wheelMaterial = new Cannon.Material('wheel');
vehicle.wheelInfos.forEach(wheel => {
  const cylinderShape = new Cannon.Cylinder(wheel.radius, wheel.radius, 0.6, 20);
  const wheelBody = new Cannon.Body({
    mass: 0,
    material: wheelMaterial,
  });
  wheelBody.type = Cannon.Body.KINEMATIC;
  wheelBody.collisionFilterGroup = 0;
  const quaternion = new Cannon.Quaternion().setFromEuler(0, -Math.PI / 2, 0);
  wheelBody.addShape(cylinderShape, new Cannon.Vec3(), quaternion);
  wheelBodies.push(wheelBody);
  physics.addBody(wheelBody);
})
const wheelBodies: Cannon.Body[] = [];
const wheelMaterial = new Cannon.Material('wheel');
vehicle.wheelInfos.forEach(wheel => {
  const cylinderShape = new Cannon.Cylinder(wheel.radius, wheel.radius, 0.6, 20);
  const wheelBody = new Cannon.Body({
    mass: 0,
    material: wheelMaterial,
  });
  wheelBody.type = Cannon.Body.KINEMATIC;
  wheelBody.collisionFilterGroup = 0;
  const quaternion = new Cannon.Quaternion().setFromEuler(0, -Math.PI / 2, 0);
  wheelBody.addShape(cylinderShape, new Cannon.Vec3(), quaternion);
  wheelBodies.push(wheelBody);
  physics.addBody(wheelBody);
})
Configure how the wheels and terrain interact:
const wheelGroundContactMaterial = new Cannon.ContactMaterial(wheelMaterial, groundMaterial, {
  friction: 0.3,
  restitution: 0,
  contactEquationStiffness: 1000,
});
physics.addContactMaterial(wheelGroundContactMaterial);
const wheelGroundContactMaterial = new Cannon.ContactMaterial(wheelMaterial, groundMaterial, {
  friction: 0.3,
  restitution: 0,
  contactEquationStiffness: 1000,
});
physics.addContactMaterial(wheelGroundContactMaterial);
Synchronize the wheel colliders with the vehicle state in a callback that runs on every tick of the simulation:
physics.addEventListener('postStep', () => {
  for (let i = 0; i < vehicle.wheelInfos.length; i++) {
    vehicle.updateWheelTransform(i);
    const transform = vehicle.wheelInfos[i].worldTransform;
    const wheelBody = wheelBodies[i];
    wheelBody.position.copy(transform.position);
    wheelBody.quaternion.copy(transform.quaternion);
  }
});
physics.addEventListener('postStep', () => {
  for (let i = 0; i < vehicle.wheelInfos.length; i++) {
    vehicle.updateWheelTransform(i);
    const transform = vehicle.wheelInfos[i].worldTransform;
    const wheelBody = wheelBodies[i];
    wheelBody.position.copy(transform.position);
    wheelBody.quaternion.copy(transform.quaternion);
  }
});

There's nothing to turn in because this isn't graded. We'll build on this exercise in Thursday's lab.

← Linear InterpolationLab: Textured Model →