Shaders are small programs that run on the GPU, one invocation per vertex or per pixel, and they are the reason a browser can draw thousands of animated effects at 60fps without the CPU lifting a finger. This article walks through a water ripple effect in the simplest code possible — and, unlike most tutorials of this length, it includes every line needed to actually run it: context creation, shader compilation, program linking, attribute setup, uniforms and the render loop.

The Two Stages
A WebGL pipeline has two shader stages, and the division of labor is worth internalizing before writing any code:
- Vertex shader. Runs once per vertex. It decides where things are — transforming positions into clip space. For a 2D effect you need only a fullscreen quad, so this shader is almost trivial.
- Fragment shader. Runs once per pixel covered by the geometry. It decides what color each pixel is. The entire ripple effect lives here, which is why it is short enough to read at a glance.
Between them sits fixed-function state you set from JavaScript: which buffer holds the vertices, which texture is bound, what the viewport is, and — critically — which program is active when you upload uniforms.
Complete, Runnable Implementation
HTML
<canvas id="canvas"></canvas>
JavaScript
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl');
if (!gl) throw new Error('WebGL is not supported in this browser');
const vsSource = `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
v_uv = a_position * 0.5 + 0.5;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const fsSource = `
precision mediump float;
varying vec2 v_uv;
uniform float u_time;
void main() {
vec2 center = vec2(0.5, 0.5);
float dist = distance(v_uv, center);
float wave = sin(dist * 30.0 - u_time * 3.0) * 0.5 + 0.5;
wave *= smoothstep(0.5, 0.0, dist);
vec3 deep = vec3(0.0, 0.5, 0.8);
vec3 light = vec3(0.0, 0.8, 0.9);
gl_FragColor = vec4(mix(deep, light, wave), 1.0);
}`;
function compile(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(log);
}
return shader;
}
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, vsSource));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fsSource));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program));
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1, 1, 1
]), gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
const uTime = gl.getUniformLocation(program, 'u_time');
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
}
resize();
window.addEventListener('resize', resize);
function render(time) {
gl.uniform1f(uTime, time * 0.001);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
That is the whole thing. Four vertices in, one fullscreen quad out, and a sine wave evaluated per pixel.
How to Read It
Coordinates. The vertex shader receives positions in clip space, where (-1, -1) is the bottom-left corner and (1, 1) the top-right. The line v_uv = a_position * 0.5 + 0.5 converts them to normalized texture coordinates from 0 to 1, which is what the fragment shader wants for a stable effect at any canvas size.
The ripple formula.
wave = sin(distance × frequency − time × speed) × 0.5 + 0.5
distance— how far this pixel sits from the center.sin— turns that distance into an oscillation, so you get rings instead of a flat gradient.u_time— pushes the phase forward every frame, which is what makes the rings move outward.smoothstep(0.5, 0.0, dist)— fades the amplitude back to zero at the edges so the effect has a boundary instead of covering the screen forever.
The loop. requestAnimationFrame drives the animation; time * 0.001 converts milliseconds into seconds so the shader’s speed constants stay human-readable. Note that gl.uniform1f is called after gl.useProgram(program) — uniforms belong to the active program, and calling it while another program is bound silently does nothing. This is the single most common source of “my shader is frozen” bugs.
Errors are not silent. The compile helper throws with the driver’s info log. A GLSL syntax error shows up here, in plain text, the moment you load the page — if you check. Skipping the status checks is how beginners lose an afternoon to a black canvas.
Where to Take It Next
Pointer-driven ripples
Replace the fixed center with the mouse or touch position by adding a second uniform:
uniform vec2 u_pointer;
...
float dist = distance(v_uv, u_pointer);
Then upload it each frame from mousemove or pointermove, converting client coordinates into the same 0–1 space as v_uv. Touch devices get this for free through Pointer Events, which fire for mouse, pen and finger with one handler.
Multiple drop sources
Rain effects sum several centers into one wave value:
float wave = 0.0;
for (int i = 0; i < 3; i++) {
float d = distance(v_uv, centers[i]);
wave += sin(d * 20.0 - u_time * 2.0 + float(i)) * 0.3;
}
Normals and specular highlights
Once the wave field exists you can differentiate it to recover a surface normal and light it like real water:
vec2 dx = dFdx(wave);
vec2 dy = dFdy(wave);
vec3 normal = normalize(vec3(dx, dy, 0.1));
float specular = pow(max(dot(normal, lightDir), 0.0), 32.0);
WebGL2 and GLSL ES 3.00
Everything above uses the original WebGL context and GLSL ES 1.00 syntax (attribute, varying, gl_FragColor). Calling getContext('webgl2') gets you #version 300 es shaders, where those keywords become in, out and an explicit output variable — plus derivative functions without extensions and better debugging. The structure of the program is identical; only the declarations change.
Where Shaders Earn Their Keep in Games
- Menu and title-screen backdrops that cost nothing on the CPU.
- Water, heat haze, fog and distortion in otherwise lightweight scenes.
- UI transitions and screen effects — damage flashes, vignettes, scanlines.
- Weather systems: rain, snow and dust driven entirely by time uniforms.
Conclusion
The barrier to WebGL is not the mathematics; it is the ceremony around it. Compilation, linking, attribute pointers and uniform locations are a one-time tax, and once you have a working boilerplate — or MDN’s tutorial, or WebGL Fundamentals’ extensive book open next to you — the shader itself shrinks to a dozen lines of trigonometry. Coordinate systems, periodic functions and color blending: that is the entire vocabulary. The GPU will handle the rest.