PageSourceSearch

https://davidar.io/post/card

html davidar.io collected 2026-09-25 21:03:37 UTC 14,027 bytes, 206 lines download raw bytes

1<!DOCTYPE html><html lang="en">
2<head>
3<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
4<meta name="viewport" content="width=device-width, initial-scale=1">
5<link rel="stylesheet" href="//texify.davidar.io/main.css" type="text/css">
6<title>Business card fluid simulator: Realistic liquid animation in 1KB of WebGL</title>
7<meta name="description" content="A self-contained version of my tiny fluid simulation, compressed to fit on the back of a business card.">
8<meta name="author" content="David A Roberts">
9<link rel="icon" type="image/png" href="/favicon.png">
10<link rel="canonical" href="https://davidar.io/post/card">
11
12<!-- Facebook Meta Tags -->
13<meta property="og:url" content="https://davidar.io/post/card">
14<meta property="og:type" content="website">
15<meta property="og:title" content="Business card fluid simulator: Realistic liquid animation in 1KB of WebGL">
16<meta property="og:description" content="A self-contained version of my tiny fluid simulation, compressed to fit on the back of a business card.">
17<meta property="og:image" content="https://davidar.io/img/card-back.png?v=2">
18
19<!-- Twitter Meta Tags -->
20<meta name="twitter:card" content="summary_large_image">
21<meta property="twitter:domain" content="davidar.io">
22<meta property="twitter:url" content="https://davidar.io/post/card">
23<meta name="twitter:title" content="Business card fluid simulator: Realistic liquid animation in 1KB of WebGL">
24<meta name="twitter:description" content="A self-contained version of my tiny fluid simulation, compressed to fit on the back of a business card.">
25<meta name="twitter:image" content="https://davidar.io/img/card-back.png?v=2">
26
27</head>
28<body>
29<header style="background: linear-gradient(rgba(32,32,32,0.5), rgba(32,32,32,0.9)), url(/img/fluid.png);">
30    <h1>Business card fluid simulator</h1>
31    <p>Realistic liquid animation in 1KB of WebGL</p>
32    <address><a href="/">David A Roberts</a></address>
33</header>
34<main>
35<section id="abstract">
36    <h6>Abstract</h6>
37    <p>A self-contained version of <a href="/post/fluid">my tiny fluid simulation</a>, compressed to fit on the back of a business card.</p>
38</section>
39<figure style="box-shadow:0 3px 10px rgba(0,0,0,0.2)">
40<div class="embed-16-9"><iframe src="https://player.vimeo.com/video/660348787?autoplay=1&loop=1&muted=1" width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>
41</figure>
42
43<figure style="box-shadow:0 3px 10px rgba(0,0,0,0.2)">
44<img src="/img/card-back.svg?v=2" style="width:100%">
45</figure>
46
47<h2 class="num">Introduction</h2>
48
49<p>For some time I've wanted to create something similar to the <a href="http://eastfarthing.com/blog/2016-01-12-card/">business card raytracer</a>:
50a piece of code that produces something interesting, but is short enough to fit onto a business card.
51The tiny fluid simulation I described in <a href="/post/fluid">my previous post</a> provided the perfect opportunity,
52as the code for the simulator is remarkably short relative to the complex behaviour it exhibits.
53
54<p>However, it was written for <a href="https://www.shadertoy.com/">Shadertoy</a>, which is a complex webapp in itself, so doesn't quite meet the challenge of making the code self-contained.
55So, 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.
56For this I took inspiration from some entries to the <a href="https://js1k.com/">JS1K</a> contest,
57in particular <a href="https://xem.github.io/articles/webgl_quest.html">MiniShadertoy</a> which provided a great starting point though lacked support for Shadertoy's multipass buffers,
58and <a href="https://js1k.com/2019-x/details/4157">this entry</a> which contained a couple of other useful techniques.
59
60<p>I've provided the source code in the form of a <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs">data URL</a> to make it easy to run ---
61all you need to do is type the code into the address bar of your browser and hit enter!
62(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.)
63
64<p>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.
65
66<figure>
67<input id="data-url" readonly type="text" style="width:67%" value='
68data:text/html,<body onload="i=2;c.height=H=innerHeight
69;c.width=W=innerWidth;for(k in g=c.getContext`webgl2`)g
70[k.split(/(^.|[^a-z])/).map(s=>s[0]).join``]=g[k];with(
71g){for(e of geSuEx())geEx(e);P=crPr();for(X=[],B=[];i--
72;t=35632)biTe(x=3553,X[i]=crTe())|teIm2D(x,0,34842,W,H,
730,6408,5126,c)|biFr(f=36160,B[i]=crFr())|geMi(x)|frTe2D
74(f,36064,x,X[i],0);A=`texelFetch(B,ivec2(i+u).yx,0)`;C=
75s=>shSo(S=crSh(t++),`%23version 300 es\nout highp vec4 
76O;uniform highp int F;uniform sampler2D B;void main(){O
77-=O;int k=169;${s};}`)|coSh(S)|atSh(P,S);C(`highp vec2 
78u=gl_FragCoord.yx,v,i;for(;k-->0;O+=vec4(0,v-i+v-v*${A}
79.w,1)*${A}.w/exp(dot(v,v))/3.142)i=vec2(k/13,k%2513)-6.
80,v=i+${A}.yz;O.yz/=O.w+1e-6;F>1?O:O+=vec4(0,u/1e3-${H}.
81/2e3,.3)`);C`k=gl_VertexID;gl_Position=vec4(k*4&4,k*2&4
82,1,2)-1.`;R=b=>uniform1i(geUnLo(P,`F`),i%25500)|biFr(f,
83b)|biTe(x,X[i%2])|drAr(4,0,3);liPr(P);usPr(P)}L=_=>R(B[
84i++%2])|R()|requestAnimationFrame(L);L()"style=margin:0
85>
85<canvas id=c> David A Roberts https://davidar.io/card
86' onclick="if (!window.__cfRLUnblockHandlers) return false; this.focus();this.select();document.execCommand(&quot;copy&quot;);" data-cf-modified-792f690c21d5f7eb20136453-="">
87<button type="button" onclick="if (!window.__cfRLUnblockHandlers) return false; document.getElementById(&quot;demo&quot;).src = document.getElementById(&quot;data-url&quot;).value" data-cf-modified-792f690c21d5f7eb20136453-="">Go!</button>
88<div style="border:1px solid black;margin:0 auto;max-width:640px;max-height:360px">
89    <div class="embed-16-9" style="margin:0"><iframe width="640" height="360" scrolling="no" id="demo"></iframe></div>
90</div>
91</figure>
92
93<h2 class="num">Higher quality</h2>
94
95<p>The code runs a low-accuracy simulation by default, for better compatibility with low-powered mobile devices.
96From my testing, it should work on most Android devices less than five years old, and <a href="https://caniuse.com/webgl2">iOS 15+</a>.
97
98<p>The video at the top of the page shows a higher accuracy version, which has two minor changes:
99it uses 32-bit float buffers rather than 16-bit, and
100it explicitly checks for division by zero (described <a href="/post/fluid">here</a>).
101In the code, these changes correspond to:
102
103<ul>
104<li>changing the magic number <code>34842</code> to <code>34836</code>
105<li>replacing the statement <code>O.yz/=O.w+1e-6</code> with <code>if(O.w>0.)O.yz/=O.w</code>
106</ul>
107
108If you have a sufficiently powerful device, such as a desktop computer, you can unlock this by clicking
109<a href="#" onclick="if (!window.__cfRLUnblockHandlers) return false; 
110const data = document.getElementById(&quot;data-url&quot;);
111data.value = data.value.replace(&quot;34842&quot;,&quot;34836&quot;);
112data.value = data.value.replace(&quot;O.yz/=O.w+1e-6&quot;,&quot;if(O.w>0.)O.yz/=O.w&quot;);
113const demo = document.getElementById(&quot;demo&quot;);
114if(demo.src) demo.src = data.value;
115return false;
116" data-cf-modified-792f690c21d5f7eb20136453-="">here</a>.
117Scroll back up to the iframe above to see it in action.
118
119<h2 class="num">Overview</h2>
120
121<p>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.
122The data URL constructs a basic HTML page, including a canvas element to render to.
123It then executes the JavaScript payload which performs several tasks, starting with resizing the canvas to fill the entire viewport, and constructing a WEBGL2 context.
124
125<p>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.
126Luckily there is a common trick to address this issue, described elsewhere as <a href="https://xem.github.io/articles/webgl_quest.html">method hashing</a> or <a href="https://marijnhaverbeke.nl/js1k/">mechanised abbreviation</a>.
127In short, it iterates over all of the available API methods and strips out most of the characters in their names.
128
129<p>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.
130It uses a regex to split the <code>camelCase</code> names into separate words, and retains only the first two characters of each word.
131That is, <code>texImage2D</code> becomes <code>teIm2D</code>.
132
133<p>The shader relies on a number of WEBGL extensions, mostly involving floating point textures.
134Instead of just enabling these specific ones, it ends up being shorter just to loop over the supported extensions and enable them all.
135It then create two textures to <a href="https://en.wikipedia.org/wiki/Multiple_buffering">ping pong</a> between, with corresponding framebuffers:
136
137<pre><code class="js">
138for(i = 2; i--;) {
139    tex[i] = g.createTexture()
140    g.bindTexture(g.TEXTURE_2D, tex[i])
141    fbo[i] = g.createFramebuffer()
142    g.bindFramebuffer(g.FRAMEBUFFER, fbo[i])
143    g.texImage2D(g.TEXTURE_2D, 0, g.RGBA32F,
144        window.innerWidth, window.innerHeight,
145        0, g.RGBA, g.FLOAT, null)
146    g.generateMipmap(g.TEXTURE_2D)
147    g.framebufferTexture2D(g.FRAMEBUFFER,
148        g.COLOR_ATTACHMENT0, g.TEXTURE_2D, tex[i], 0)
149}
150</code></pre>
151
152<p>It also generates a mipmap for each texture.
153This 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.
154The rendering function sets up the shader to render to one texture, and read from the other:
155
156<pre><code class="js">
157function render(buffer=null) {
158    g.bindFramebuffer(g.FRAMEBUFFER, buffer)
159    g.bindTexture(g.TEXTURE_2D, tex[frame % 2])
160    g.uniform1i(g.getUniformLocation(P,'F'), frame % 500)
161    g.drawArrays(g.TRIANGLES, 0, 3)
162}
163</code></pre>
164
165<p>The rendering loop, driven by <code>setInterval</code>, alternates between two rendering calls:
166
167<pre><code class="js">
168render(fbo[frame++ % 2])
169render()
170</code></pre>
171
172<p>The first renders to a texture, and the seconds renders the same image to the screen.
173It 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.
174
175<p>Note that it's not necessary to construct a vertex array for the draw call,
176as the vertex shader uses a <a href="https://rauwendaal.net/2014/06/14/rendering-a-screen-covering-triangle-in-opengl/">clever trick</a> to automatically construct a triangle covering the entire screen.
177This is actually <a href="https://michaldrobot.com/2014/04/01/gcn-execution-patterns-in-full-screen-passes/">a little more efficient that the traditional fullscreen quad</a>.
178
179<p>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 
179in <a href="/post/fluid">my previous post</a>.
180
181<h2 class="num">Update</h2>
182
183<p>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:
184
185<ul>
186<li><p>On some platforms, initialising only some elements of builtin vectors like <code>gl_Position</code> results in the remaining elements defaulting to a sensible value.
187On other platforms it doesn't. Always explicitly initialise the entire vector.
188<li><p>Most modern mobile devices support <a href="https://developer.mozilla.org/en-US/docs/Web/API/EXT_color_buffer_float">float buffers</a>,
189but a decent number still lack <a href="https://developer.mozilla.org/en-US/docs/Web/API/OES_texture_float_linear">complete support for 32-bit float buffers</a>.
190<li><p>Using <code>setInterval</code> instead of <code>requestAnimationFrame</code> for animation is a bad idea.
191The former is much less verbose, and therefore quite attractive from a code-golfing perspective,
192but even if you're willing to look past <a href="https://researchhubs.com/post/computing/javascript/requestAnimationFrame.html">its more superficial deficiencies</a>,
193using it for the rendering loop can sometimes cause people's computers to freeze.
194The extra 20 or so bytes is unfortunate but worth it.
195<p><a href="https://ebenpackwood.com/posts/wavybits.html">I'm not the first person to have fallen into this trap.</a>
196Why didn't I simply use the abbreviation trick to shorten it?
197Because browsers like to throw exceptions for <a href="https://michalzalecki.com/why-using-localStorage-directly-is-a-bad-idea/">merely accessing some globals in some circumstances</a>.
198</ul>
199
200<p>Thanks to Jodie, Theron, and BrowserStack Live for help with testing.
201
202</main>
203<script async src="//texify.davidar.io/load.js" type="792f690c21d5f7eb20136453-text/javascript"></script>
203
204<!-- Cloudflare Pages Analytics -->
vendor: 99 bytes, line 204
204<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "
20458aced9614cc4a259a465717f7347819
vendor: 61 bytes, line 204
204"}' type="792f690c21d5f7eb20136453-text/javascript"></script>
204<!-- Cloudflare Pages Analytics -->
vendor: 142 bytes, line 204
204<script src="/cdn-cgi/scripts/7d0fa10a/cloudflare-static/rocket-loader.min.js" data-cf-settings="792f690c21d5f7eb20136453-|49" defer></script>
204<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"10d49f17c8b24855a767c6b3d2b1b64c","spa":2}' crossorigin="anonymous"></script>
204
205</body>
206</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.