On this page

  1. Shader Injection via onBeforeCompile
  2. Caustic Shader & Water Refraction
  3. Volumetric God Rays
  4. Water Surface Animation
  5. Instanced Rendering Performance
  6. Marine Snow Particles
  7. IBL & Material Caching

Shader Injection via onBeforeCompile

Light has a language underwater. Caustic patterns flicker across the seafloor. The water surface ripples. Particles drift. We built this visual language using shader injection - a technique that layers effects onto Three.js's standard materials without rebuilding the renderer. This approach reuses the entire PBR pipeline (lighting, shadows, reflections) and only modifies the specific parts needed for water effects.

The system works in three stages: vertex shader modifications inject wave animation and caustic UVs, fragment shader modifications add caustic patterns and depth fading, and uniforms are passed dynamically each frame (time, camera position, etc.). The material is applied once at load time; thereafter only uniforms change, not the shader code itself.

Why onBeforeCompile? It avoids the cost of writing custom shader from scratch. Three.js's MeshStandardMaterial already handles normal mapping, roughness, metalness, IBL, and shadow mapping. By injecting code fragments at compile time, we layer effects on top without reimplementing the lighting model.

material.onBeforeCompile = function (shader) {
  // Inject custom uniforms
  shader.uniforms.uTime = { value: 0.0 };
  shader.uniforms.uCausticScale = { value: 1.5 };
  shader.uniforms.uCausticIntensity = { value: 0.09 };
  shader.uniforms.uCameraPos = { value: new Vector3() };

  // Inject vertex shader code before common includes
  shader.vertexShader = shader.vertexShader.replace(
    '#include <common>',
    '#include <common>\n' + vertexInjection
  );

  // Inject fragment shader after normal map decoding
  shader.fragmentShader = shader.fragmentShader.replace(
    '#include <normal_fragment_maps>',
    '#include <normal_fragment_maps>\n' + fragmentInjection
  );
};

This architecture makes it trivial to adjust effects in real time. Changing caustic intensity is just an assignment to material.uniforms.uCausticIntensity.value. Toggling effects on or off is a single boolean flag. The game even supports per-instance effect variation through instance attributes.

Caustic Shader & Water Refraction

Caustics are the dancing light patterns you see on the seafloor when looking up at the surface. In PolyFish, they're generated using dual-octave Voronoi domain warping - a procedural technique that mimics the refraction of light through an uneven water surface.

The algorithm works in two passes. First pass computes a Voronoi pattern at one scale and uses it to warp the coordinates for a second Voronoi lookup. This creates organic, interconnected cell shapes rather than a rigid grid. Second pass refines the pattern at half the scale, blending the two together for visual richness. The result scrolls smoothly over time.

Distance from surface matters enormously. Caustics are full intensity (1.0) at the seafloor, but fade linearly over 30 units of depth. This prevents the effect from overwhelming the image and keeps distant scenes readable. Additionally, caustics fade as they approach the camera - beyond 25 units, they're invisible. This prevents distracting shimmer on objects you're interacting with.

The base intensity is deliberately subtle: 0.09 (9% of maximum brightness). This feels natural - real caustics are ambient, not spotlight-like. The pattern scrolls at a speed tied to engine time, creating the illusion of flowing water overhead.

2
Voronoi octaves
30
Depth fade distance
25
Camera fade distance
0.09
Base intensity

The caustic shader includes a simple LOD system. At LOD 0 (close objects), the full dual-octave pattern is computed. At LOD 1 (distant objects), only the coarse first-octave Voronoi is evaluated, cutting shader complexity by half. The transition is invisible because both LODs fade with distance anyway.

A crucial visual achievement was applying caustics not just to the seafloor, but directly onto the creatures themselves. The underwater light patterns flow across fish and dolphin bodies, making them feel embedded in the lit water column rather than simply passing through it. This breakthrough transformed the scene from "characters swimming over terrain" to "characters swimming through light itself".

// Fragment shader caustic injection  -  Voronoi domain warping
float caustic(vec2 p) {
  // First Voronoi octave  -  used to warp coordinates
  vec2 wrapped = mod(p, 1.0);
  float dist = 1.0;
  for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
      vec2 cell = vec2(x, y);
      vec2 seed = mod(cell * 123.456, 1.0);
      seed = fract(seed * sin(seed * 45.164));
      dist = min(dist, distance(wrapped, fract(seed)));
    }
  }

  // Second octave at half scale  -  warped by first octave
  vec2 warped = p * 0.5 + vec2(sin(p.y), cos(p.x)) * dist;
  float fine = caustic(warped);

  // Blend octaves and add time animation
  return mix(dist, fine, 0.5);
}

