Table of Contents
One of my favorite animations to both watch and recreate is the cluster growth animation, and it looks as follows:
You can view this on its own standalone page by going here. You can also jump a billion steps and go straight toviewing the source on Github.
Pay close attention to how this animation works. A few square pixels (which we’ll refer to as cells) start off from the middle, and then these cells multiply outward at each frame. This multiplication and expansion continues until all available space is taken up by the cells. Once this happens, the whole process repeats itself with cells of a different color switching things up.
An interesting detail is in how the cells multiply. There is a degree of randomness where all outer cells don’t multiply evenly. Some cells multiply first, and other cells follow later...much later in some cases. The end result of this randomness is that we can see that the cell growth is a bit uneven at various moments in time:
This stylistic choice isn’t by accident. This stylistic choice is heavily inspired by what happens in biological systems in nature. Allow me to elaborate...
When we look at how cells grow in nature, they tend to follow a pattern similar to our animation where they kind of grow or multiply organically from a starting point. Images of Petri dishes with bacteria or equivalent growth compounds are the perfect representation of this:
What we are doing with our cluster growth animation mimics a lot of that behavior. Let’s take a few moments to slow down and describe this behavior in more detail.
First, there is a starting or seed point where we have a few cells clustered together:
Over a period of time, these cells (more specifically, the outer cells) will multiply and create an ever-growing cluster of cells. Each unit of time where the cells will multiply is commonly known as a generation. The starting point we are highlighting above is the first generation. It is the starting point.
In the next generation, our cells will begin to multiply. In real life, the rate at which cells multiply varies based on the type of cell and the environment the cells are in. For our digital simulation, we are going to artificially define the parameters for cell growth. We are going to assign a growth probability to each cell that will specify whether the cell will multiply or stay the same. For our simulation, let’s say our growth probability is 50%. This means the outer cells have a 50% chance of growing at each generation.
When we visualize this growth in the next generation, we will see something that looks as follows:
Notice that our cluster of cells grew a bit in this generation (with the new cells differentiated in a darker blue color) compared to the starting generation we had earlier. As another generation of time passes and the outer cells are given the opportunity to grow with the same 50% probability, our cluster of cells will continue to grow, with the new cells now marked in red:
The key to how our cells grow has to do with our growth probability at each generation. If our growth probability is too low, our cluster of cells will grow very slowly. If our growth probability is high, our cluster of cells will grow rapidly. As long as the growth probability is not 0, after enough generations, our cells will have fully grown and multiplied to take up all available space:
When there is no more space available, this is considered the end state for our cluster growth simulation. When this end state is reached, we start over by placing a new cluster of cells near the middle and repeating the growth process from the beginning.
Now that we’ve seen at a very high level how the cluster growth animation works, let’s go much deeper into the specifics and pair that up with implementation details.
While it may not look like it, there is a whole lot more going on with our cluster growth animation than what we just described at a high-level. This is where the giant leap from understanding to implementing comes in. When we turn all our initial ideas into code, there are more considerations we need to fully wrap our heads around.
Instead of having you create this animation from scratch, we are going to focus on the core parts of the functionality as part of looking deeper into the various things going on. To see the full code in its entirety, go to the source hosted on Github. If you take all of those contents, plop them in a blank HTML page, and run the page in your browser, you will see the cluster growth animation running in all its awesomeness:
Take a few moments to step through the code on your own and try to understand what is happening. In the following sections, we’ll walk through how our cluster growth animation works.
Each cell we see isn’t arbitrarily positioned. Nope. Instead, they are positioned precisely using a hidden 2D grid:
This grid allows us to easily specify a horizontal and vertical position for each cell that isn’t tied to specific pixel values, resolution/DPI quirks, and so on. All we need to worry about is the x/y position of the grid location our cell will live, and we can deal with actually drawing that cell on our screen separately. Creating this grid is one of the first things our implementation does.
One of the easiest ways to create a grid is by using a two-dimensional array. That is just a fancy way of saying it is a collection of arrays nested inside arrays, and our createEmptyGrid function shows this in action.
function createEmptyGrid() {
let emptyGrid = [];
for (let i = 0; i < width + 1; i++) {
let row = [];
for (let j = 0; j < height + 1; j++) {
row.push(0); // Initialize each cell to 0
}
emptyGrid.push(row);
}
return emptyGrid;
}
The end result is that we’ll have created an array representation of the grid we visualized earlier. The width and height variables specify the dimensions of our grid, each cell in our grid will have an initial value of 0 to represent an empty state, and we can access any grid item by specifying an x/y position as follows: emptyGrid[x][y].
This now sets us up for being able to actually define our cell clusters and track their positions as part of growing them!
For our cells to grow, they need a starting or seed point. This point is going to be at the center of our grid:
The center of our grid is calculated by dividing our width and height variables by 2. Because our grid positions are cells and not actual pixel positions, the center point will be offset from the center, as shown in the above example.
After we have our center point, we then define the number of cells that will be a part of this cluster. This will be a square shape centered at our seed point. We specify the size of our square by providing the half-width value (more specifically the apothem) that acts like a circle’s radius.
If we specify a size value of 2, this is what our initial cluster would look like:
Notice that our cluster of squares emanates two cells outward in all directions from our center point. If we translate all of this into code, our setInitialCluster function captures the details:
function setInitialCluster(x, y, size) {
for (let i = x - size; i < x + size; i++) {
for (let j = y - size; j < y + size; j++) {
if (i >= 0 && i < width && j >= 0 && j < height) {
tempGrid[i][j] = 1;
activeCells++;
}
}
}
}
Notice that we have two for loops where they start at the center point (defined by the passed in arguments of x and y) and define the filled-in cells surrounding the center point. We saw earlier that an empty cell will have a value of 0 in our grid. A filled-in cell will have a value of 1. By using 0’s and 1’s in our grid, we have an intermediate way of representing empty cells and filled-in cells without actually having to draw anything to the screen just yet.
Now, we are getting to the exciting part! We have our grid defined. We have our initial cluster of cells. What we need to do is grow our cells outward from the initial cluster we have right now:
The way we grow our cells isn’t linear and mechanical. There is a bit of randomness that gives it a more chaotic and natural feel. There are a few plot lines at play here. Just like a Game of Thrones season summary, let’s just look at everybody and everything involved at once.
First, our newly grown cells will always be adjacent to an existing grown cell. Using our example from earlier, our new cells will form in the empty region currently marked in yellow:
Our new cells won’t all form evenly, though. There is a bit of randomness involved. In order for a cell to have a chance of growing, the following two things need to happen:
If we take any empty region in yellow, we know it has a neighbor. In fact, it is likely to have anywhere from 1 to 3 neighbors, depending on which empty yellow cell we pick. The only missing question is whether the randomness criteria is met. In our implementation, we define the growth probability as follows:
growthProbability = customRandom(0.05, 0.2);
The lower bound for our probability is .05, and the upper bound for our probability is .2. The result will be some value in between these two bounds, which would be 5% and 20% if translated to percentages. If we take our requirement for needing at least one neighbor and meeting a randomness threshold, we have an if statement that looks as follows:
if (neighbors > 0) {
if (growthProbability > Math.random()) {
// grow new cell
}
}
If both of these conditions are true, then we grow a new cell at the position we are exploring this calculation in:
This brings up another point. When do we perform the above check to see if a cell has neighbors and the randomness threshold is met? We perform this check on every empty cell. Yes, for every empty cell in our grid, we ask these two questions to determine whether a new cell needs to be spawned there.
This brings up the elephant in the room. Is this the most efficient way of doing this? No...not really. We can likely optimize our check to only go after the immediate empty neighbors of active (live) cells instead of checking empty cells that have zero chance of ever having neighbors:
To keep the code simple, that isn’t an optimization we do in this particular implementation. (If you do make this optimization, please do share your version on the forums for others to benefit from!)
The code for describing the above logic fully is captured in these two functions:
function updateGrid() {
let updatedGrid = createEmptyGrid();
for (let i = 1; i < tempGrid.length - 1; i++) {
for (let j = 1; j < tempGrid[0].length - 1; j++) {
if (tempGrid[i][j] === 0) {
let neighbors = countNeighbors(tempGrid, i, j);
if (neighbors > 0) {
if (growthProbability > Math.random()) {
updatedGrid[i][j] = 1;
activeCells++;
}
}
} else {
updatedGrid[i][j] = tempGrid[i][j];
}
}
}
tempGrid = updatedGrid;
}
function countNeighbors(grid, x, y) {
let sum = 0;
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
sum += tempGrid[x + i][y + j];
}
}
return sum;
}
When looking at this code AND given the inefficient approach of how this code works, you want to keep your grid fairly small. The two nested for loops are responsible for looping through every grid cell involving a nested for loop, finding an empty region, checking for neighbors in that region, and then doing a randomness dice roll to determine whether that empty region will have an active cell in it. Phew. That’s a lot of work our computers have to do, so keep your grid size small for the best performance - especially on slower devices.
Earlier, we explained that we use a two-dimensional array (mimicking our grid) to store the positions of all empty cells and active cells. Empty cells are represented with a 0. Active cells are represented as a 1. The way we draw our cells is by mapping from these 0’s and 1’s to actual pixels using our canvas drawing APIs.
The following is a visualization of how we go from array data made up of 0’s and 1’s to pixel data:
The code for drawing our cluster of cells look is captured by the draw function:
function draw() {
ctx.fillStyle = color;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
if (tempGrid[x][y] === 1) {
let xPos = x * totalOffset;
let yPos = y * totalOffset;
ctx.fillRect(xPos, yPos, cellSize, cellSize);
}
}
}
}
This is the moment we put pixels on screen. We go through each cell in our entire grid and, each time we encounter a 1 instead of a 0, we draw a square rectangle at the appropriate position.
Before we wrap things up, now that we have a rough idea of how this effect works, let’s take a few moments to talk about how we can customize the appearance of our cells. Because of how we are drawing our cells using the canvas, there are literally an infinite number of visual tweaks we can make.
To change the size of each cell and the gap between them, change the following two values:
let cellSize = 3;
let gap = 3;
The larger the cellSize or gap value, the fewer cells will need to processed and get painted onto the screen. If we used a cellSize of 20 and a gap of 20, here is what our output will look like (click play on the following video):
As this video highlights, our code has a lot fewer cells to deal with, and this has the benefit of greatly improving performance, which may be handy if you are drawing on a really large canvas. The flip side holds true as well. If we go to the extreme and set our cellSize to 1 and our gap to be 0, we have a mapping where one pixel equates to one cell, play this next video to see what this looks like:
This certainly has the effect of creating very detailed cell clusters, but it comes with a heavy computational cost. We can totally feel the weight of the work needed to draw each new cell as the animation gets closer to the end.
Right now, our cell colors alternate between a dark color and white. The dark colors currently used can be modified here:
const dark_colors = [
"#9D0000", // Dark Red
"#C70039", // Deep Pink
"#800080", // Purple
"#4C3378", // Dark Purple (bluish)
"#003080", // Navy Blue (more vivid)
"#007BFF", // Material Blue (darker, vivid)
"#388E3C", // Dark Green (vivid)
"#00695C", // Dark Teal
"#663399", // Dark Amethyst
"#8B0000", // Maroon (more vivid)
"#A020F0", // Deep Purple (more intense)
"#B32830", // Dark Red (brownish)
"#BF360C", // Dark Orange
"#C2C255", // Dark Lime (more muted)
"#FF9933", // Dark Orange (reddish)
];
let color = getRandomColor(dark_colors);
When a dark color is needed, a color is randomly pulled from the dark_colors array. You can change the colors here to reflect a different color scheme if you want.
If we don’t want the colors to alternate between a dark color and a white color each time the animation runs, we’ll need to change this code:
if (iterations % 2 !== 0) {
color = getRandomColor(dark_colors);
} else {
color = "white";
}
We can specify a different color where “white” is specified, or we can create a parallel light_colors array and have a random light color selected, or...and so on. We have many options.
In our draw function, we currently specify that our cell is drawn as a square:
function draw() {
ctx.fillStyle = color;
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
if (tempGrid[x][y] === 1) {
let xPos = x * totalOffset;
let yPos = y * totalOffset;
ctx.fillRect(xPos, yPos, cellSize, cellSize);
}
}
}
}
We have the xPos and yPos values that specifies our cell position, and we have the cellSize value that specifies our cell size. With these three variables, we can switch the shape we draw with anything else. The Canvas drawing basics tutorials can help you out if you want some pointers.
In the previous sections, we looked at the big important things going on both conceptually as well as with code. If you understand everything we covered so far, you know enough about how a cluster growth animation works where you can be effective in giving an elevator pitch to interested people and...possibly smart dolphins:
There are a handful of smaller details that would be important for you to understand. Some of those revolve around how we track active cells to reset the environment, how we draw the right amount of cells taking into account cell size/gap, and more. I tried to comment the code as best as I can, but don’t hesitate to ask on the forums if anything else comes up that you’d like clarification from me on.
Just a final word before we wrap up. If you have a question and/or want to be part of a friendly, collaborative community of over 220k other developers like yourself, post on the forums for a quick response!
:: Copyright KIRUPA 2024 //--