The Squishy Lab

Animations of The Squishy Lab: Constellation and Anodize

Posted on under Projects

Any of you who have spent any amount of time on this website will probably have noticed the animations featured throughout. To me, these animations are a significant part of what gives the site its look and feel, and as such I've spent a significant amount of time fine-tuning them. While I don't think that they're extremely significant in terms of programming or creativity, I figured I'd take some time to wax about the process of making them. So, today I'll be writing a bit about the Constellations and Anodize animations.


Constellations

The premise behind Constellations definitely isn't a new one, but implementing and optimizing it was a fun exercise for me. The code I'm presenting here is different from the production code on the site because of the optimizations, but most of the general ideas are the same.

The idea is to imagine that the viewer is floating in space, and that presented before them is a mass of stars that slowly move around independently of one another. They rotate about a central axis in response to the user interacting with the site, and form and break bonds as they draw closer or further from one another. The end result is something akin to constellations in the night sky, albeit with the connections being explicit rather than being left to the imagination.

Naturally, the best way for me to conceptualize this was as a particle system, with each particle having its own velocity and position in 3 dimensions. To keep the particles from escaping the view of the user, they would need to be kept in a container. Since I envisioned the particles spinning around a single vertical axis, I decided that the best shape for this container would be a cylinder.

And with a general idea of what I wanted to make mapped out, I set about creating a framework for myself to get started, starting with some basic utility classes for matrices and vectors, and a simple particle class. That being said, this part of the process wasn't without its challenges.

One of the troubles I had was with getting a random point within a unit sphere, something that I first tried to do like so:

static randomSpherical() {
  const r = Math.random();
  const theta = Math.random() * 2 * Math.PI;
  const phi = Math.random() * Math.PI;
  return new this(
    r * Math.sin(phi) * Math.cos(theta),
    r * Math.sin(phi) * Math.sin(theta),
    r * Math.cos(phi)
  );
}

This did give me a set of random points within a unit sphere, albeit not a uniformly distributed set of them, as you can see:

Unevenly distributed points within a unit sphere. Unevenly distributed points within a unit sphere.
My first attempt at uniformly distributed points in a sphere. The first image is seen from the z-axis, highlighting the bunching of points around the center of the sphere. The second image is seen from the y-axis, showing that this bunching also centers around the z-axis.

This was solved by changing the value of r from rand to rand3, and the value of φ from rand×π to arccos(2×rand1). This was something that I ended up having to look up, but the results speak for themselves:

static randomSpherical() {
  const r = Math.cbrt(Math.random());
  const theta = Math.random() * 2 * Math.PI;
  const phi = Math.acos(2 * Math.random() - 1);
  return new this(
    r * Math.sin(phi) * Math.cos(theta),
    r * Math.sin(phi) * Math.sin(theta),
    r * Math.cos(phi)
  );
}

Evenly distributed points within a unit sphere.
A much more even distribution of points, no matter which axis you view from!

After all the utility classes were written, I started writing the code that would be specific to the constellations. I added a class specifically for particles within the constellation, which implemented collision detection with the walls of the cylinder along with velocity and position updates.

