Interactive procedural simulation of ferrofluid in WebGL.
I have been fascinated by ferrofluid since I first saw it in a physics program. Its bizarre shape and strange movements have a unique aesthetic quality for me. So in one of my recent experiments I tried to recreate an interactive ferrofluid simulation using web technologies (mainly JS and WebGL). In this article, I describe some of the key aspects and challenges of this project, but without going into too much technical detail.
My goal was to capture the aesthetics of the ferrofluid and not to implement a realistic physical simulation of the underlying electromagnetic principles. Nevertheless, the appearance and behaviour should be convincing in some way. Trying it with a fluid simulation was kind of obvious. In my research on this topic, I learned that there are fluid simulations that work on a grid (like this very impressive example) and fluid simulations that work with particles. After that, I focused on particle fluid simulations, mainly on smoothed-particle hydrodynamics.
The basic idea was to create a two-dimensional particle-fluid simulation and use it to procedurally generate the ferrofluid geometry. Here, the position of each particle represents a peak in the ferrofluid. The peaks are thus free to move, but also form an almost lattice-like structure due to the density-preserving properties of the fluid simulation.
There are many online articles, papers, and source code repositories that help one implement an SPH simulation (i.e. 1, 2 or 3). For performance reasons, it was especially important to me that the entire simulation runs on the GPU. To further improve performance, the particles should be divided into cells, so that only the particles of the neighbouring cells have to be included in the calculation of a particle. However, to implement this on the GPU, one must first sort the particles by cell and then create an offset lookup list (as just one of the possible solutions to this problem). A live demo and the source code to my SPH implementation is also available on github.
In addition to mouse interaction or touch input, I wanted the ferrofluid simulation to respond to audio input. I liked the idea of the fluid having a kind of primitive consciousness. It should simply respond differently to certain pitches of audio input — the higher the pitch, the more excited it gets. This way, the user can influence the fluid’s appearance by whistling or singing.
Retrieving a frequency spectrum of the microphone signal in the browser works quite easily. However, it is important to note that this spectrum is based on a linear frequency division. To make the audio input appear as natural as possible, you should work with logarithmic intervals. This is because the human perception of musical intervals is approximately logarithmic.
The positions of the particles of the fluid simulation formed the basis for the creation of the surface. Since the simulation was to take place on a flat surface, I started with a subdivided flat geometry that I can then distort in the vertex shader.
My first approach was to create a voronoi height-map. However, a normal voronoi pattern was not suitable, because on the one hand it did not provide the desired result optically and on the other hand it did not allow an acceptable normals calculation due to its discontinuity. Fortunately, there is a smoothed voronoi algorithm from Inigo Quilez which I then used to generate the height-map.
Next, I used a simple smoothstep function to flatten the ferrofluid at the edge. I noticed that with ferrofluid, the peaks are usually not parallel. So I changed the displacement of each vertex depending on its position from the center of the surface — the farther away a vertex was, the more inclined the displacement.
// p = the vertex position
// h = the height-map value
// r = the distorted position
h *= smoothstep(0.5, 0.8, 1. - length(p));
vec3 sp = normalize(p - vec3(0., -0.4, 0.)) * h;
vec3 r = p + sp;
I calculated the normals using a simple gradient from the height-map to get a tangent and bitangent vector and from that the normal vector.
vec3 t = distort(position + vec3(epsilon, 0., 0.), ...);
vec3 b = distort(position + vec3(0., 0., epsilon), ...);
v_normal = normalize(cross(t - p, p - b));
The rendering of the ferrofluid surface consists of a specular highlight, an ambient map reflection, a fresnel component and a faux-iridescence component. For the iridescent component, I simply used the fresnel value, scaled it and sampled a color palette with it.
float fresnel = dot(N, V);
vec3 iridescence = palette(fresnel * 3., a, b, c, d) * (1. - fresnel);
I hope I was able to give a bit of insight into how this experiment came to be. You can find more experiments at robert.leitl.dev.