Single-Octave Voronoi

Pure cellular pattern. Regular, geometric, underwater-y but visually repetitive. Lacks depth and organic variation.

Dual-Octave Domain Warping

Coarse octave warps the fine octave's coordinates, creating interconnected cells. Feels like light refracting through actual rippling water.

Volumetric God Rays

Caustics handle the light patterns on the seafloor, but the water column itself needed visible shafts of light - thick beams cutting through the water from the surface above. These "god rays" are one of those effects that immediately sell the underwater atmosphere. Getting them right took several iterations.

The Wrong Turns

The first attempt injected god ray calculations directly into CausticShader.js via onBeforeCompile - the same system used for floor caustics. Each material got its own god ray contribution baked into the fragment shader. This was architecturally clean but produced thin, web-like patterns that looked like caustic intersections rather than thick beams of light. The Voronoi F2-F1 distance (which highlights cell edges) was the wrong choice; it created a lacy mesh where we wanted broad columns.

The second attempt routed the entire scene through an intermediate render target so god rays could be composited in a post-process pass. This worked technically, but Three.js's color management pipeline (sRGB output space, HalfFloat buffers) meant the scene colors came out darker after the round trip - as if looking through sunglasses. Multiple attempts to fix the color space conversion only traded one artifact for another.

The Solution: Additive Overlay

The breakthrough was realizing that scene colors should never pass through any intermediate buffer at all. The final architecture uses a two-phase approach: a depth-only pre-pass captures the scene's depth buffer (discarding color entirely), then the scene renders normally to the screen, and finally a fullscreen quad renders only the god ray light contribution with THREE.AdditiveBlending. Scene colors are never touched - the god rays are purely additive light on top.

Screen-Space Raymarching

Each pixel fires a ray from the camera through the scene. Using the depth buffer, the shader knows how far to march before hitting geometry. It takes 16 steps along the ray, sampling a beam pattern at each point. Each sample accumulates light based on its depth below the water surface, with an exponential fade that makes beams brightest near the surface.

The beam pattern uses inverted Voronoi F1 - the opposite of the caustic approach. Where caustics use F2-F1 to highlight cell edges (thin bright lines), god rays use inverted F1 to highlight cell centers (broad bright regions). The result is thick, soft columns of light rather than a spiderweb pattern.

// Voronoi F1 inverted: cell CENTERS are bright = thick beams
float minDist = 1.0;
for (int y = -1; y <= 1; y++) {
  for (int x = -1; x <= 1; x++) {
    vec2 cellId = id + vec2(x, y);
    vec2 offset = hash(cellId);
    offset = 0.5 + 0.45 * sin(6.2831 * offset);
    minDist = min(minDist, length(diff));
  }
}
float beam = 1.0 - smoothstep(0.0, 0.9, minDist);
beam *= beam; // quadratic falloff for guaranteed soft edges

Making Beams Feel Natural

Naive Voronoi cells sit on a regular grid, which reads as "spotlights arranged in a circle." Two octaves of domain warping at different frequencies break this regularity, making beam placement feel random and organic. The cell centers themselves are static (no time dependency), so beam roots stay anchored at the surface while the domain warp provides slow drift.

Uniform beams look artificial. Several layers of variation fix this:

Per-Beam Depth Staggering

Each Voronoi cell gets a hashed depth reach value, cube-biased so most beams stop near the surface and only rare ones extend to the floor. This prevents the "wall of light" look.

Internal Breakup

A secondary value noise layer creates bright and dark patches within each beam, so they're not solid columns of uniform light. The breakup is sampled in the same tilted coordinate space as the beams so patches align naturally.

The beams tilt at a fixed angle - roughly a 4pm sun direction - rather than pointing straight down. This was achieved by shifting the XZ lookup coordinates based on depth: deeper samples offset further in the tilt direction. A very slow wobble (period of ~200 seconds) keeps the direction from feeling completely locked.

Solving the Ring Artifacts

Looking straight up at the beams initially produced ugly concentric ring artifacts. When the camera looks along the beam axis, consecutive raymarch steps sample nearly identical XZ positions, and the 16 discrete steps create visible banding. Two techniques solve this: a view-angle fade that smoothly reduces beam intensity when looking along the beam axis (since beams are most visible from the side anyway), and per-pixel interleaved gradient noise dithering that offsets each pixel's ray start by a random fraction of one step, breaking up coherent banding patterns.

