Business card fluid simulator

Realistic liquid animation in 1KB of WebGL

David A Roberts
Abstract

A self-contained version of my tiny fluid simulation, compressed to fit on the back of a business card.

Introduction

For some time I've wanted to create something similar to the business card raytracer: a piece of code that produces something interesting, but is short enough to fit onto a business card. The tiny fluid simulation I described in my previous post provided the perfect opportunity, as the code for the simulator is remarkably short relative to the complex behaviour it exhibits.

However, it was written for Shadertoy, which is a complex webapp in itself, so doesn't quite meet the challenge of making the code self-contained. So, I set about wrapping it in the smallest amount of WEBGL boilerplate I could get away with, so that it can run without relying on any remote websites. For this I took inspiration from some entries to the JS1K contest, in particular MiniShadertoy which provided a great starting point though lacked support for Shadertoy's multipass buffers, and this entry which contained a couple of other useful techniques.

I've provided the source code in the form of a data URL to make it easy to run --- all you need to do is type the code into the address bar of your browser and hit enter! (If you're skeptical that it's truly self-contained, feel free to disconnect from the internet first to prove that it doesn't rely on loading any external resources.)

If you don't want to manually type in all that text, click "Go!" below to load the URL into an iframe, or tap the URL to copy it to your clipboard so you can paste it in your browser's address bar.

Higher quality

The code runs a low-accuracy simulation by default, for better compatibility with low-powered mobile devices. From my testing, it should work on most Android devices less than five years old, and iOS 15+.

The video at the top of the page shows a higher accuracy version, which has two minor changes: it uses 32-bit float buffers rather than 16-bit, and it explicitly checks for division by zero (described here). In the code, these changes correspond to:

If you have a sufficiently powerful device, such as a desktop computer, you can unlock this by clicking here. Scroll back up to the iframe above to see it in action.

Overview

The code has been obfuscated quite a bit to fit in the size constraints, so I'll provide a high-level overview to help understand what it's doing. The data URL constructs a basic HTML page, including a canvas element to render to. It then executes the JavaScript payload which performs several tasks, starting with resizing the canvas to fill the entire viewport, and constructing a WEBGL2 context.

The remainder of the code requires numerous GL API calls, which can make things difficult in minimising the size of the code, as the API methods often have quite verbose names. Luckily there is a common trick to address this issue, described elsewhere as method hashing or mechanised abbreviation. In short, it iterates over all of the available API methods and strips out most of the characters in their names.

My method of doing this is a little different than in the previous links, both to reduce the number of hash collisions, and to make the abbreviated names a little more readable. It uses a regex to split the camelCase names into separate words, and retains only the first two characters of each word. That is, texImage2D becomes teIm2D.

The shader relies on a number of WEBGL extensions, mostly involving floating point textures. Instead of just enabling these specific ones, it ends up being shorter just to loop over the supported extensions and enable them all. It then create two textures to ping pong between, with corresponding framebuffers:


for(i = 2; i--;) {
    tex[i] = g.createTexture()
    g.bindTexture(g.TEXTURE_2D, tex[i])
    fbo[i] = g.createFramebuffer()
    g.bindFramebuffer(g.FRAMEBUFFER, fbo[i])
    g.texImage2D(g.TEXTURE_2D, 0, g.RGBA32F,
        window.innerWidth, window.innerHeight,
        0, g.RGBA, g.FLOAT, null)
    g.generateMipmap(g.TEXTURE_2D)
    g.framebufferTexture2D(g.FRAMEBUFFER,
        g.COLOR_ATTACHMENT0, g.TEXTURE_2D, tex[i], 0)
}

It also generates a mipmap for each texture. This isn't actually used by the shader, so the content of it doesn't particularly matter, it's just shorter than having to set filtering modes for the textures. The rendering function sets up the shader to render to one texture, and read from the other:


function render(buffer=null) {
    g.bindFramebuffer(g.FRAMEBUFFER, buffer)
    g.bindTexture(g.TEXTURE_2D, tex[frame % 2])
    g.uniform1i(g.getUniformLocation(P,'F'), frame % 500)
    g.drawArrays(g.TRIANGLES, 0, 3)
}

The rendering loop, driven by setInterval, alternates between two rendering calls:


render(fbo[frame++ % 2])
render()

The first renders to a texture, and the seconds renders the same image to the screen. It would likely be more efficient to render the texture to the screen, rather than running the simulation twice per frame, but this would have required a second shader program which would have added considerable length to the code.

Note that it's not necessary to construct a vertex array for the draw call, as the vertex shader uses a clever trick to automatically construct a triangle covering the entire screen. This is actually a little more efficient that the traditional fullscreen quad.

Finally, the most important part of the code outside of all this boilerplate is the fragment shader, which is a modified version of the one described in my previous post.

Update

I've made a few changes to this code since publishing it, as I learnt about some of the things that can go wrong with WEBGL on different platforms:

Thanks to Jodie, Theron, and BrowserStack Live for help with testing.