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:
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.
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;
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
Then we elevate the vector so the camera sits higher:
Then we apply that vector to the chassis position:
That's all we need to make a first-person camera behave like a third-person camera:
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);
back.y = 6;
back.y = 6;
const from = chassisBody.position.vadd(back);
const from = chassisBody.position.vadd(back);
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
The indices orient the car. We'll have the car move along its local z-axis.
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);
Add four wheels to the vehicle using Cannon's API, like this:
The numbers in
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);
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.