Where beams overlap, additive blending can create harsh brightness spikes. An exponential soft-clamp (1.0 - exp(-accum * 1.8)) compresses the high end so overlapping beams blend smoothly rather than creating hard edges.

16
Raymarch steps
2
Domain warp octaves
F1
Voronoi mode (inverted)
0
Extra render targets for color

Water Surface Animation

The water surface itself (the "sea ceiling" mesh far above the camera) uses a vertex shader animation that runs entirely on the GPU. Three overlapping sine waves at different angles and speeds create the illusion of wind-driven waves. The waves are tightly clustered near the surface (high frequency) and invisible to distant cameras.

The wave formula combines macro swells (large, slow waves) with micro chop (small, fast ripples at 18% of the macro amplitude). This two-tier system is common in ocean simulation - large swells are caused by distant weather, while small chop responds to local wind. Both are purely procedural; no texture lookup or geometry deformation is needed beyond the sine calculation.

The animation is driven by vertex position XZ coordinates and engine time, so movement is smooth and continuous regardless of player motion. Every vertex is computed independently, making it trivially parallelizable - the GPU evaluates 10,000+ vertices per frame with zero CPU overhead.

// Vertex shader  -  GPU-driven water surface animation
float wave(vec3 p, float speed, float scale, float phase) {
  return sin((p.x + p.z) / scale + uTime * speed + phase) * 0.5;
}

// Macro swell (slow, large amplitude)
float macro = wave(p, 0.3, 20.0, 0.0)
            + wave(p, 0.25, 18.0, 1.5)
            + wave(p, 0.35, 22.0, -1.0);

// Micro chop (fast, small amplitude = 18% of macro)
float micro = (wave(p, 2.0, 2.5, 0.2)
             + wave(p, 1.8, 3.0, -0.5)) * 0.18;

// Final displacement
float displacement = (macro + micro) * uWaveAmplitude;
vPosition.y += displacement;

Instanced Rendering Performance

With 60 fish, 15 dolphins, and 10 manatees on screen, drawing each creature as a separate mesh would mean 85 draw calls. Instead, PolyFish uses InstancedMesh to pack identical creatures into single draw calls. Fish share one InstancedMesh (60 instances, 1 draw call), dolphins share another (15 instances, 1 draw call), and manatees a third (10 instances, 1 draw call). Total: 3 draw calls for all creatures.

Each creature instance has two custom attributes: aSwimPhase (phase offset in the swim cycle) and aSwimAmplitude (how far the creature oscillates). These are packed into vertex attribute arrays. The vertex shader reads them and modulates the swim animation per-instance. Dead creatures are marked by setting their amplitude to 0, making them rigid and still.

The transformation matrices (position, rotation, scale) are stored directly in the InstancedMesh's instanceMatrix buffer. Every frame, creature positions are updated by writing new transforms into this buffer and setting the WebGL dirty flag. The GPU then fetches the appropriate matrix for each vertex based on gl_InstanceID, completely eliminating transform overhead.

85
Without instancing
3
With instancing
97%
Draw call reduction

See the interactive instancing comparison on the Performance page.

// Initialize 60-instance mesh for fish
const fishInstancedMesh = new InstancedMesh(
  fishGeometry,
  fishMaterial,
  60 // 60 instances, 1 draw call
);

// Set up custom vertex attributes
const aSwimPhase = new BufferAttribute(
  new Float32Array(60),
  1
);
const aSwimAmplitude = new BufferAttribute(
  new Float32Array(60),
  1
);
fishInstancedMesh.geometry.setAttribute('aSwimPhase', aSwimPhase);
fishInstancedMesh.geometry.setAttribute('aSwimAmplitude', aSwimAmplitude);

// Every frame: update instance transforms and attribute data
for (let i = 0; i < fishCount; i++) {
  fishInstancedMesh.setMatrixAt(i, fishCreatures[i].matrix);
  aSwimPhase.array[i] = fishCreatures[i].swimPhase;
  aSwimAmplitude.array[i] = fishCreatures[i].alive ? 1.0 : 0.0;
}
aSwimPhase.needsUpdate = true;
aSwimAmplitude.needsUpdate = true;
fishInstancedMesh.instanceMatrix.needsUpdate = true;

A crucial optimization: the game only updates transforms for creatures that moved or rotated. Static creatures skip the buffer write entirely. This reduces bandwidth to the GPU by ~40% on average scenes. Combined with the 97% reduction in draw calls, instancing is the single biggest win in the rendering pipeline.

Marine Snow Particles