At this point I wrote the rendering functions. These were simple, they just involved using some matrices to translate from the cylinder space (bounded on [-1, 1 on all three axes) to the screen using orthographic projection, and repurposing the z-component to affect the particles' size. And from here, things were already looking pretty good!

A collection of stars on a black background.
Look at the sky, see the stars!

But of course, I wanted to get those connecting lines, so I started writing that in. It took some trial and error, but I think I found a good balance of enough lines to define some cool shapes, but not so many that it was overwhelming:

A collection of stars on a black background with a couple short lines connecting a few of them
Too few lines makes for not enough shapes.
A collection of stars on a black background with some short lines connecting a some of them, forming some shapes.
Adding some more lines adds some more variety.
A collection of stars on a black background with lines connecting a some of them, forming many shapes.
This is the sweet spot I decided on, which shows a lot of connections without filling the screen too much.
A collection of stars on a black background with some many lines connecting them, forming a few shapes.
Too many lines starts to get overwhelming, as all the dots form one shape.
A collection of stars on a black background with lines connecting every star to every other star.
This one looks like cobwebs.

The last thing I did was to add mouse interaction, which you should be able to see in the background of this page! It's pretty simple, there's just a rotation value that's based on the mouse's position, which is then used to either spin the stars about the z-axis, or tilt the cylinder up or down slightly. And there it is! One constellation animation!


Until you remember that calculating and drawing all of those stars and lines takes a lot of work. Optimizing this to run smoothly in the background on lower-end devices was an adventure in itself…

Adventures in Optimization

Drawing the stars is easy enough, that's an O(n) operation. The problem comes with drawing those lines, because (a) distance calculations have a square root in them, which is kind of expensive, and (b) every particle has to be compared with every other particle, which makes it an O(n2) operation. Yikes.

So, the first thing I did was to attack the distance calculations. In deciding how many lines needed to be drawn, I also set a minimum distance for which a line should be drawn at all. So, I tried partitioning the space into cubes with this side-length, and only ran distance calculations for each particle with particles in the same and adjacent cells.

To be fair, this was a very effective optimization, reducing the number of distance calculations by a factor of 30, and lowered the time complexity. But it wasn't good enough for me, and so I looked for further optimizations…


Just to see what it would do, I asked ChatGPT to write a version of my code for me from scratch. (Asking it for optimization tips on my own code did effectively nothing.) To my surprise, ChatGPT's code was running several times faster than my own. This was an interesting result, as looking at both codebases showed that both of them were doing essentially the same thing. The only significant difference that I could see was that ChatGPT's code was using simple function calls and hardcoded vector operations, while my own code wrapped all of the operations into classes and objects to make things more readable.

I hadn't thought that writing object-oriented code as opposed to procedural code would make a significant performance difference; I thought that the Javascript complier would make those optimizations for me. But with no other optimizations in sight, I figured I might as well try rewriting the whole codebase to be procedural, and see what boosts it'd bring.

And after the rewrite, my constellations were suddenly even faster than ChatGPT's version of the code. Turns out all those object wrappers and whatnot do add up.


Turns out all those object wrappers and whatnot do add up.

If there's one thing that I've taken away from this experience, it's this. Object-oriented code may have been easier for me to write, but when it comes to performance, sometimes readability needs to take a hit. A lesson which I'll be taking into account as I write more animations like this in the future.

And that's how I wrote Constellations. I managed to squeeze a bit more performance out of the browser by using web workers, but that's the gist of it. If you want to see the object-oriented version's source code, it's right here.


Anodize

Unlike Constellation, Anodize isn't featured front and center. Rather, it's the animation that plays on the site's error pages, which as of the time of writing means the 404 and 500 pages. It starts off as a shifting black-and-white noise field, and as time goes on it becomes more and more colorful, shifting through different hues. Simple enough.

Your browser does not support JS Canvas. T_T
Live demo of the Anodize animation! Click the image to play/pause.

I called the animation Anodize because the colors and pattern reminded me of the various colors of anodized titanium. So, how was it done?


The heart of the animation is an overlay of three noise fields, one for each of the red, green, and blue color channels. To generate this noise field, I used 3D simplex noise, which I ported to Javascript from an existing implementation. (Perlin noise would have worked just as well for the effect, but simplex noise was faster.) I also used 3D simplex noise for the animation in the background of SquishyChat, which I may cover in a future post.

The x and y coordinates of the noise field are mapped to the x and y coordinates of the canvas, and the z coordinate is mapped to time. Then, the value of the noise field at each point is used to determine the value of each color channel at each point. With all three fields being the same, this produces a grayscale noise field like you see at the beginning of the effect.

However, different effects can be achieved by using different fields for each color channel. For example, setting each color channel to a random field gives a pattern of random assorted colors:

A random assortment of colors dispersed as gradients across an image
Using three fields of the same grid size and completely different values gives a very colorful result.

For the effect in Anodize, the red color channel moves through time slightly slower than the other channels, and the blue color channel moves through time slightly faster. The result is that as time goes on, some spots on the grid are slightly more reddish or warm, and some spots are slightly more bluish or cool, giving a cool effect similar to chromatic aberration.

Another way to think of this is that in this arrangement, the color channels are ordered in terms of rate of change as red, green, and blue, from slowest to fastest. Naturally, we can get 5 more permutations and see how those turn out:

A random assortment of colors dispersed as gradients across an image, with emphasis on blue and orange tones
RGB ordering, which is what I decided on. I like how this one reminded me of the “Fireblue” finish on some of Kaweco's pens.
A random assortment of colors dispersed as gradients across an image, with emphasis on blue and orange tones
BGR ordering, which places more emphasis on warmer tones and warmer blues. This is probably my second favorite of the bunch.
A random assortment of colors dispersed as gradients across an image, with emphasis on purple and green tones
BRG ordering, which features a lot of lime green and some lavender tones.
A random assortment of colors dispersed as gradients across an image, with emphasis on purple and green tones
GRB ordering, which is much like an inverted version of BRG ordering.
A random assortment of colors dispersed as gradients across an image, with emphasis on magenta and cyan tones
GBR ordering, which puts emphasis on some magentas and teals.
A random assortment of colors dispersed as gradients across an image, with emphasis on magenta and cyan tones
RBG ordering, which is much like an inverted version of GBR ordering.

Of course, as time goes on, the fields diverge more and more, leading to a result much like that of the first image, which becomes very random and variegated. This leads to the ordering not mattering as much as time goes on, but that in-between phase where the colors start to diverge and develop, the ordering does make a big difference.

That's about it. The one trick is offsetting the noise fields in time, but there's a decent amount you can do by just varying the rate at which each channel moves. The only other thing that I did was to place a mask over the noise to get the text on the error page. It's a pretty simple effect, but a nice-looking one at that.


And that's that! I've made many animations for The Squishy Lab over the years, but as of the time of writing, these two and the one in the background of SquishyChat are the only ones featured on the site. This concludes my log on developing these animations. If I have time in the future, I might make and explain more animations and similar projects. Stay tuned until then!

Author's note: I've always loved reading code explanations, tutorials, and development logs like this, but I've never written one of my own until now. It's definitely harder than I thought—I feel like this one was kind of aimless. But I want to write more of these in the future, both to show off my projects and to help myself reflect on them. I'm still not 100% sure whether this is better off as a Musing or a Project, but for now I've put it in the Projects category. Let me know what you think!

2 Comments

Log in to comment on posts.


rolo
apple ahhh gradients

Penton1753
gaussian blur lookin ahh