Shaders are small programs that run on the GPU. In WebGL, you write them in GLSL (OpenGL Shading Language). Every pixel you see in a 3D browser game passed through a shader. There are two types:
The GPU runs them in parallel, which is why shaders are fast. A fragment shader that runs in 0.001ms per pixel can process 2 million pixels in under 2ms total.
Here’s the minimum WebGL setup to compile and use a shader pair:
// Compile a shader
function compileShader(gl, source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
// Link vertex + fragment into a program
const vs = compileShader(gl, vertexSrc, gl.VERTEX_SHADER);
const fs = compileShader(gl, fragmentSrc, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.useProgram(program);
In Three.js, you skip the boilerplate and use ShaderMaterial:
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new THREE.Color(0xc8a53a) }
},
vertexShader: `...`,
fragmentShader: `...`
});
A classic farming game effect. Distort UV coordinates using sine waves based on time:
// Fragment shader
uniform float uTime;
uniform sampler2D uTexture;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
uv.x += sin(uv.y * 20.0 + uTime * 2.0) * 0.01;
uv.y += cos(uv.x * 15.0 + uTime * 1.5) * 0.01;
gl_FragColor = texture2D(uTexture, uv);
}
The 0.01 controls ripple intensity. Keep it subtle — too high and the water looks like jelly.
Tint the entire scene based on a time-of-day value (0.0 = midnight, 0.5 = noon, 1.0 = midnight again):
// Fragment shader (post-processing pass)
uniform float uTimeOfDay;
uniform sampler2D uScene;
varying vec2 vUv;
vec3 getDayColor(float t) {
// Dawn (0.25): warm orange
// Noon (0.5): bright white
// Dusk (0.75): deep orange-pink
// Night (0.0/1.0): dark blue
vec3 night = vec3(0.1, 0.1, 0.3);
vec3 dawn = vec3(1.0, 0.7, 0.4);
vec3 noon = vec3(1.0, 1.0, 0.95);
vec3 dusk = vec3(1.0, 0.5, 0.3);
if (t < 0.25) return mix(night, dawn, t / 0.25);
if (t < 0.5) return mix(dawn, noon, (t - 0.25) / 0.25);
if (t < 0.75) return mix(noon, dusk, (t - 0.5) / 0.25);
return mix(dusk, night, (t - 0.75) / 0.25);
}
void main() {
vec4 scene = texture2D(uScene, vUv);
vec3 tint = getDayColor(uTimeOfDay);
gl_FragColor = vec4(scene.rgb * tint, scene.a);
}
Bloom makes bright areas bleed light into surrounding pixels. The typical approach:
// Threshold pass — keep only bright pixels
uniform sampler2D uScene;
uniform float uThreshold;
varying vec2 vUv;
void main() {
vec4 color = texture2D(uScene, vUv);
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
gl_FragColor = brightness > uThreshold ? color : vec4(0.0);
}
Snap UV coordinates to a grid for a retro look:
uniform vec2 uResolution;
uniform float uPixelSize;
uniform sampler2D uScene;
varying vec2 vUv;
void main() {
vec2 grid = uResolution / uPixelSize;
vec2 snapped = floor(vUv * grid) / grid;
gl_FragColor = texture2D(uScene, snapped);
}
Set uPixelSize to 4.0 for a mild effect or 8.0 for a Game Boy look.
Distance-based fog blends objects toward a horizon color as they get farther from the camera:
// Vertex shader: pass distance to fragment
varying float vFogDepth;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vFogDepth = -mvPosition.z; // distance from camera
gl_Position = projectionMatrix * mvPosition;
}
// Fragment shader: mix with fog color
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;
varying float vFogDepth;
void main() {
vec4 color = /* your base color */;
float fogFactor = smoothstep(uFogNear, uFogFar, vFogDepth);
gl_FragColor = vec4(mix(color.rgb, uFogColor, fogFactor), color.a);
}
Apply a color lookup table (LUT) for cinematic grading, or do it mathematically:
// Warm autumn palette for a farming game
vec3 colorGrade(vec3 color) {
// Boost warmth
color.r *= 1.1;
color.b *= 0.9;
// Slight contrast curve
color = smoothstep(0.0, 1.0, color);
// Vignette would go here
return color;
}
Uniforms are values you pass from JavaScript into your shader every frame. Common game uniforms:
| Uniform | Type | Usage |
|---|---|---|
uTime | float | Elapsed seconds — drives all animation |
uResolution | vec2 | Canvas width/height in pixels |
uMouse | vec2 | Normalized cursor position (0–1) |
uTexture | sampler2D | The scene or a sprite texture |
uTimeOfDay | float | 0–1 for day/night cycle |
In Three.js, update them in your render loop:
function animate(time) {
material.uniforms.uTime.value = time * 0.001;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
GLSL has no console.log. When a shader breaks, you get a black screen or nothing. Debugging strategies:
gl_FragColor = vec4(vUv, 0.0, 1.0) to visualize UV coordinates. Red = X, Green = Y.gl.getShaderInfoLog(). GLSL error messages include line numbers.precision mediump float; is required in WebGL fragment shaders), integer vs float mismatch (1 vs 1.0), missing semicolons.if/else forces the GPU to evaluate both paths. Use mix(), step(), and smoothstep() instead.texture2D() call is expensive. A Gaussian blur with 9 samples is fine; 25 samples will tank mobile performance.lowp/mediump where precision doesn’t matter. Color values don’t need highp.FarmHeart uses day/night, water, and fog shaders. View source to learn from a real implementation.
Play FarmHeart