🌾 FarmHeart
← Back to Blog

Shader Effects for Browser Games: A Practical GLSL Guide

October 6, 2026 · FarmHeart · 11 min read

What Shaders Do

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.

The WebGL Shader Pipeline

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: `...`
});

Practical Shader Effects

Water Ripple

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.

Day/Night Cycle

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 / Glow

Bloom makes bright areas bleed light into surrounding pixels. The typical approach:

  1. Render the scene to a framebuffer
  2. Extract bright pixels (threshold pass)
  3. Blur the bright pixels (Gaussian blur, 2 passes: horizontal + vertical)
  4. Composite the blurred result back onto the scene
// 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);
}
Performance tip: Blur at half resolution. Render bloom to a framebuffer that’s 50% the size of the screen. The quality difference is barely visible, but it runs 4× faster.

Pixelation

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.

Fog

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);
}

Color Grading

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: Connecting JavaScript to Shaders

Uniforms are values you pass from JavaScript into your shader every frame. Common game uniforms:

UniformTypeUsage
uTimefloatElapsed seconds — drives all animation
uResolutionvec2Canvas width/height in pixels
uMousevec2Normalized cursor position (0–1)
uTexturesampler2DThe scene or a sprite texture
uTimeOfDayfloat0–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);
}

Debugging Shaders

GLSL has no console.log. When a shader breaks, you get a black screen or nothing. Debugging strategies:

Performance Guidelines

See Shaders in Action

FarmHeart uses day/night, water, and fog shaders. View source to learn from a real implementation.

Play FarmHeart