Marine snow - the drift of organic particles constantly falling through the water - is rendered using 500 particles on desktop (150 on mobile). Each particle is a small triangle sprite (64×64 pixels) that rotates slowly. Particles drift downward at 0.3–0.6 units per second with gentle lateral sway, creating the impression of passive settling.

Instead of using a standard PointsMaterial, the system employs a custom shader that supports per-particle rotation. Each particle stores a rotation angle in a vertex attribute. The fragment shader renders a pre-made grayscale triangle texture, rotated by that angle. This avoids the harsh circular look of traditional point sprites and feels more organic.

Particles that fall below the camera trigger a soft reset - they're repositioned above the view with a randomized lateral offset. This creates an infinite, seamless fall without pool exhaustion. The particle system is CPU-driven (position updates in JavaScript) because the motion is pseudo-random and data-driven. However, the rotation and rendering is pure GPU.

// Marine snow particle system initialization
const particleCount = 500; // 150 on mobile
const positions = new Float32Array(particleCount * 3);
const rotations = new Float32Array(particleCount);
const velocities = new Float32Array(particleCount * 3);

// Initialize particles with random spread
for (let i = 0; i < particleCount; i++) {
  positions[i*3] = (Math.random() - 0.5) * 100; // X spread
  positions[i*3+1] = Math.random() * 100; // Y (depth)
  positions[i*3+2] = (Math.random() - 0.5) * 100; // Z spread

  rotations[i] = Math.random() * Math.PI * 2;
  velocities[i*3+1] = -(0.3 + Math.random() * 0.3); // Downward
}

// Each frame: update positions and rotations
for (let i = 0; i < particleCount; i++) {
  // Apply velocity
  positions[i*3] += velocities[i*3] * dt;
  positions[i*3+1] += velocities[i*3+1] * dt;
  positions[i*3+2] += velocities[i*3+2] * dt;

  // Lateral sway
  velocities[i*3] += Math.sin(time + i) * 0.02;
  velocities[i*3+2] += Math.cos(time * 0.5 + i) * 0.02;

  // Increment rotation
  rotations[i] += 0.02;

  // Reset if below camera
  if (positions[i*3+1] < cameraPos.y - 50) {
    positions[i*3+1] = cameraPos.y + 50;
  }
}
positionAttribute.needsUpdate = true;
rotationAttribute.needsUpdate = true;

Why soft particles? The rotation shader allows particles to fade smoothly at depth edges without needing additive blending or depth testing. This prevents the harsh "popping" of traditional particle systems at screen edges.

IBL & Material Caching

All creatures are shaded with vertex-colored MeshStandardMaterial. Colors are baked directly into the mesh geometry, avoiding texture overhead. This is perfect for a low-poly art style: solid colors for each body part (head, fins, belly) feel intentional rather than unfinished.

Material parameters are fixed across all creatures: roughness 0.85 (matte, non-reflective), metalness 0.05 (barely metallic), and flatShading enabled for the crisp faceted look. The combination is critical. Metalness at exactly 0.05 prevents creatures from looking like polished plastic (too shiny) while preserving subtle specular highlights. The roughness at 0.85 ensures soft shadows and diffuse interactions with image-based lighting.

PolyFish pre-caches all materials in a single lookup table keyed by a hash of the parameters. Since most creatures share identical material properties (a rare game design choice!), the hash typically produces one or two cache hits, avoiding redundant Three.js Material objects. The cache persists across scenes, so loading a new area reuses existing materials instead of allocating new ones.

0.85
Roughness
0.05
Metalness
1
Typical cache hits
// Material cache keyed by parameter hash
const materialCache = new Map();

function getMaterial(color, roughness, metalness, vertexColors) {
  const key = hashParams(color, roughness, metalness, vertexColors);

  if (materialCache.has(key)) {
    return materialCache.get(key);
  }

  const material = new MeshStandardMaterial({
    color: color,
    roughness: roughness,
    metalness: metalness,
    vertexColors: vertexColors,
    flatShading: true
  });

  // Inject caustic effects via onBeforeCompile
  material.onBeforeCompile = setupCausticShader;

  materialCache.set(key, material);
  return material;
}

Image-based lighting (IBL) is baked into the scene via Three.js PMREMGenerator. The high dynamic range environment map is pre-convolved offline, producing a specular cubemap (roughness-dependent) and an irradiance map (diffuse). This gives creatures rich, contextual shading without any lights in the scene - the "sky" itself provides lighting. Underwater, the HDR map shows a blue-tinted sky with caustic patterns, creating the sense of depth.