Created
December 12, 2025 17:10
-
-
Save jlfwong/ff51701c05e7f70d0dab2984dbda8ad7 to your computer and use it in GitHub Desktop.
fluid-sim.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 2D fluid simulation code for | |
| * http://jamie-wong.com/2016/08/04/webgl-fluid-simulation/ | |
| */ | |
| window.FluidSim = function(canvasId, options) { | |
| options = options || {}; | |
| options.initVFn = options.initVFn || [ | |
| 'sin(2.0 * 3.1415 * y)', | |
| 'sin(2.0 * 3.1415 * x)' | |
| ]; | |
| options.initCFn = options.initCFn || [ | |
| 'step(1.0, mod(floor((x + 1.0) / 0.2) + floor((y + 1.0) / 0.2), 2.0))', | |
| 'step(1.0, mod(floor((x + 1.0) / 0.2) + floor((y + 1.0) / 0.2), 2.0))', | |
| 'step(1.0, mod(floor((x + 1.0) / 0.2) + floor((y + 1.0) / 0.2), 2.0))' | |
| ]; | |
| if (options.threshold === undefined) { | |
| options.threshold = true; | |
| } | |
| if (options.advectV === undefined) { | |
| options.advectV = true; | |
| } | |
| if (options.applyPressure === undefined) { | |
| options.applyPressure = false; | |
| } | |
| if (options.showArrows === undefined) { | |
| options.showArrows = true; | |
| } | |
| if (options.dyeSpots === undefined) { | |
| options.dyeSpots = false; | |
| } | |
| // For silly reasons, these have to be equal for now. | |
| // This is because I assumed grid spacing was equal along | |
| // each axis, so if you want to change these to not be equal, you'd have to | |
| // carefully go through the code and decide which values of EPSILON should be | |
| // 1/WIDTH, and which should be 1/HEIGHT. | |
| var WIDTH = options.size || 400; | |
| var HEIGHT = WIDTH; | |
| var EPSILON = 1/WIDTH; | |
| // We assume every time step will be a 120th of a second. | |
| // The animation loop runs at 60 fps (hopefully), so we're simulating 2x | |
| // slow-mo. | |
| var DELTA_T = 1/120.0; | |
| // We arbitrarily set our fluid's density to 1 (this is rho in equations) | |
| var DENSITY = 1.0; | |
| var canvas = document.getElementById(canvasId); | |
| canvas.style.margin = "0 auto"; | |
| canvas.style.display = "block"; | |
| var gl = GL.create(canvas); | |
| gl.canvas.width = WIDTH; | |
| gl.canvas.height = HEIGHT; | |
| gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); | |
| // Standard 2-triangle mesh covering the viewport | |
| // when draw with gl.TRIANGLE_STRIP | |
| var standardMesh = gl.Mesh.load({ | |
| vertices: [ | |
| [-1, 1], | |
| [1, 1], | |
| [-1, -1], | |
| [1, -1] | |
| ], | |
| coords: [ | |
| [0, 1], | |
| [1, 1], | |
| [0, 0], | |
| [1, 0] | |
| ] | |
| }); | |
| var standardVertexShaderSrc = '\ | |
| varying vec2 textureCoord;\ | |
| void main() {\ | |
| textureCoord = gl_TexCoord.xy;\ | |
| gl_Position = gl_Vertex;\ | |
| }'; | |
| // Given a texture holding a 2D velocity field, draw arrows | |
| // showing the direction of the fluid flow. | |
| var drawVectorFieldArrows = (function() { | |
| var shader = new gl.Shader('\ | |
| mat2 rot(float angle) { \ | |
| float c = cos(angle); \ | |
| float s = sin(angle); \ | |
| \ | |
| return mat2( \ | |
| vec2(c, -s), \ | |
| vec2(s, c) \ | |
| ); \ | |
| } \ | |
| \ | |
| attribute vec2 position; \ | |
| uniform sampler2D velocity; \ | |
| void main() { \ | |
| vec2 v = texture2D(velocity, (position + 1.0) / 2.0).xy; \ | |
| float scale = 0.05 * length(v); \ | |
| float angle = atan(v.y, v.x); \ | |
| mat2 rotation = rot(-angle); \ | |
| gl_Position = vec4( \ | |
| (rotation * (scale * gl_Vertex.xy)) + position, \ | |
| 0.0, 1.0); \ | |
| } \ | |
| ', '\ | |
| void main() { \ | |
| gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); \ | |
| } \ | |
| '); | |
| // Triangle pointing towards positive x axis | |
| // with baseline on the y axis | |
| var triangleVertices = [ | |
| [0, 0.2], | |
| [1, 0], | |
| [0, -0.2] | |
| ]; | |
| var arrowsMesh = new gl.Mesh({triangles: false}); | |
| arrowsMesh.addVertexBuffer('positions', 'position'); | |
| var INTERVAL = 30; | |
| for (var i = INTERVAL / 2; i < HEIGHT; i += INTERVAL) { | |
| for (var j = INTERVAL / 2; j < WIDTH; j += INTERVAL) { | |
| for (var k = 0; k < 3; k++) { | |
| arrowsMesh.vertices.push(triangleVertices[k]); | |
| arrowsMesh.positions.push([2 * j / WIDTH - 1, 2 * i / HEIGHT - 1]); | |
| } | |
| } | |
| } | |
| arrowsMesh.compile(); | |
| return function(velocityTexture) { | |
| velocityTexture.bind(0); | |
| shader.uniforms({ | |
| velocity: 0 | |
| }); | |
| shader.draw(arrowsMesh, gl.TRIANGLES); | |
| }; | |
| })(); | |
| // Given glsl expressions for r, g, b, a mapping (x, y) -> a value, return | |
| // a function that will paint a color generated by that function evaluated at | |
| // every pixel of the output buffer. (x, y) will be in the range | |
| // ([-1, 1], [-1, 1]). | |
| var makeFunctionPainter = function(r, g, b, a) { | |
| r = r || '0.0'; | |
| g = g || '0.0'; | |
| b = b || '0.0'; | |
| a = a || '0.0'; | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| varying vec2 textureCoord; \ | |
| void main() { \ | |
| float x = 2.0 * textureCoord.x - 1.0; \ | |
| float y = 2.0 * textureCoord.y - 1.0; \ | |
| gl_FragColor = vec4(' + [r, g, b, a].join(',') +'); \ | |
| } \ | |
| '); | |
| return function() { | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP); | |
| }; | |
| }; | |
| var drawBlack = makeFunctionPainter('0.0', '0.0', '0.0', '1.0'); | |
| // Draw a texture directly to the framebuffer. | |
| // Will stretch to fit, but in practice the texture and the framebuffer should be | |
| // the same size. | |
| var drawTexture = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| varying vec2 textureCoord; \ | |
| uniform sampler2D inputTexture; \ | |
| void main() { \ | |
| gl_FragColor = texture2D(inputTexture, textureCoord); \ | |
| } \ | |
| '); | |
| return function(inputTexture) { | |
| inputTexture.bind(0); | |
| shader.uniforms({ | |
| input: 0 | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP) | |
| }; | |
| })(); | |
| // Draw a texture to the framebuffer, thresholding at 0.5 | |
| var drawTextureThreshold = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| varying vec2 textureCoord; \ | |
| uniform sampler2D inputTexture; \ | |
| void main() { \ | |
| gl_FragColor = step(0.5, texture2D(inputTexture, textureCoord)); \ | |
| } \ | |
| '); | |
| return function(inputTexture) { | |
| inputTexture.bind(0); | |
| shader.uniforms({ | |
| input: 0 | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP) | |
| }; | |
| })(); | |
| // Given an velocity texture and a time delta, advect the | |
| // quantities in the input texture into the output texture | |
| var advect = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| uniform float deltaT; \ | |
| uniform sampler2D inputTexture; \ | |
| uniform sampler2D velocity; \ | |
| varying vec2 textureCoord; \ | |
| \ | |
| void main() { \ | |
| vec2 u = texture2D(velocity, textureCoord).xy; \ | |
| \ | |
| vec2 pastCoord = fract(textureCoord - (0.5 * deltaT * u)); \ | |
| gl_FragColor = texture2D(inputTexture, pastCoord); \ | |
| } \ | |
| '); | |
| return function(inputTexture, velocityTexture) { | |
| inputTexture.bind(0); | |
| velocityTexture.bind(1); | |
| shader.uniforms({ | |
| deltaT: DELTA_T, | |
| input: 0, | |
| velocity: 1 | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP); | |
| }; | |
| })(); | |
| // Apply a "splat" of change to a given place with a given | |
| // blob radius. The effect of the splat has an exponential falloff. | |
| var addSplat = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| uniform vec4 change; \ | |
| uniform vec2 center; \ | |
| uniform float radius; \ | |
| uniform sampler2D inputTex; \ | |
| \ | |
| varying vec2 textureCoord; \ | |
| \ | |
| void main() { \ | |
| float dx = center.x - textureCoord.x; \ | |
| float dy = center.y - textureCoord.y; \ | |
| vec4 cur = texture2D(inputTex, textureCoord); \ | |
| gl_FragColor = cur + change * exp(-(dx * dx + dy * dy) / radius); \ | |
| } \ | |
| '); | |
| return function(inputTexture, change, center, radius) { | |
| inputTexture.bind(0); | |
| shader.uniforms({ | |
| change: change, | |
| center: center, | |
| radius: radius, | |
| inputTex: 0 | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP); | |
| }; | |
| })(); | |
| // Make sure all the color components are between 0 and 1 | |
| var clampColors = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| uniform sampler2D inputTex; \ | |
| varying vec2 textureCoord; \ | |
| \ | |
| void main() { \ | |
| gl_FragColor = clamp(texture2D(inputTex, textureCoord), 0.0, 1.0); \ | |
| } \ | |
| '); | |
| return function(inputTexture) { | |
| inputTexture.bind(0); | |
| shader.uniforms({ | |
| inputTex: 0 | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP); | |
| }; | |
| })(); | |
| // Calculate the divergence of the advected velocity field, and multiply by | |
| // (2 * epsilon * rho / deltaT). | |
| var calcDivergence = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| uniform float deltaT; // Time between steps \n\ | |
| uniform float rho; // Density \n\ | |
| uniform float epsilon; // Distance between grid units \n\ | |
| uniform sampler2D velocity; // Advected velocity field, u_a \n\ | |
| \ | |
| varying vec2 textureCoord; \ | |
| \ | |
| vec2 u(vec2 coord) { \ | |
| return texture2D(velocity, fract(coord)).xy; \ | |
| } \ | |
| \ | |
| void main() { \ | |
| gl_FragColor = vec4((-2.0 * epsilon * rho / deltaT) * ( \ | |
| (u(textureCoord + vec2(epsilon, 0)).x - \ | |
| u(textureCoord - vec2(epsilon, 0)).x) \ | |
| + \ | |
| (u(textureCoord + vec2(0, epsilon)).y - \ | |
| u(textureCoord - vec2(0, epsilon)).y) \ | |
| ), 0.0, 0.0, 1.0); \ | |
| } \ | |
| '); | |
| return function(velocityTexture) { | |
| velocityTexture.bind(0); | |
| shader.uniforms({ | |
| velocity: 0, | |
| epsilon: EPSILON, | |
| deltaT: DELTA_T, | |
| rho: DENSITY | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP); | |
| }; | |
| })(); | |
| // Perform a single iteration of the Jacobi method in order to solve for | |
| // pressure. | |
| var jacobiIterationForPressure = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| uniform float epsilon; // Distance between grid units \n\ | |
| uniform sampler2D divergence; // Divergence field of advected velocity, d \n\ | |
| uniform sampler2D pressure; // Pressure field from previous iteration, p^(k-1) \n\ | |
| \ | |
| varying vec2 textureCoord; \ | |
| \ | |
| float d(vec2 coord) { \ | |
| return texture2D(divergence, fract(coord)).x; \ | |
| } \ | |
| \ | |
| float p(vec2 coord) { \ | |
| return texture2D(pressure, fract(coord)).x; \ | |
| } \ | |
| \ | |
| void main() { \ | |
| gl_FragColor = vec4(0.25 * ( \ | |
| d(textureCoord) \ | |
| + p(textureCoord + vec2(2.0 * epsilon, 0.0)) \ | |
| + p(textureCoord - vec2(2.0 * epsilon, 0.0)) \ | |
| + p(textureCoord + vec2(0.0, 2.0 * epsilon)) \ | |
| + p(textureCoord - vec2(0.0, 2.0 * epsilon)) \ | |
| ), 0.0, 0.0, 1.0); \ | |
| } \ | |
| '); | |
| return function(divergenceTexture, pressureTexture) { | |
| divergenceTexture.bind(0); | |
| pressureTexture.bind(1); | |
| shader.uniforms({ | |
| divergence: 0, | |
| pressure: 1, | |
| epsilon: EPSILON | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP); | |
| }; | |
| })(); | |
| // Subtract the pressure gradient times a constant from the advected velocity | |
| // field. | |
| var subtractPressureGradient = (function() { | |
| var shader = new gl.Shader(standardVertexShaderSrc, '\ | |
| uniform float deltaT; // Time between steps \n\ | |
| uniform float rho; // Density \n\ | |
| uniform float epsilon; // Distance between grid units \n\ | |
| uniform sampler2D velocity; // Advected velocity field, u_a \n\ | |
| uniform sampler2D pressure; // Solved pressure field \n\ | |
| \ | |
| varying vec2 textureCoord; \ | |
| \ | |
| float p(vec2 coord) { \ | |
| return texture2D(pressure, fract(coord)).x; \ | |
| } \ | |
| \ | |
| void main() { \ | |
| vec2 u_a = texture2D(velocity, textureCoord).xy; \ | |
| \ | |
| float diff_p_x = (p(textureCoord + vec2(epsilon, 0.0)) - \ | |
| p(textureCoord - vec2(epsilon, 0.0))); \ | |
| float u_x = u_a.x - deltaT/(2.0 * rho * epsilon) * diff_p_x; \ | |
| \ | |
| float diff_p_y = (p(textureCoord + vec2(0.0, epsilon)) - \ | |
| p(textureCoord - vec2(0.0, epsilon))); \ | |
| float u_y = u_a.y - deltaT/(2.0 * rho * epsilon) * diff_p_y; \ | |
| \ | |
| gl_FragColor = vec4(u_x, u_y, 0.0, 0.0); \ | |
| } \ | |
| '); | |
| return function(velocityTexture, pressureTexture) { | |
| velocityTexture.bind(0); | |
| pressureTexture.bind(1); | |
| shader.uniforms({ | |
| velocity: 0, | |
| pressure: 1, | |
| epsilon: EPSILON, | |
| deltaT: DELTA_T, | |
| rho: DENSITY | |
| }); | |
| shader.draw(standardMesh, gl.TRIANGLE_STRIP); | |
| }; | |
| })(); | |
| var makeTextures = function(names) { | |
| var ret = {}; | |
| names.forEach(function(name) { | |
| ret[name] = new gl.Texture(WIDTH, HEIGHT, {type: gl.FLOAT}); | |
| }); | |
| ret.swap = function(a, b) { | |
| var temp = ret[a]; | |
| ret[a] = ret[b]; | |
| ret[b] = temp; | |
| }; | |
| return ret; | |
| }; | |
| var textures = makeTextures([ | |
| 'velocity0', | |
| 'velocity1', | |
| 'color0', | |
| 'color1', | |
| 'divergence', | |
| 'pressure0', | |
| 'pressure1' | |
| ]); | |
| var initVFnPainter = makeFunctionPainter(options.initVFn[0], | |
| options.initVFn[1]); | |
| var initCFnPainter = makeFunctionPainter(options.initCFn[0], | |
| options.initCFn[1], | |
| options.initCFn[2]); | |
| var reset = function() { | |
| textures.velocity0.drawTo(initVFnPainter); | |
| textures.color0.drawTo(initCFnPainter); | |
| textures.pressure0.drawTo(drawBlack); | |
| }; | |
| reset(); | |
| // Reset the simulation on double click | |
| canvas.addEventListener('dblclick', reset); | |
| // Returns true if the canvas is on the screen | |
| // If "middleIn" is true, then will only return true if the middle of the | |
| // canvas is within the scroll window. | |
| var onScreen = function(middleIn) { | |
| var container = canvas.offsetParent; | |
| var canvasBottom = canvas.offsetTop + canvas.height; | |
| var canvasTop = canvas.offsetTop; | |
| var containerTop = window.scrollY; | |
| var containerBottom = window.scrollY + window.innerHeight; | |
| if (middleIn) { | |
| return (containerTop < (canvasTop + canvasBottom) / 2 && | |
| (canvasTop + canvasBottom) / 2 < containerBottom); | |
| } else { | |
| return (containerTop < canvasBottom && containerBottom > canvasTop); | |
| } | |
| }; | |
| gl.ondraw = function() { | |
| // If the canvas isn't visible, don't draw it | |
| if (!onScreen()) return; | |
| gl.clearColor(1.0, 1.0, 1.0, 1.0); | |
| gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); | |
| if (options.threshold) { | |
| drawTextureThreshold(textures.color0); | |
| } else { | |
| drawTexture(textures.color0); | |
| } | |
| if (options.showArrows) { | |
| drawVectorFieldArrows(textures.velocity0); | |
| } | |
| }; | |
| gl.onupdate = function() { | |
| // If the canvas isn't fully on-screen, don't run the simulation | |
| if (!onScreen(true)) return; | |
| if (options.advectV) { | |
| // Advect the velocity texture through itself, leaving the result in | |
| // textures.velocity0 | |
| textures.velocity1.drawTo(function() { | |
| advect(textures.velocity0, textures.velocity0); | |
| }); | |
| textures.swap('velocity0', 'velocity1'); | |
| } | |
| if (options.applyPressure) { | |
| // Calculate the divergence, leaving the result in textures.divergence | |
| textures.divergence.drawTo(function() { | |
| calcDivergence(textures.velocity0); | |
| }); | |
| // Calculate the pressure, leaving the result in textures.pressure0 | |
| var JACOBI_ITERATIONS = 10; | |
| for (var i = 0; i < JACOBI_ITERATIONS; i++) { | |
| textures.pressure1.drawTo(function() { | |
| jacobiIterationForPressure(textures.divergence, textures.pressure0); | |
| }); | |
| textures.swap('pressure0', 'pressure1'); | |
| } | |
| // Subtract the pressure gradient from the advected velocity texture, | |
| // leaving the result in textures.velocity0 | |
| textures.velocity1.drawTo(function() { | |
| subtractPressureGradient(textures.velocity0, textures.pressure0); | |
| }); | |
| textures.swap('velocity0', 'velocity1'); | |
| } | |
| // Advect the color field, leaving the result in textures.color0 | |
| textures.color1.drawTo(function() { | |
| advect(textures.color0, textures.velocity0); | |
| }); | |
| textures.swap('color0', 'color1'); | |
| if (options.dyeSpots) { | |
| // Add a few spots slowly emitting dye to prevent the color from | |
| // eventually converging to the grey-ish average color of the whole fluid | |
| var addDyeSource = function(color, location) { | |
| textures.color1.drawTo(function() { | |
| addSplat( | |
| textures.color0, | |
| color.concat([0.0]), | |
| location, | |
| 0.01 | |
| ); | |
| }); | |
| textures.swap('color0', 'color1'); | |
| }; | |
| // Add red to bottom left | |
| addDyeSource([0.004, -0.002, -0.002], [0.2, 0.2]); | |
| // Add blue to the top middle | |
| addDyeSource([-0.002, -0.002, 0.004], [0.5, 0.9]); | |
| // Add green to the bottom right | |
| addDyeSource([-0.002, 0.004, -0.002], [0.8, 0.2]); | |
| } | |
| }; | |
| gl.onmousemove = function(ev) { | |
| if (ev.dragging) { | |
| textures.velocity1.drawTo(function() { | |
| addSplat( | |
| textures.velocity0, | |
| [10.0 * ev.deltaX / WIDTH, -10.0 * ev.deltaY / HEIGHT, 0.0, 0.0], | |
| [ev.offsetX / WIDTH, 1.0 - ev.offsetY / HEIGHT], | |
| 0.01 | |
| ); | |
| }); | |
| textures.swap('velocity0', 'velocity1'); | |
| } | |
| }; | |
| gl.animate(); | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment