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.

Frequently Asked Questions

What will I learn in this tutorial?

Write WebGL fragment shaders for fire (gradient noise, fbm, colour ramp) and water (Gerstner waves, Fresnel, caustics) — from scratch with a minimal WebGL setup.

What topics are covered in this tutorial?

This tutorial covers: Minimal WebGL Fullscreen Quad, Fire — Gradient Noise and fBm, Fire — Colour Ramp and Alpha Erosion, Water — Gerstner Wave Normals, Water — Caustics and Fresnel, Integrating into Three.js ShaderMaterial.

What tools and technologies does this tutorial use?

This tutorial uses ⏱ ~55 min 🟡 Intermediate Raw WebGL, GLSL ES 3.00.

How long does this tutorial take?

This tutorial takes approximately 55 minutes to complete.