GLSL · WebGL · Shaders
⏱ ~55 min🟡 IntermediateRaw WebGL · GLSL ES 3.00

GLSL Fire & Water Fragment Shaders

Write fragment shaders for fire (fractal Brownian motion noise, colour ramp, alpha erosion) and water (Gerstner wave normals, Fresnel reflectance, caustic patterns) — no framework, minimal WebGL.

1

Minimal WebGL Fullscreen Quad

Before writing any shader effects, set up a raw WebGL context with a fullscreen triangle — the cheapest way to run a fragment shader on every pixel.

const canvas = document.querySelector('canvas'); const gl = canvas.getContext('webgl2'); canvas.width = innerWidth; canvas.height = innerHeight; // Vertex shader — just output clip-space coordinates const VS = `#version 300 es const vec2 VERTS[3] = vec2[](vec2(-1,-1), vec2(3,-1), vec2(-1,3)); void main() { gl_Position = vec4(VERTS[gl_VertexID], 0.0, 1.0); }`; function makeProgram(gl, vs, fs) { const compile = (src, type) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(s); return s; }; const prog = gl.createProgram(); gl.attachShader(prog, compile(vs, gl.VERTEX_SHADER)); gl.attachShader(prog, compile(fs, gl.FRAGMENT_SHADER)); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw gl.getProgramInfoLog(prog); return prog; } // Draw: no VAO needed — vertex IDs are built-in // gl.drawArrays(gl.TRIANGLES, 0, 3);
A single large triangle covering clip space is more efficient than a quad (2 triangles): it avoids the diagonal seam and needs zero vertex buffers — gl_VertexID is enough.
2

Fire — Gradient Noise and fBm

Fire is built from fractal Brownian motion (fBm) — several octaves of smooth noise added together. The flame shape rises by shifting UV upward over time.

// Fragment shader — fire const FIRE_FS = `#version 300 es precision highp float; uniform float uTime; uniform vec2 uResolution; out vec4 fragColor; // 2D gradient noise (value noise variant) float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.545); } float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f*f*(3.0 - 2.0*f); // smoothstep return mix(mix(hash(i), hash(i+vec2(1,0)), u.x), mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)), u.x), u.y); } float fbm(vec2 p) { float v = 0.0, amp = 0.5; for (int i = 0; i < 6; i++) { v += noise(p) * amp; p *= 2.1; // lacunarity amp *= 0.5; // persistence } return v; } void main() { vec2 uv = gl_FragCoord.xy / uResolution; uv.x = uv.x * 2.0 - 1.0; // centre horizontally // Flame rises: subtract time to shift noise upward vec2 p = vec2(uv.x * 1.5, uv.y * 2.0 - uTime * 0.8); float f = fbm(p + fbm(p + fbm(p))); // domain-warped fBm // ...colour ramp in Step 3 fragColor = vec4(f, f*0.4, 0.0, 1.0); }`;
Domain warping — passing fBm output as input to another fBm call — creates the characteristic turbulent, swirling shape of fire. Three levels of warping are usually enough.
3

Fire — Colour Ramp and Alpha Erosion

Map the fBm value to a fire colour ramp and erode alpha near the top and edges so the flame tapers naturally.

// Inside main() in FIRE_FS, after computing f: // Vertical mask — fire burns from bottom, fades at top float mask = smoothstep(1.0, 0.0, uv.y) * // fade at top smoothstep(-0.9, 0.0, uv.x) * // fade left edge smoothstep( 0.9, 0.0, uv.x); // fade right edge float fire = f * mask; fire = pow(fire, 1.5); // increase contrast // Colour ramp: black → red → orange → yellow → white vec3 col = vec3(0.0); col = mix(col, vec3(0.8,0.1,0.0), smoothstep(0.0,0.2,fire)); // black→red col = mix(col, vec3(1.0,0.4,0.0), smoothstep(0.2,0.4,fire)); // red→orange col = mix(col, vec3(1.0,0.85,0.2),smoothstep(0.4,0.7,fire)); // orange→yellow col = mix(col, vec3(1.0,1.0,0.95),smoothstep(0.7,1.0,fire)); // yellow→white float alpha = smoothstep(0.05, 0.3, fire); // discard dark pixels fragColor = vec4(col, alpha);
Use gl.blendFunc(gl.SRC_ALPHA, gl.ONE) (additive blending) for fire rendered against a dark background — it accumulates light naturally and avoids dark halos around the sprite edges.
4

Water — Gerstner Wave Normals

Water surface normals are computed from the sum of Gerstner waves directly in the fragment shader. The displaced normal drives reflections and specular highlights.

