Metaballs
Metaballs, not to be confused with meatballs, are organic looking squishy gooey blobs. From a mathematical perspective they are an iso-surface. They are rendered using equations such as f(x,y,z) = r / ((x - x0)2 + (y - y0)2 + (z - z0)2)
. Jamie Wong has a fantastic tutorial on rendering metaballs with canvas.
We can replicate the metaball effect using CSS & SVG by applying both blur and contrast filters to an element. For example in Chris Gannon’s Bubble Slider below.
SVG Metaball
I discovered another approach to creating this metaball effect from Paper.js examples. Back in the days of Scriptographer Hiroyuki Sato created a script for generating gooey blobs in Adobe Illustrator. Unlike the previous techniques this does not render pixels or rely on filters. Instead it connects two circles with a membrane. Which means that the we can generate the entire blob as a path. For the Amoeba CodePen I followed exactly this technique.
In this blog post I am going break down the steps required to generate the metaball. We are going to go through a function called metaball
which generates the black shaded path that you see below. This consists of the connector plus a part of the second circle.
Building the Metaball
To figure out where the connector touches the two circles we start by locating two tangents that touch both circles. This is the widest the connector can be. BTW I’m focusing on the case when the circles are not overlapping first.
We can calculate the maximum angle of spread using:
const maxSpread = Math.acos((radius1 - radius2) / d);
Why? This took me a while to figure out. I could attempt to explain here, but you are probably better of seeing the step-by-step illustration in this external tangents to two given circles guide.
This is the maximum possible spread that the connector can have. We can control spread amount by multiplying it with a factor called v
. The Paper.js code has v = 0.5
. That seems to work well.
The spread for the smaller circle is (Math.PI - maxSpread) * v
. This is because the sum of the opposite angles of a polygon is 180°.
Next we need to find the location of those four points. We know the centre of the circles (center1
& center2
) and the radii (radius1
& radius2
). Therefore, we will only be dealing in terms of angles and then use polar coordinates to convert it into (x, y)
values later.
const angleBetweenCenters = angle(center2, center1);
const maxSpread = Math.acos((radius1 - radius2) / d);
// Circle 1 (left)
const angle1 = angleBetweenCenters + maxSpread * v;
const angle2 = angleBetweenCenters - maxSpread * v;
// Circle 2 (right)
const angle3 = angleBetweenCenters + (Math.PI - (Math.PI - maxSpread) * v);
const angle4 = angleBetweenCenters - (Math.PI - (Math.PI - maxSpread) * v);
The angles need to be measured clockwise. Therefore, for the second circle we take that into account by subtracting from Math.PI
. We add angleBetweenCenters
to all because the circles can be moving diagonally too. Then convert polar coords to cartesian.
// Points
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
To convert the trapezium shaped connector into a curved one we need to add handles to all four points. The next part of the process is to figure out the location of the handles.
The handle for a particular point should be aligned to the tangent to the circle at that point. Again we’ll use polar coords to locate the handle. This time however, it will be relative to the point itself.
The lines AB and BC are perpendicular because AB is radial and BC is a tangent to the circle. Therefore, the angle for the handle 1 is angle1 - Math.PI / 2
. Similarly we can calculate the angles for the other three handles.
The length of the handle is relative to the radius of the circle they originate from times the factor d2
. For example, the length of handle 1 is radius1 * d2
. We can now calculate the location of the handles like so:
const totalRadius = radius1 + radius2;
// Handle length scaling factor
const d2 = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
// Handle lengths
const r1 = radius1 * d2;
const r2 = radius2 * d2;
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
We have all the points 🙌🏽 Time to construct the SVG path. The path is made of three sections: curve from point 1
to point 3
, arc of radius2
from point 3
to point 4
and curve from point 4
to point 2
.
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}
Circle Overlap
We have a gooey metaball! But you’ll notice that path gets all weird and twisty when the circles start to overlapping. We are going to fix this by expanding the spread in proportion to how much the circles are overlapping.
The spread expansion will be controlled using the angles u1
and u2
. We can calculate these using the law of cosines.
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)
);
But what shall we do with these 🤔 To be honest I’m not entirely sure how this works. What I do know is that it expands the spread as the circles get closer and then collapses it once circle 2 is completely inside circle 1.
const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v;
const angle2 = angleBetweenCenters - (u1 + (maxSpread - u1) * v);
const angle3 =
angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle4 =
angleBetweenCenters - (Math.PI - u2 - (Math.PI - u2 - maxSpread) * v);
And one final change to account for overlapping circles. The length of the handles will also be proportional to the distance between the circles.
// Define handle length by the distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, (d * 2) / (radius1 + radius2));
const r1 = radius1 * d2;
const r2 = radius2 * d2;
Conclusion
And here is the final result and the entire code snippet for metaball
. Try forking it and playing around with different values of handleSize
and v
. See how they impact the shape of the connector. There are so many amazing little details in these 70 lines of code. Fascinating work by Hiroyuki Sato. I learnt so much from it!
/**
* Based on Metaball script by Hiroyuki Sato
* http://shspage.com/aijs/en/#metaball
*/
function metaball(
radius1,
radius2,
center1,
center2,
handleSize = 2.4,
v = 0.5
) {
const HALF_PI = Math.PI / 2;
const d = dist(center1, center2);
const maxDist = radius1 + radius2 * 2.5;
let u1, u2;
// No blob if a radius is 0
// or if distance between the circles is larger than max-dist
// or if circle2 is completely inside circle1
if (
radius1 === 0 ||
radius2 === 0 ||
d > maxDist ||
d <= Math.abs(radius1 - radius2)
) {
return '';
}
// Calculate u1 and u2 if the circles are overlapping
if (d < radius1 + radius2) {
u1 = Math.acos(
(radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)
);
u2 = Math.acos(
(radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)
);
} else {
// Else set u1 and u2 to zero
u1 = 0;
u2 = 0;
}
// Calculate the max spread
const angleBetweenCenters = angle(center2, center1);
const maxSpread = Math.acos((radius1 - radius2) / d);
// Angles for the points
const angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v;
const angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v;
const angle3 =
angleBetweenCenters + Math.PI - u2 - (Math.PI - u2 - maxSpread) * v;
const angle4 =
angleBetweenCenters - Math.PI + u2 + (Math.PI - u2 - maxSpread) * v;
// Point locations
const p1 = getVector(center1, angle1, radius1);
const p2 = getVector(center1, angle2, radius1);
const p3 = getVector(center2, angle3, radius2);
const p4 = getVector(center2, angle4, radius2);
// Define handle length by the distance between both ends of the curve
const totalRadius = radius1 + radius2;
const d2Base = Math.min(v * handleSize, dist(p1, p3) / totalRadius);
// Take into account when circles are overlapping
const d2 = d2Base * Math.min(1, (d * 2) / (radius1 + radius2));
// Length of the handles
const r1 = radius1 * d2;
const r2 = radius2 * d2;
// Handle locations
const h1 = getVector(p1, angle1 - HALF_PI, r1);
const h2 = getVector(p2, angle2 + HALF_PI, r1);
const h3 = getVector(p3, angle3 + HALF_PI, r2);
const h4 = getVector(p4, angle4 - HALF_PI, r2);
// Generate the connector path
return metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, d > radius1, radius2);
}
// prettier-ignore
function metaballToPath(p1, p2, p3, p4, h1, h2, h3, h4, escaped, r) {
return [
'M', p1,
'C', h1, h3, p3,
'A', r, r, 0, escaped ? 1 : 0, 0, p4,
'C', h4, h2, p2,
].join(' ');
}