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.
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.
Add Schlick Fresnel to blend refracted depth colour with sky
reflection, then overlay procedural caustic patterns from
high-frequency wave interference.
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.