// Fragment shader — water surface const WATER_FS = `#version 300 es precision highp float; uniform float uTime; uniform vec2 uResolution; uniform samplerCube uEnvMap; out vec4 fragColor; // Gerstner wave: returns (displacement XZ, wave normal delta XZ) struct GW { float A, k, omega, phi; vec2 D; }; vec3 gerstnerNormal(GW w, vec2 xz) { float theta = dot(w.k * w.D, xz) - w.omega * uTime + w.phi; float s = sin(theta); float scale = w.A * w.k; return vec3(-scale * w.D.x * s, 0.0, // y component (height) unused here -scale * w.D.y * s); } void main() { vec2 uv = gl_FragCoord.xy / uResolution; vec2 xz = (uv - 0.5) * 20.0; // world-space XZ plane // Superpose 4 Gerstner waves GW waves[4]; waves[0] = GW(0.5, 2.0, 2.0, 0.0, normalize(vec2(1.0, 0.7))); waves[1] = GW(0.3, 3.5, 2.8, 1.2, normalize(vec2(-0.5, 1.0))); waves[2] = GW(0.2, 5.0, 3.5, 2.4, normalize(vec2(0.8, -0.3))); waves[3] = GW(0.15,7.0, 4.5, 3.7, normalize(vec2(-0.3, -0.9))); vec3 normal = vec3(0.0, 1.0, 0.0); for (int i = 0; i < 4; i++) { normal += gerstnerNormal(waves[i], xz); } normal = normalize(normal); // Reflection direction from camera looking down vec3 viewDir = normalize(vec3(uv - 0.5, 1.0)); vec3 reflDir = reflect(viewDir, normal); // ...Fresnel + colour in Step 5 fragColor = vec4(normal * 0.5 + 0.5, 1.0); // debug view }`;
5

Water — Caustics and Fresnel

Add Schlick Fresnel to blend refracted depth colour with sky reflection, then overlay procedural caustic patterns from high-frequency wave interference.

// Inside WATER_FS main() — after computing normal and reflDir: // Schlick Fresnel approximation float fresnel(vec3 n, vec3 v, float F0) { return F0 + (1.0 - F0) * pow(1.0 - max(dot(n, -v), 0.0), 5.0); } // Simple sky colour (gradient) vec3 sky = mix(vec3(0.05, 0.1, 0.3), vec3(0.5, 0.8, 1.0), clamp(reflDir.y * 0.5 + 0.5, 0.0, 1.0)); // Deep water colour vec3 waterDeep = vec3(0.0, 0.08, 0.18); // Procedural caustics — interference of 3 wave sets float cx = xz.x, cz = xz.z; float caus = 0.0; for (float f = 1.0; f <= 4.0; f++) { float th = cx * f * 1.1 + cz * f * 0.7 + uTime * 1.2; caus += sin(th) * 0.5 + 0.5; th = cx * f * 0.8 - cz * f * 1.3 - uTime * 0.9; caus += sin(th) * 0.5 + 0.5; } caus = pow(caus / 8.0, 3.0) * 2.5; // sharpen caustic highlights float F = fresnel(normal, viewDir, 0.02); // water F0 ≈ 0.02 vec3 col = mix(waterDeep + caus * vec3(0.2, 0.3, 0.4), // refracted sky, // reflected F); // Specular highlight float spec = pow(max(dot(reflDir, vec3(0.3, 0.9, 0.3)), 0.0), 80.0); col += vec3(1.0) * spec * 0.8; fragColor = vec4(col, 1.0);
Schlick's approximation: F(θ) = F₀ + (1−F₀)(1−cosθ)⁵ — water has F₀ ≈ 0.02, so at grazing angles almost 100% of light is reflected (mirror-like), while near-normal angles show the deep-water colour below.
6

Integrating into Three.js ShaderMaterial

Port the fragment shader logic into a Three.js ShaderMaterial so it can be applied to any mesh — a plane for water, a billboard sprite for fire.

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; // Water plane — 10×10m, 128×128 subdivisions for detail const waterGeo = new THREE.PlaneGeometry(10, 10, 128, 128); waterGeo.rotateX(-Math.PI / 2); const waterMat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, uResolution: { value: new THREE.Vector2(innerWidth, innerHeight) }, uEnvMap: { value: envCubeTexture }, // CubeRenderTarget or PMREMGenerator }, vertexShader: ` varying vec2 vUv; varying vec3 vWorldPos; void main() { vUv = uv; vec4 worldPos = modelMatrix * vec4(position, 1.0); vWorldPos = worldPos.xyz; gl_Position = projectionMatrix * viewMatrix * worldPos; }`, fragmentShader: WATER_FS_BODY, // paste Step 4+5 GLSL here, use vUv/vWorldPos transparent: true, side: THREE.DoubleSide, }); const water = new THREE.Mesh(waterGeo, waterMat); scene.add(water); // Fire billboard (always faces camera — use Sprite or custom billboard shader) const fireMat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, uResolution: { value: new THREE.Vector2(2,4) } }, vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`, fragmentShader: FIRE_FS_BODY, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, }); // Update time each frame function animate() { requestAnimationFrame(animate); const t = performance.now() * 0.001; waterMat.uniforms.uTime.value = t; fireMat.uniforms.uTime.value = t; renderer.render(scene, camera); } animate();
Disable depthWrite:false on the fire ShaderMaterial so transparent fire pixels don't occlude geometry behind them. For water, keep depth writing on so it properly interacts with submerged objects.