Making Useless Blobs
Recently for a personal project I found a need to have some random blobs on a webpage. My first thought was just to whip some up in Adobe Illustrator but I thought it would nice to be able to write some code that could just create some organic shapes for me. I was able to find an npm package called blobs
but I thought it'd be interesting to try and solve the problem myself. Essentially the goal was to write an algorithm that would accept some parameters and spit out an SVG compliant shape. To give you and idea of what we're trying to create here's an example:
Creating a polygon
In order to eventually create a blob my first thought was to start by making some random polygon with an arbitrary number of vertices as one our parameters. My reasoning was that if I could create some set of vertices that represented a polygon then later I would be able to connect the points together with some smoothing between them to create our final organic shape.
Determining angles
The simplest approach that I found in order to create some arbitrary polygon was to first start by generating a set of angles at which each of the vertices would fall relative to the center of our shape.
For instance, say we want our polygon to have 5 vertices we can take 2 PI and divide that by 5. Now we can start from zero and increment that value as we go along to create a set of angles. With this set of angles we now know where we want the vertices or our polygon to fall relative to the center.
const UNIT_CIRCLE = 2 * Math.PI;
function createAnglesList(vertices) {
const standardAngle = UNIT_CIRCLE / steps;
let cumulativeSum = 0;
const angles = [...new Array(steps)].map((_,i) => {
return i * standardAngle;
});
return angles;
}
The issue with the above approach though is that it creates a set of equidistant angles every time. What we need to do is introduce a modifier that ensures the angles returned are different even if the same values for vertices
is passed in. To do this we can extend the function slightly to allow a fractional value that adds some irregularity to the angles returned.
const UNIT_CIRCLE = 2 * Math.PI;
function createRandomAnglesList(steps, irregularity) {
const standardAngle = UNIT_CIRCLE / steps;
let cumulativeSum = 0;
const angles = [...new Array(steps)].map(() => {
// accepts `a` and `b` and returns value between `a-b` and `a+b`
const angle = giveOrTake(standardAngle, irregularity);
cumulativeSum += angle;
return angle;
});
// normalize to force angles to sum to 2PI
cumulativeSum /= UNIT_CIRCLE;
return angles.map((angle) => angle / cumulativeSum);
};
Passing a 0 to the above function for the irregularity
value will still allow it to return equidistant angles but otherwise it will return a set of random angles for the same inputs.
Distance from center
Now that we have a set of angles our next step is to translate these angles into a set of vertices that can represent our polygon.
Let's think of each angle we have in our set as casting a ray from the center. In order to create a random shape we need only choose a point on this ray that fits within our bounding box. Our bounding box in this case is the width and height of the resulting SVG we are going to create.
In order to do this we'll first calculate the point [x,y]
that falls on our bounding box for a given angle since this is the maximum point from the center our vertex can fall on. One thing to note is that we can calculate this point to always be in the first quadrant since our bounding shape is uniform in all quadrants and all we care about is it's distance from the center.
Once we have this point we can then calculate the distance maxRadius
from the center of our shape to it using the distance formula and multiply that by a random number [0,1]
creating a new distance from the center. With this length we can then convert that into a point [x,y]
using a bit of trigonometry. Putting this together with our previously defined createRandomAnglesList
our algorithm would look something like:
function createShape(vertices, width, height, irregularity) {
const angleSet = createRandomAnglesList(vertices, irregularity);
const halfWidth = width / 2;
const halfHeight = height / 2;
return anglesSet.map((currentAngle) => {
const sin = Math.sin(currentAngle);
const cos = Math.cos(currentAngle);
// for given angle calculate point on bounding box
const [x, y] = calculateIntersectionPoint(
currentAngle,
halfHeight,
halfWidth
);
// distance formula
const maxRadius = Math.sqrt(x ** 2 + y ** 2);
const radius = Math.random() * maxRadius;
// convert to x, y position
const point = [(wRadius + radius * cos), (hRadius + radius * sin)];
return point;
});
}
In the above example we also have to think in terms of two different coordinate systems. This is because in standard Cartesian space our starting point [0,0]
would normally exist at the center of our image.
However, since we're going to have to plot in SVG space our [0,0]
point isn't at the center but rather the upper left-hand side. This doesn't matter when calculating angles or even the intersection point of the bounding box given these can be done in Cartesian space first and then translated. The translation occurs here:
const point = [(wRadius + radius * cos), (hRadius + radius * sin)];
As this line allows us to shift our x
and y
values from the top left to the center and then apply our new radius from the center using trigonometry.
It can also be noted the function calculateIntersectionPoint
exist to calculate the point [x,y]
where our ray from the center intersects our bounding box in the first quadrant. If we assume our bounding box is a rectangle the implementation for calculateIntersectionPoint
could be as follows:
function calculateIntersectionPoint(angle, heightRadius, widthRadius) {
// ignore slope since bounding shape is uniform
const tan = Math.abs(Math.tan(angle));
let x = widthRadius;
let y = tan * x;
// whether to bound to x or y axis
if (y > heightRadius) {
y = heightRadius;
x = y / tan;
}
return [x, y];
};
However, we can also apply our bounding box as an ellipsis instead with an implementation like so:
function calculateIntersectionPoint(angle, heightRadius, widthRadius, type) {
const tan = Math.abs(Math.tan(angle));
const ab = widthRadius * heightRadius;
const bottom = Math.sqrt(heightRadius ** 2 + widthRadius ** 2 * tan ** 2);
const x = ab / bottom;
const y = (ab * tan) / bottom;
return [x, y];
};
In both implementations of calculateIntersectionPoint
we don't really care about the sign of our tan
value and as such can take the absolute value to force our point to be in the first quadrant. This is because, as stated before, we use this point to calculate the max distance from the center so direction doesn't matter.
With all this put together we now have an algorithm for creating a random polygon. Our inputs allow an arbitrary number of vertices as well as a value for irregularity to create some entropy. With that here's an example of what the output might look like for both types of bounding boxes with a width/height of 200px
and an input of 12 for the vertices
:
Smoothing lines
With that we've now created a list of vertices that can represent our shape. But if we were to just connect each of the points with a straight line we would end up with a very jagged blob. In order to eliminate these harsh lines we have to introduce another step.
What we'll do is introduce something called Bezier interpolation.
For the purpose of brevity I won't go too in depth on Bezier interpolation as there are a multitude of other articles explaining the concept better than I can. To get a better understanding I'd recommend these articles:
But to summarize the process, what we'll do is create a function that takes in 4 points a, b, c, d
. From these 4 points we will return 2 points controlB, controlC
that will represent the 2 control points for b
and c
. All together b
, c
, controlB
, and controlC
will represent the 4 parts that make up a single cubic bezier curve.
For the points a, b, c, d
the points b
and c
represent the 2 points that form the current line we want to draw for our blob and a
and d
represent the adjacent points before and after our current line respectively.
Let's say we have a list of vertices [p1,p2,p3,p4]
and a function that returns the 2 control points mentioned before called calculateControl
. If we iterate over our list of vertices then the calls to calculateControl
would look like:
- Current line
p1
top2
:calculateControl(p4, p1, p2, p3)
- Current line
p2
top3
:calculateControl(p1, p2, p3, p4)
- Current line
p3
top4
:calculateControl(p2 ,p3, p4, p0)
- Current line
p4
top1
:calculateControl(p3, p4, p0, p1)
The calculateControl
function is rather lengthy so I won't post it all here. The implementation I used is essentially a translation of the one found here into Javascript though.
In order to create all the Bezier curves for the points in our polygon the code needed would look like this:
function allControlPoints(polygonPoints) {
const loopedPoints = [
polygonPoints[polygonPoints.length - 1],
...polygonPoints,
polygonPoints[0],
polygonPoints[1]
];
return loopedPoints.slice(1, -2).map((point, index) => {
const before = loopedPoints[index];
const a = point;
const b = loopedPoints[index + 2];
const after = loopedPoints[index + 3];
const controlPoints = calculateControl(before, a, b, after);
return controlPoints;
});
}
Here the input of polygonPoints
represents the returned list of points from our createShape
function. The return value for this function is a 2-D list of control points with the same length of our input list and each value being a list of our 2 control points.
From here we can finally generate a path string that can be understood as the d
value for a path
tag as outlined in the SVG docs. All together the code to generate our nice organic blob is:
function generatePathString(vertices, irregularity) {
// create our polygon vertices
const points = createShape(vertices, irregularity);
// initial cursor move
const starting = `M ${points[0][0]} ${points[0][1]}`;
// 2D list of all cubic bezier control points
const controlPoints = allControlPoints(points);
// reduce all this into a single d string
return [...points, points[0]].slice(1).reduce((acc, curr, index) => {
const [x, y] = curr;
const [[a1, a2], [b1, b2]] = controls[index];
return `${acc} C ${a1} ${a2}, ${b1} ${b2}, ${x} ${y}`;
}, starting);
};
Here the initial starting
variable represents the move M
call that must be made to move our cursor to the starting point before we can begin to draw in our SVG. Now let's take a look at the output of our algorithm for various different parameter inputs:
Conclusion
If you want to play around a bit more I made an npm package containing all this code as well as a demo you can play around with to see how the parameters affect the final result.