Warning: You should have a good understanding of the basic tutorials before venturing further.
Buoyancy causes things to be pushed in the opposite direction to gravity. This happens when they are less dense than the fluid they are in, like an apple in water, or a helium balloon in air (yes, technically air is a fluid :) The strength of the buoyancy force depends on the mass of fluid displaced.
We can simulate this effect in Box2D by finding the displaced fluid mass, and applying a force equal and opposite to the gravity that would have acted on that mass. The displaced mass will be the area of fluid displaced, multiplied by its density. So the main task is finding the area of overlap between the fluid and the floating object.
We can use sensor shapes to represent water, and calculate the overlapping portion between the water and other shapes. A full implementation of this would require an algorithm to calculate the intersection between two polygons, between two circles, and between a polygon and a circle. In this topic we will just look at intersection of two polygons.
Once the overlapping portion between two polygons has been found, we can use its area to calculate the necessary force, and apply that force at the centroid of of the intersecting area. We will also need to apply some drag to stop it bouncing around forever.
Buoyancy forces need to be calculated and applied every time step. We will use the BeginContact and EndContact functions of the collision listener to keep track of a list of overlapping shape pairs, and every time step we will run through that list and see what forces are necessary. It's handy to create a shape pair type like this:
// Create a pair type to store shape references
const createShapePair = (shapeA, shapeB) => ({shapeA, shapeB});
The list of overlapping shapes only needs to include those that are relevant to buoyancy, so we can set some conditions on what types of shapes get put into the list. For example we might want something like this - one shape should be a sensor, and the other should be on a dynamic body:
// Store shape pairs in a Map to keep track of the actual objects
const shapePairObjects = new Map();
function beginContact(contact) {
const shapeA = b2GetShape(world, contact.shapeIdA);
const shapeB = b2GetShape(world, contact.shapeIdB);
if (shapeA.IsSensor() && b2Body_GetType(b2Shape_GetBody(shapeIdB)) === DYNAMIC) {
const pair = createShapePair(shapeA, shapeB);
shapePairObjects.set(`${shapeA.id}-${shapeB.id}`, pair);
}
else if (shapeB.IsSensor() && b2Body_GetType(b2Shape_GetBody(shapeIdBA)) === DYNAMIC) {
const pair = createShapePair(shapeB, shapeA);
shapePairObjects.set(`${shapeB.id}-${shapeA.id}`, pair);
}
}
function endContact(contact) {
const shapeA = b2GetShape(world, contact.shapeIdA);
const shapeB = b2GetShape(world, contact.shapeIdB);
if (shapeA.IsSensor() && b2Body_GetType(b2Shape_GetBody(shapeIdB)) === DYNAMIC) {
shapePairObjects.delete(`${shapeA.id}-${shapeB.id}`);
}
else if (shapeB.IsSensor() && b2Body_GetType(b2Shape_GetBody(shapeIdA)) === DYNAMIC) {
shapePairObjects.delete(`${shapeB.id}-${shapeA.id}`);
}
}
Note that the BeginContact/EndContact are almost the same, the only difference is the add/delete. Also important to note is that we always make the water shape the first member of the pair, and the floating object is the second member. This will be important later.
Of course in a real game you would probably not want to make every sensor a water shape, so the exact conditions used will depend on your situation. Once we have obtained a list of the currently overlapping and relevant shape pairs, we can go through that list every time step and do the calculations for their overlapping area.
Intersection of two polygons
Calculating the overlapping portion between two polygons is not a trivial procedure, and although it is a core part of this topic it is not really the main focus. There are many established methods for finding the intersection, and a good place to start is Sutherland-Hodgman polygon clipping, which has source code for a common implementation in many languages. For this topic I took the Javascript version and adapted it to make a function with this outline:
function findIntersectionOfShapes(shapeA, shapeB, outputVertices) {
// Returns boolean success and fills outputVertices array with intersection points
}
This implementation would only handle polygon-vs-polygon shape pairs. The returned vertices for the overlapping area would be in world coordinates. Other methods may be faster, so choose the one that suits your situation best. For example if your water surface only needs to be a single horizontal line instead of a shape/region, you may be able to take some shortcuts.
Area and centroid of a polygon
Likewise, calculating the area and centroid of a polygon is not the focus of this topic. Box2D includes functions for computing polygon properties, which we can use to calculate both area and centroid. The function signature would look like:
function computeCentroid(vertices) {
// Returns {centroid: b2Vec2, area: number}
}
Updating each time step
Once we have the groundwork ready for processing the shape pairs, we can set up a loop which goes through them every time step:
// Inside step function, loop through all currently overlapping shape pairs
for (const pair of shapePairs) {
// shapeA is the fluid
const shapeA = pair.shapeA;
const shapeB = pair.shapeB;
const density = b2Shape_GetDensity(shapeA.id);
const intersectionPoints = [];
if (findIntersectionOfShapes(shapeA, shapeB, intersectionPoints)) {
// find centroid and area
const {centroid, area} = computeCentroid(intersectionPoints);
// apply buoyancy stuff here...
}
}
Applying buoyancy force
Once we have the overlapping area and centroid for a polygon pair, applying the buoyancy force is dead simple. Multiplying the overlapping area by the density of the fluid gives us the displaced mass, which we can then multiply by gravity to get the force:
const gravity = new b2Vec2(0, -10);
// Apply buoyancy force (shapeA is the fluid)
const displacedMass = b2Shape_GetDensity(shapeA.id) * area;
const buoyancyForce = new b2Vec2(
displacedMass * -gravity.x,
displacedMass * -gravity.y
);
b2Body_ApplyForce(b2Shape_GetBody(shapeB.id), buoyancyForce, centroid, true);
Applying simple drag
At this stage, floating bodies will be pushed upwards when they overlap the fluid, but with nothing to slow them down they keep getting faster until they actually jump completely out of the fluid, then fall down, and jump back out, and so on forever. What we need is some drag force to slow these bodies down while they are in the fluid.
We can create a simple drag by applying a force to the body in the opposite direction to the current movement, at the centroid position. As we can see from the drag equation, the magnitude of the drag force should be proportional to the density of the fluid and the square of the velocity.
// Find relative velocity between object and fluid
const bodyVel = b2Body_GetLinearVelocity(b2Shape_GetBody(shapeB.id));
const fluidVel = b2Body_GetLinearVelocity(b2Shape_GetBody(shapeA.id));
const relativeVel = new b2Vec2(bodyVel.x - fluidVel.x, bodyVel.y - fluidVel.y);
const velMagnitude = Math.sqrt(relativeVel.x * relativeVel.x + relativeVel.y * relativeVel.y);
if (velMagnitude > 0) {
const velDir = new b2Vec2(
relativeVel.x / velMagnitude,
relativeVel.y / velMagnitude
);
// Apply simple linear drag
const dragMag = b2Shape_GetDensity(shapeA.id) * velMagnitude * velMagnitude;
const dragForce = new b2Vec2(
dragMag * -velDir.x,
dragMag * -velDir.y
);
b2Body_ApplyForce(b2Shape_GetBody(shapeB.id), dragForce, centroid, true);
}
We can also apply some angular drag to prevent the floating bodies from rotating forever:
// Apply simple angular drag
const angularDrag = area * -b2Body_GetAngularVelocity(b2Shape_GetBody(shapeB.id));
b2Body_ApplyTorque(b2Shape_GetBody(shapeB.id), angularDrag, true);
Better drag
The drag shown above is quite primitive. It makes no difference what shape the floating body is, the drag will always be the same for a given area. We can make a much better calculation for the drag by looking at the individual edges which are actually causing the drag. For instance, we could look at the leading edges of the body as it moves through the fluid, because it is their area which resists the movement.
// Apply drag separately for each polygon edge
for (let i = 0; i < intersectionPoints.length; i++) {
// The end points and mid-point of this edge
const v0 = intersectionPoints[i];
const v1 = intersectionPoints[(i + 1) % intersectionPoints.length];
const midPoint = new b2Vec2(0.5 * (v0.x + v1.x), 0.5 * (v0.y + v1.y));
// Find relative velocity between object and fluid at edge midpoint
const velDirA = b2Body_GetLinearVelocityFromWorldPoint(shapeA.bodyId, midPoint); // TODO: this function does not exist... open query on box2d discord: https://discord.com/channels/460295137705197568/1250865746494619650/1310451914441363527
const velDirB = b2Body_GetLinearVelocityFromWorldPoint(shapeB.bodyId, midPoint);
const velDir = new b2Vec2(velDirB.x - velDirA.x, velDirB.y - velDirA.y);
const vel = b2GetLengthAndNormalize(velDir);
const edge = new b2Vec2(v1.x - v0.x, v1.y - v0.y);
const edgeLength = b2GetLengthAndNormalize(edge);
const normal = new b2Vec2(-edge.y, edge.x);
const dragDot = b2Dot(normal, velDir);
if (dragDot < 0) {
continue; // Normal points backwards - this is not a leading edge
}
const dragMag = dragDot * edgeLength * density * vel * vel;
const dragForce = new b2Vec2(dragMag * -velDir.x, dragMag * -velDir.y);
b2Body_ApplyForce(shapeB.bodyId, dragForce, midPoint, true);
}
Now that we are applying many smaller forces around the body and taking into account the actual surface areas contributing to the drag, we start to see some more realistic looking effects. For example, these two shapes should slow down at very different rates after falling directly downwards into the fluid, right?
Lift
Lift is the force generated perpendicular to the direction of movement. Our simulation is looking reasonably good already with just drag, but without applying any lift force we are missing some nice touches that give extra realism. Consider the situation below - if these two bodies were pushed toward the right, they both present about the same surface area in the direction of travel to resist the movement. So with our current drag force, the resulting effect will be about the same... but that doesn't feel right at all.
Our natural sense tells us that the body on the left should tend to rise upward as it moves, due to the shape of its leading edge. As another example, consider the case below where a plank of wood is held diagonally in a stream of water, or a sailboat is catching wind in its sail. Drag pushes the plank downstream, and lift pushes it sideways. (Lift is not necessarily an upwards force, even though the name kinda sounds like it.)
What we're dealing with here is known as flat plate aerodynamics (or hydrodynamics). The lift should be zero when the plank is at right angles to the stream and highest somewhere in-between, with the maximum force at 45 degrees. You could consider this to be proportional to the area of the rectangle that the plank is a diagonal of.
To put this into a calculation, I have multiplied the dot product that we had earlier (the edge normal and the direction of movement) with the dot product of the edge direction and the direction of movement. This effectively becomes cos(angle) * cos(90-angle). One of these is zero at zero degrees, and the other is zero at ninety degrees, and they are both non-zero in between.
So for each edge, we add another force in the perpendicular direction. Like drag, lift is also proportional to the density of fluid and the square of the velocity:
// Apply lift
const liftDot = b2Dot(edge, velDir);
const liftMag = (dragDot * liftDot) * edgeLength * density * vel * vel;
const liftDir = new b2Vec2(-velDir.y, velDir.x);
const liftForce = new b2Vec2(liftMag * liftDir.x, liftMag * liftDir.y);
b2Body_ApplyForce(shapeB.bodyId, liftForce, midPoint, true);
Now we should get a better effect when the leading edge is at an angle relative to the direction of movement.
Potential problems
For most simple shapes the approach shown here will work quite well, but when you have a body made from many shapes the 'leading' edges inside them might cause trouble. Making a body from multiple shapes is necessary when the desired overall shape is concave, but it can also be necessary for convex surfaces which have more than Box2D's default maximum of 8 vertices. Let's suppose the boat from the example above was made from two individual shapes.
Can you see the problem here? With our current calculations the shape at the rear of the boat has a leading edge which will be used to generate drag and lift. The lift will push the boat downwards as it moves right, and the overall drag will be about twice as strong as it should be. To fix this problem you would need to use a polygon that describes the overall boat hull rather than using the individual Box2D fixtures within it. A looped chain edge might be perfect for this.
Credits
This tutorial is adapted from an original piece of work created by Chris Campbell and is used under license.