https://varun.ca/metaballs/ Varun Vachhar finder of new ways to confuse myself - writing 16th October, 2017 Metaballs See the Pen amoeba by winkerVSbecks (@winkerVSbecks) on CodePen. 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 - x[0])^2 + (y - y[0])^2 + (z - z[0])^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. See the Pen SVG Bubble Slider by chrisgannon (@chrisgannon) on CodePen. 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. See the Pen Meta balls Debug by winkerVSbecks (@winkerVSbecks) on CodePen. 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. See the Pen Metaballs debug -- the path by winkerVSbecks ( @winkerVSbecks) on CodePen. 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. max-spread 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. See the Pen Metaballs debug -- spread by winkerVSbecks (@winkerVSbecks ) on CodePen. The spread for the smaller circle is (Math.PI - maxSpread) * v. This is because the sum of the opposite angles of a polygon is 180deg. 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. See the Pen Metaballs debug -- handles by winkerVSbecks ( @winkerVSbecks) on CodePen. 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. ABCangle 1 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. See the Pen Metaballs debug -- no overlap by winkerVSbecks ( @winkerVSbecks) on CodePen. The spread expansion will be controlled using the angles u1 and u2. We can calculate these using the law of cosines. radius1dradius2u1u2 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); See the Pen Metaballs debug -- overlap by winkerVSbecks ( @winkerVSbecks) on CodePen. 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! See the Pen Metaballs debug -- final by winkerVSbecks (@winkerVSbecks) on CodePen. /** * 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(' '); } Questions, Comments or Suggestions? Open an Issue Creative coding from a front-end developer's perspective My goal with this blog is to go beyond the basics. Breakdown my sketches. And make animation math approachable. The newsletter is more of that. Hear about what's got my attention--new tools and techniques I've picked up. Experiments I'm working on. And previews of upcoming posts. Enter your email address [ ][Subscribe] Related Posts * Animating Clipped Shapes 31st October, 2017 * Polar Coordinates 23rd November, 2017 * Learning CSS Grid - Part 2 4th September, 2017 * About * Writing * Work * RSS * Twitter * Github * CodePen * Dribbble * Newsletter