Building Ocean Sparkles from First Principles

Raymarching, halftone post-processing, and procedural sparkle generation in a single HTML file.

Mar 2026

There's a particular quality to sunlight on water — the way it fractures into a thousand tiny stars that appear and vanish in rhythm with the waves. It's one of those things that feels impossible to capture digitally. This tutorial walks through building exactly that effect: a raymarched ocean, dreamy halftone post-processing, and procedural sparkle generation, all in a single HTML file with Three.js.

The final result feels like a risograph print of an ocean caught mid-shimmer. We'll build it layer by layer, from first principles — starting with the mathematics of noise, working through the physics of light on water, and ending with the print-shop techniques that give it all a tangible, physical quality.

Noise & fBm Raymarching Kawase Blur Riso Halftone Sparkle Mask Composite

0. Foundations: Noise

Before we can build an ocean, we need a way to generate organic randomness. Not Math.random() — that's white noise, uncorrelated from pixel to pixel. We need coherent noise: smooth, continuous randomness where nearby points have similar values.

Value noise

The simplest coherent noise is value noise. The idea: assign random values to integer grid points, then smoothly interpolate between them for fractional positions.

GLSLfloat noise(vec2 p) {
  vec2 i = floor(p);           // integer part (grid cell)
  vec2 f = fract(p);           // fractional part (position within cell)
  vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep interpolation

  // Sample random values at 4 corners of the cell
  float a = hash(i + vec2(0.0, 0.0));
  float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0));
  float d = hash(i + vec2(1.0, 1.0));

  // Bilinear interpolation with smoothstep
  return a + (b - a) * u.x
           + (c - a) * u.y
           + (a - b - c + d) * u.x * u.y;
}

The smoothstep interpolation (3t² - 2t³) is critical. Linear interpolation would produce visible grid artifacts. Smoothstep ensures zero derivative at grid points — the noise "stops" briefly at each integer coordinate, producing a rounder, more organic feel.

smoothstep(t) = 3t² − 2t³
smoothstep(0) = 0,   smoothstep(1) = 1,   smoothstep'(0) = smoothstep'(1) = 0
In our implementation we use a 256×256 noise texture rather than a hash function. Each texel holds a random value in [0, 1]. Sampling with nearest-neighbor filtering + manual smoothstep gives us identical results but offloads the random number generation to texture hardware.

Fractal Brownian motion (fBm)

A single layer of value noise looks blobby and synthetic. Real water has detail at every scale — large swells, medium waves, small ripples, tiny capillary patterns. We achieve this by layering octaves of noise at increasing frequencies and decreasing amplitudes:

fBm(p) = Σᵢ noise(p · fⁱ) · aⁱ

where f = frequency multiplier (lacunarity), a = amplitude multiplier (persistence)

Each successive octave is called a "layer" because it adds a layer of detail. With f = 1.75 and a = 0.5, the first octave creates broad swells, the second adds medium waves, and by octave 5+ we're painting individual ripple patterns:

GLSLfloat fbm(vec3 p, int octaves) {
  float result = 0.0;
  float freq   = 1.0;
  float amp    = 1.0;

  for (int i = 0; i < 9; i++) {
    if (i >= octaves) break;

    // Each octave moves at different speed (big waves = slow)
    vec2 moving = p.xz + uTime * 0.06 * float(9 - i + 1);
    result += noise(freq * moving) * amp;

    freq *= 1.75;  // lacunarity: each octave 1.75× higher frequency
    amp  *= 0.5;   // persistence: each octave half the amplitude

    p.xz *= rotationMatrix; // rotate 180° to break axis alignment
  }
  return result;
}

Two crucial details: (1) each octave gets a different time offset — higher octaves (smaller features) move faster than lower ones, creating realistic wave dynamics where ripples dance atop slow swells. (2) Each octave is rotated by the same matrix (here, π radians) to prevent the grid alignment of the noise from showing through as directional patterns.

Try it: adjust the sliders below. Watch how adding octaves introduces progressively finer detail, and how lacunarity (freq ×) controls the frequency jump between layers.
Interactive fBm
Interactive fBm — 1 octave, 2 octaves, all octaves. Adjust parameters below the images.

The height function

Our ocean surface is defined as a heightfield — for any (x, z) position on the water plane, we compute a height y = h(x, z). This is simply our fBm noise scaled by a wave amplitude:

GLSLfloat oceanHeight(vec2 xz, int octaves) {
  float h = 2.7 * fbm(vec3(xz.x, 0.0, xz.y) * 0.08, octaves);

  // Fade to flat at distance (avoids aliasing at horizon)
  float dist = length(xz);
  float fade = 1.0 - smoothstep(280.0, 400.0, dist);
  return mix(-100.0, h, fade);
}

The 2.7 is the wave height — the vertical extent of our tallest waves. The 0.08 is the wave scale — how compressed the noise is in world space. Together they control whether we get a gentle lake (height=0.5, scale=0.03) or a stormy sea (height=8.0, scale=0.15).

Try changing waveHeight from 2.7 to 0.5 in the ocean shader for a glassy lake, or to 8.0 for rough seas. The sparkle distribution changes dramatically — calmer water produces broader, more diffuse highlights.

1. The Ocean

Raymarching

We need to find where each camera ray intersects the ocean surface. Unlike flat geometry where we can compute this analytically, our procedural heightfield requires raymarching — stepping along the ray until we detect a crossing.

The algorithm: start at the camera, advance along the ray direction. At each step, check if we've gone below the ocean surface. If yes, we've crossed the surface — refine with binary search. If no, keep stepping. The step size is proportional to the current distance above the surface (this is the "sphere tracing" insight — we can safely step by the distance to the nearest surface without missing it):

GLSLfloat march(vec3 origin, vec3 dir, out float hitDist) {
  if (dir.y >= 0.0) return 0.0; // ray going up = no water hit

  float t = 0.0;
  float prevStep = 0.0;

  for (int i = 0; i < 24; i++) {
    vec3 p = origin + dir * t;
    float dist = p.y - oceanHeight(p.xz, 3); // coarse octaves for speed

    if (dist < 0.01) {
      // Crossed surface! Refine with binary search
      hitDist = binaryRefine(origin, dir, t - prevStep, t);
      return 1.0; // hit
    }

    prevStep = max(dist * 0.5, 0.1 + t * 0.01);
    t += prevStep;

    if (t > 1200.0) break;
  }
  return 0.0; // miss
}

Note the step size: max(dist * 0.5, 0.1 + t * 0.01). The dist * 0.5 means we take conservative half-steps (full steps might overshoot waves). The minimum step grows with distance (0.1 + t * 0.01) to prevent spending all our iterations on distant detail we can't see anyway.

We use only 3 octaves for the marching (coarse shape) but switch to 7 octaves for the normal calculation (fine detail), because normals are what the eye actually perceives.

Click anywhere to cast a ray. Red = march steps, green = hit
Click anywhere to cast a ray. Red = march steps, green = hit. Step size adapts to distance from surface.

Surface normals

Once we hit the surface, we need to know which direction it faces — the normal vector. For an analytical surface we'd compute the gradient; for our procedural heightfield, we use finite differences. Sample the height at 4 nearby points, forming a cross pattern:

n = normalize(h(x−ε, z) − h(x+ε, z),   2ε,   h(x, z−ε) − h(x, z+ε))
GLSLvec3 getNormal(vec3 p, float hitDist) {
  // Epsilon grows with distance (less detail needed far away)
  float e = (0.05 / uResolution.y) * pow(hitDist, 1.55);

  return normalize(vec3(
    oceanHeight(p.xz - vec2(e, 0), 7) - oceanHeight(p.xz + vec2(e, 0), 7),
    2.0 * e,
    oceanHeight(p.xz - vec2(0, e), 7) - oceanHeight(p.xz + vec2(0, e), 7)
  ));
}
The epsilon scaling (hitDist^1.55) is a LOD trick — distant water gets less normal detail, which both saves performance and reduces shimmer aliasing. Try changing the exponent: 1.0 gives uniform detail, 2.0 makes distant water completely smooth.

The Fresnel effect

Water is more reflective at shallow viewing angles. Looking straight down, you see through it. Looking at the horizon, it's a mirror. This is the Fresnel effect, and it's the single most important thing for making water look like water.

The full Fresnel equations involve the refractive indices of both media, but for real-time rendering we use the Schlick approximation:

F(θ) = F₀ + (1 − F₀)(1 − cos θ)⁵

Where F₀ is the reflectance at normal incidence (looking straight at the surface) and θ is the angle between the view direction and the surface normal. For water, F₀ ≈ 0.02 — at normal incidence, only 2% of light is reflected. But at grazing angles, it approaches 100%.

GLSLfloat cosTheta = max(dot(normal, viewDir), 0.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - cosTheta, 5.0);

// Blend between water color and sky reflection
vec3 reflected = sky(reflect(-viewDir, normal));
vec3 waterColor = ambient + diffuse + subsurface;
vec3 finalColor = mix(waterColor, reflected, fresnel);
Hover over either panel to change the viewing angle. At graz
Hover over either panel to change the viewing angle. At grazing angles, nearly all light is reflected.

Subsurface scatter

When sunlight hits thin wave crests from behind, some light passes through the water and scatters out toward the camera. This creates the glowing turquoise effect you see when waves are backlit:

GLSL// Back-scatter: light passing through wave crests
vec3 backDir = normalize(-sunDir + normal * 0.3);
float scatter = pow(max(dot(viewDir, backDir), 0.0), 4.0) * 1.1 * crestFactor;
vec3 subsurface = vec3(0.49, 0.86, 0.79) * scatter;

The crestFactor is derived from the wave height — only the tops of waves (thin crests) allow light through. The color (0.49, 0.86, 0.79) is the characteristic turquoise of shallow water scatter.

Specular highlights

The sun's direct reflection uses the Blinn-Phong model with a very high exponent (256) for a tight, bright highlight:

h = normalize(sunDir + viewDir)
specular = (n · h)²⁵⁶ × intensity
GLSLvec3 halfVec = normalize(sunDir + viewDir);
float spec = pow(max(dot(normal, halfVec), 0.0), 256.0);
vec3 specular = vec3(1.0, 0.9, 0.8) * spec * 3.0;
The exponent 256 is unusually high — normal water might use 64–128. We want tight pinpoints because the sparkle pass later will bloom them out. Think of this as providing "seeds" for the sparkle generation.

Tonemapping

The specular highlights can reach values of 3.0+ (HDR), but our display only goes to 1.0. ACES tonemapping compresses the range while preserving color relationships:

ACES(x) = (x(2.51x + 0.03)) / (x(2.43x + 0.59) + 0.14)

This S-curve gently rolls off highlights while keeping shadows dark and midtones relatively untouched. After tonemapping, we apply gamma correction (x^(1/2.2)) for display.

Step 1
Step 1 — The complete ocean: raymarching + Fresnel + subsurface scatter + specular + ACES tonemap

2. The Blur

The raw ocean is too detailed for our halftone effect. We need to soften it into dreamy, cloud-like tonal patches. For this we use a Kawase blur — a multi-pass technique that achieves large blur radii cheaply.

Why not Gaussian?

A Gaussian blur of radius r requires sampling (2r+1)² texels per pixel. For our ~100px blur, that's 40,000+ texture reads per pixel — far too expensive.

The Kawase blur takes a different approach: each pass samples just 4 texels (the corners of a square), then feeds its output to the next pass. By increasing the square size each pass, a few iterations achieve what would require enormous kernels:

GLSL// Each Kawase pass: average 4 corner samples
vec2 uv = gl_FragCoord.xy * texelSize;
float k = kernel + 0.5; // offset by half for bilinear filtering
vec2 offset = texelSize * k;

vec4 sum = texture2D(input, uv + vec2(-offset.x, -offset.y))
         + texture2D(input, uv + vec2( offset.x, -offset.y))
         + texture2D(input, uv + vec2(-offset.x,  offset.y))
         + texture2D(input, uv + vec2( offset.x,  offset.y));

gl_FragColor = sum * 0.25;

The + 0.5 offset is a nice trick: it lands each sample between 4 texels, and since the GPU's bilinear filtering averages those 4, each of our "4 samples" actually reads 16 texels. So 4 texture reads × 4 bilinear = 16 effective samples per pass.

Kernel progression

We chain 6 passes with kernel values [0, 1, 2, 4, 8, 12]. The effective radius of each pass is (kernel + 0.5) texels. Because each pass reads from the previous pass's output, the blur compounds:

Pass 1: radius = 0.5 texels (local smoothing)
Pass 2: radius = 1.5 texels (applied to already-blurred image)
Pass 3: radius = 2.5 texels
Pass 4: radius = 4.5 texels
Pass 5: radius = 8.5 texels
Pass 6: radius = 12.5 texels

Effective total radius ≈ sum of all ≈ 30 texels, but appears much larger
because each pass operates on already-smoothed data

This "ping-pong" technique alternates between two render targets — pass 1 writes to buffer A, pass 2 reads A and writes B, pass 3 reads B and writes A, and so on:

JavaScriptconst KERNELS = [0, 1, 2, 4, 8, 12];

function runBlur(sourceTexture) {
  for (let i = 0; i < KERNELS.length; i++) {
    const dst = (i % 2 === 0) ? bufferA : bufferB;
    const src = (i === 0) ? sourceTexture
               : ((i % 2 === 0) ? bufferB : bufferA).texture;

    blurUniforms.inputBuffer.value = src;
    blurUniforms.kernel.value = KERNELS[i];

    renderer.setRenderTarget(dst);
    renderer.render(blurScene, camera);
  }
}
A critical bug I hit: the uniform was vec4(1/W, 1/H, W, H) but the shader used .zw (width, height) instead of .xy (1/width, 1/height) for the UV calculation. This meant every pixel sampled the texture at huge coordinates — which wraps to the edge pixel. The result looked like a uniform pale blue that seemed right. It took hours to find. Always check your texelSize components.
Step 2
Step 2 — 6-pass Kawase blur. The ocean is now smooth tonal gradients, perfect for halftoning.
Try changing the kernel array to [0, 1] for a subtle blur, or [0, 1, 2, 4, 8, 12, 16, 20] for an extreme dreamy look. The halftone result in the next step depends entirely on how smooth this base is.

3. The Halftone

This is where the ocean transforms from a photograph into a print. We're simulating risograph printing — a technique where ink is forced through a stencil to create a pattern of dots. Dark areas get dense dots, light areas get sparse dots, and the eye blends them into continuous tone.

From luminance to dots

The core of halftoning: convert each pixel's brightness into a binary decision (dot or no dot). The simplest approach — threshold at 0.5 — produces ugly banding. We need dithering: varying the threshold per pixel so the transition from light to dark appears gradual.

dot(pixel) = step(threshold[pixel], 1 − luminance)

If luminance is low (dark) → 1−luminance is high → more likely to exceed threshold → dot ON
If luminance is high (bright) → 1−luminance is low → less likely to exceed threshold → dot OFF

Blue noise dithering

The choice of threshold pattern is everything. Three options:

Blue noise is the gold standard for halftoning because the human visual system is insensitive to high-frequency noise but very sensitive to low-frequency patterns. Blue noise only contains high frequencies — it looks uniformly random despite being carefully structured.

Same coverage, three different threshold patterns. Blue nois
Same coverage, three different threshold patterns. Blue noise (right) looks the most "even" and printlike.

We store the blue noise as a lookup table — a 32×32 array of 1024 threshold values. Each pixel indexes into this table based on its screen coordinate:

GLSLfloat dotCoverage(vec2 pixel, float luma) {
  // Remap luminance: compress dark range, expand light range
  luma = pow(min(luma / 0.8, 1.0), 2.0);

  // Index into blue noise LUT (tiled 32×32)
  vec2 cell = floor(pixel);
  int idx = int(mod(cell.y, 32.0)) * 32
           + int(mod(cell.x, 32.0));
  float threshold = blueNoiseLUT[idx];

  return step(threshold, 1.0 - luma);
}
The luma remapping (÷0.8, then square) is doing important work. Without it, mid-tones would be too dense with dots. The squaring pushes coverage toward dark areas: a luminance of 0.5 becomes (0.5/0.8)² = 0.39 — fewer dots than you'd expect, keeping the overall image bright and airy.

Poisson disc blur for organic variation

If we fed the clean Kawase blur directly to the halftone, the dot density would vary smoothly and predictably — it would look computer-generated. For an organic, hand-printed feel, we compute a separate Poisson disc blur with intentional noise.

A Poisson disc is a set of points distributed with a minimum distance guarantee — no two points closer than a threshold. We use 16 pre-computed sample points scattered within a unit circle:

GLSLconst vec2 disc[16] = vec2[16](
  vec2(-0.1252, 0.0698), vec2( 0.2079, -0.1567),
  vec2(-0.0583, 0.2918), vec2(-0.3108, -0.0927),
  vec2( 0.3781, 0.1473), vec2(-0.2104, -0.3562),
  vec2( 0.0415, 0.4691), vec2( 0.4287, -0.2835),
  vec2(-0.4953, 0.1812), vec2( 0.2346,  0.5108),
  vec2(-0.5621, -0.2249), vec2( 0.5894,  0.0637),
  vec2(-0.3467, 0.5583), vec2( 0.1178, -0.6415),
  vec2( 0.6752, -0.3091), vec2(-0.6138,  0.4826)
);

The magic: each pixel gets a random rotation angle. The same 16 sample points are rotated differently at every pixel, breaking up any pattern:

GLSLvec3 blurScene(sampler2D scene, vec2 center) {
  float angle = hash(gl_FragCoord.xy) * TAU;
  mat2 rot = mat2(cos(angle), sin(angle),
                  -sin(angle), cos(angle));

  vec3 sum = vec3(0.0);
  float totalWeight = 0.0;

  for (int i = 0; i < 16; i++) {
    vec2 sampleUV = center + rot * disc[i] * 100.0 * texelSize;
    vec4 samp = texture2D(scene, sampleUV);

    // Tint bright samples toward highlight color
    float luma = dot(samp.rgb, vec3(.299, .587, .114));
    if (luma > 0.4) {
      vec3 tinted = highlightColor * samp.rgb;
      samp.rgb = mix(samp.rgb, tinted, smoothstep(0.4, 1.0, luma) * 0.89);
    }

    // Gaussian weight based on sample distance
    float d = length(disc[i]);
    float w = exp(-d * d / 0.32);
    sum += samp.rgb * w;
    totalWeight += w;
  }

  vec3 result = sum / totalWeight;
  // Lift dark areas toward highlight for dreamy pastel look
  result = mix(result, highlightColor * 1.25, 0.55);
  return result;
}

The highlight tinting is crucial. When a sample lands on a specular highlight (luma > 0.4), we multiply its color by the highlight color #c8dbe0 at 89% strength. This prevents harsh whites from washing out the halftone, pulling them into the overall cool palette.

The final mix with highlightColor×1.25 at 55% lifts all dark values toward a bright pastel — this is what creates the dreamy, washed-out quality.

Blend modes: the math

We composite the halftone dots onto the blurred base using hard light blending. Each blend mode is a simple per-channel formula:

Multiply:   out = base × blend
Screen:   out = 1 − (1 − base)(1 − blend)
Hard Light:   out = blend < 0.5 ? 2·base·blend : 1 − 2(1−base)(1−blend)
Overlay:   out = base < 0.5 ? 2·base·blend : 1 − 2(1−base)(1−blend)

Hard Light is essentially Overlay with base and blend swapped. It's aggressive: values below 0.5 darken (multiply), values above 0.5 lighten (screen). This is perfect for halftone dots — dark dots darken the base, while white paper areas leave it untouched:

GLSLvec3 hardLight(vec3 base, vec3 blend) {
  return mix(
    2.0 * base * blend,               // dark blend → multiply
    1.0 - 2.0 * (1.0 - base) * (1.0 - blend), // light blend → screen
    step(0.5, blend)
  );
}
Same gradient + halftone pattern, five different blend modes
Same gradient + halftone pattern, five different blend modes. Hard Light (center) gives the most printlike result.

The final halftone composite

We mix the hard-light blended result with the original smooth blur at a ratio controlled by blurAmount (0.7 = 70% smooth, 30% halftone):

GLSL// Compute halftone dot coverage
float coverage = dotCoverage(gl_FragCoord.xy, poissonBlurLuma);

// Create single-color halftone pattern
vec3 halftone = mix(vec3(1.0), risoColor, coverage); // white paper + teal ink

// Hard light blend
vec3 blended = hardLight(toSrgb(blur), toSrgb(halftone));

// Mix halftone result with smooth blur
vec3 composited = mix(blended, toSrgb(blur), 0.7);
Try changing blurAmount from 0.7 to 0.0 for 100% halftone (heavy stippled look) or 1.0 for 100% smooth blur (no dots). The sweet spot for a riso-print feel is usually 0.6–0.8.

Organic borders

The edges of the frame don't just clip — they fade organically, as if the ink didn't quite reach the paper's edge. This uses a signed distance field (SDF) for a rounded rectangle, modulated by noise:

GLSL// Distance to rounded rectangle edge
vec2 edgeDist = abs(pixel - center) - halfSize + cornerRadius;
float sdf = length(max(edgeDist, 0.0))
          + min(max(edgeDist.x, edgeDist.y), 0.0) - cornerRadius;

// Add noise to border for organic, hand-torn feel
float angle = atan(pixel.y - center.y, pixel.x - center.x);
float t = angle / TAU;  // normalize angle to [0, 1]
sdf += (0.65 * noise(t * 12.0) + 0.35 * noise(t * 23.0) - 0.45) * 4.0;

// Dots fade out first, then the blur fades out
float blurFactor = 0.75 + 0.5 * noise(t * 4.0 + 3.1);
dotFadeOut = 1.0 - smoothstep(0.0, 15.0 * blurFactor, -sdf);
blurFadeOut = smoothstep(12.0 * blurFactor - 15.0 * blurFactor, 12.0 * blurFactor, -sdf);
The dual-noise border shape (12 + 23 frequency) creates medium lumps plus fine jitter. Different corners get different radii (via hash), so no two edges look the same. The blur factor noise at frequency 4 creates slowly undulating fade widths along the border.
Step 3
Step 3 — Full halftone: blue noise dithering, Poisson disc blur, hard light blend, organic borders.

4. The Sparkles

The halftone ocean is serene but static. The sparkles bring it to life — they're the sun's reflection fracturing across wave facets, the moment of diamond-bright light that makes water mesmerizing. Each sparkle is generated procedurally in a fragment shader, sampling the raw ocean render to know where highlights are brightest.

Cell-based spawning

Divide the screen into a grid of 20 × 20 px cells. Each cell is a potential sparkle location. For each cell we compute:

  1. A hash-based seed from the cell coordinate → determines sparkle lifetime, species, and offset within the cell
  2. The ocean luminance at the cell center → determines whether a sparkle appears at all
  3. A spawn probability derived from luminance → brighter areas spawn more sparkles
GLSL// For each cell in the sparkle grid...
vec2 cellCoord = floor(pixel / 20.0);
float seed = hash(cellCoord);

// Sample raw ocean luminance at cell center
vec2 cellCenter = (cellCoord + 0.5) * 20.0 / resolution;
vec4 ocean = texture2D(oceanBuffer, cellCenter);
float luma = dot(ocean.rgb, vec3(.299, .587, .114));

// Spawn probability: only bright areas get sparkles
float threshold = pow(smoothstep(0.7, 1.0, luma), 2.0) * 0.4;
if (seed >= threshold) continue; // no sparkle in this cell

The smoothstep(0.7, 1.0, luma) means only pixels with luminance > 0.7 have any chance of spawning a sparkle, and the probability ramps up quadratically toward luma=1.0. The ×0.4 caps maximum probability — even the brightest spots only spawn sparkles 40% of the time.

Five sparkle species

Not all sparkles are identical. The system randomly selects from 5 species, each with distinct geometry:

The 5 sparkle species + custom (right). Sliders control the custom shape
The 5 sparkle species + custom (right). Sliders control the custom shape — adjust points, inner radius, streak length.

Each sparkle is rendered as a combination of three components:

Core

A bright central dot with smooth falloff. The intensity decreases quadratically from the center:

core = max(0, 1 − d/innerRadius)²

Streaks

Thin rays extending from the center. For each of N points, we check if the current pixel lies along a ray direction:

GLSLfor (int p = 0; p < points; p++) {
  float streakAngle = float(p) * TAU / float(points);
  float deltaAngle = abs(atan(dy, dx) - streakAngle);

  // How far along the streak direction?
  float along = cos(deltaAngle) * dist;
  // How far perpendicular to streak?
  float perp = abs(sin(deltaAngle) * dist);

  if (along > 0.0 && along < streakLength && perp < streakWidth) {
    float falloff = 1.0 - along / streakLength;
    float sharpness = 1.0 - perp / streakWidth;
    streak = max(streak, falloff * sharpness);
  }
}

Glow

A soft halo surrounding the sparkle, with a wider radius and gentler falloff:

glow = max(0, 1 − d/glowRadius)² × 0.5

Temporal animation

Each sparkle fades in and out on its own timeline. The lifecycle is driven by a hash-based time offset:

GLSL// Per-cell timing
float cycleDuration = 2.0 + seed * 3.0;       // 2-5 second cycle
float phase = fract((uTime + seed * 100.0) / cycleDuration);

// Fade in for 20%, hold for 40%, fade out for 40%
float fadeIn  = smoothstep(0.0, 0.2, phase);
float fadeOut = smoothstep(1.0, 0.6, phase);
float visibility = fadeIn * fadeOut;

Persistence with ping-pong buffers

Sparkles don't just pop in and out — they bloom and trail. This is achieved by maintaining two framebuffers and feeding each frame's sparkle mask back into the next, multiplied by a fade factor of 0.82:

GLSL// Read previous frame, apply temporal fade
vec4 prev = texture2D(prevBuffer, uv) * 0.82;
vec4 current = computeSparkle(pixel);

// Keep the brighter of previous and current
result = max(prev, current);

The fade factor controls persistence: 0.82 means each frame retains 82% of the previous brightness. A sparkle at full brightness takes about 10 frames to fade below visibility (0.82¹⁰ ≈ 0.14). At 60fps that's about 170ms of trail — just enough to feel organic.

Try changing the fade factor: 0.5 for quick, flashy sparkles; 0.95 for long, dreamy trails that smear across the image. The original uses 0.82, which feels like a natural glint.

Perspective scaling

Water sparkles in real life are smaller near the horizon and larger close to you. We scale the sparkle radius based on vertical screen position:

GLSLfloat yFrac = pixel.y / resolution.y; // 0 = bottom, 1 = top
float yScale = smoothstep(0.7, 0.15, yFrac);

// yFrac < 0.15 → yScale = 1.0 (full size, bottom of screen, close water)
// yFrac > 0.70 → yScale = 0.0 (no sparkles, sky region)
// Between → smooth interpolation

radius *= yScale;
The smoothstep goes from 0.7 to 0.15 (reversed!) because yFrac increases upward but we want size to increase downward. This is equivalent to smoothstep(0.15, 0.7, 1.0 - yFrac). The cutoff at 0.7 ensures no sparkles appear in the sky.

5. The Composite

The final pass combines the halftone ocean with the sparkle mask. Each sparkle component gets its own color:

GLSL// Sparkle colors
const vec3 coreColor   = vec3(1.0, 1.0, 1.0);       // pure white
const vec3 glowColor   = vec3(1.0, 0.9, 0.83);      // warm peach #FFE6D4
const vec3 streakColor = vec3(1.0, 0.97, 0.87);     // pale yellow #FFF8DF

vec3 base = texture2D(halftoneBuffer, uv).rgb;

// Apply each component via hard light blend
vec3 result = base;
result = hardLight(result, mix(vec3(0.5), glowColor,   sparkle.g));
result = hardLight(result, mix(vec3(0.5), streakColor, sparkle.b));
result = hardLight(result, mix(vec3(0.5), coreColor,   sparkle.r));
We use 0.5 as the neutral value because hard light at 0.5 is a no-op — it neither darkens nor lightens. As the sparkle intensity increases from 0 to 1, the blend smoothly transitions from "no effect" to "full brightness."

The canvas overlay

The final touch: a paper texture applied via CSS. This is a PNG image of actual canvas/paper grain, composited using mix-blend-mode: color-burn:

HTML<!-- Container structure -->
<div style="position:relative; width:616px; height:431px">
  <canvas id="webgl"></canvas>
  <img src="canvas.png"
       style="position:absolute; inset:0;
              width:100%; height:100%;
              pointer-events:none;
              mix-blend-mode: color-burn;" />
</div>

Color burn darkens the base color to reflect the blend color. For our mostly-white canvas texture, this means light areas pass through unchanged while the paper fibers — slightly darker than white — create subtle darkening:

color-burn(base, blend) = 1 − (1 − base) / blend

blend = 1.0 (white fiber) → result = base (no change)
blend = 0.95 (slight paper texture) → result slightly darker than base

This single PNG transforms a digital image into something that feels printed on physical paper. The grain is subtle but the tactile quality it adds is enormous.

Mouse interaction

The sun direction shifts with the mouse position, creating an interactive spotlight effect. We map mouse X/Y to sun azimuth and elevation:

JavaScriptcontainer.addEventListener('mousemove', (e) => {
  const rect = container.getBoundingClientRect();
  const mx = (e.clientX - rect.left) / rect.width;   // 0 to 1
  const my = (e.clientY - rect.top) / rect.height;   // 0 to 1

  // Shift sun azimuth ±0.3 radians based on mouse X
  sunAzimuth = -2.8 + (mx - 0.5) * 0.6;
  // Shift sun elevation ±0.1 radians based on mouse Y
  sunElevation = 0.85 + (0.5 - my) * 0.2;

  // Recompute sun direction vector
  sunDir = normalize(vec3(
    sin(sunAzimuth) * cos(sunElevation),
    sin(sunElevation),
    cos(sunAzimuth) * cos(sunElevation)
  ));
});
Final composite
Final composite — ocean + blur + halftone + sparkles + canvas overlay. Move your mouse to shift the sun.

6. Putting It All Together

The complete pipeline

Ocean FBO Kawase blur ×6 Riso halftone Sparkle mask (ping-pong) Composite → Screen

Everything runs every frame in a single HTML file. The render targets:

JavaScript// Render target setup
const oceanRT     = makeRT(W, H);  // Raw ocean render
const blurPingA   = makeRT(W, H);  // Kawase ping-pong A
const blurPingB   = makeRT(W, H);  // Kawase ping-pong B
const lightBlurRT = makeRT(W, H);  // Light blur (2 passes) saved separately
const risoRT      = makeRT(W, H);  // Halftone output
const sparkleA    = makeRT(W, H);  // Sparkle mask ping
const sparkleB    = makeRT(W, H);  // Sparkle mask pong

// Each frame:
// 1. Render ocean → oceanRT
// 2. Light blur (kernels [0,1]) → copy to lightBlurRT
// 3. Heavy blur (kernels [0,1,2,4,8,12]) → blurPingA or B
// 4. Halftone (reads ocean + heavy blur + Poisson) → risoRT
// 5. Sparkle mask (reads ocean, writes ping→pong) → sparkleA/B
// 6. Composite (reads riso + sparkle) → screen

Key numbers

Config// Ocean
waveHeight:  2.7     // vertical wave amplitude
waveScale:   0.08    // noise frequency multiplier
sunAzimuth:  -2.8    // radians, measured from +Z axis
sunElevation:0.85    // radians above horizon

// Sparkles
cellSize:    20      // px, grid cell for sparkle placement
fadeOut:     0.82    // temporal persistence (ping-pong fade)
species:     5       // BASE, SPIKY, SPECKLE, GLINT, CIRCLE

// Halftone
risoColor:   #427b8a // teal ink
bgColor:     #F2F0EE // warm off-white paper
highlightColor: #c8dbe0 // desaturated blue tint
blurAmount:  0.7     // 70% smooth, 30% halftone
dotSpacing:  1       // 1 = every pixel is a potential dot

// Kawase blur kernels
lightKernels: [0, 1]               // subtle wave structure
heavyKernels: [0, 1, 2, 4, 8, 12]  // smooth dreamy base

Lessons learned

  1. The Poisson disc blur — uniform Kawase blur produces a flat, lifeless halftone. The per-pixel random rotation of 16 scattered samples creates organic tonal variation that makes the halftone feel hand-printed rather than computed.
  2. Highlight tinting — bright specular samples tinted toward #c8dbe0 at 89% keeps the entire palette cohesive. Without it, white specular highlights create harsh holes in the halftone.
  3. Blue noise over white noise — the difference is subtle in isolation but enormous at full scale. Blue noise produces the "stippled" quality of real printmaking; white noise just looks like TV static.
  4. Temporal sparkle persistence — the ping-pong fade at 0.82 means sparkles don't pop. They bloom softly and trail off, like real light catching water.
  5. Canvas overlay with color-burn — a single PNG with CSS mix-blend-mode adds more physical quality than any amount of shader trickery. The trick is that it happens outside the shader pipeline entirely.
  6. Multiple blur layers for different purposes — smooth Kawase for base color, noisy Poisson for halftone density, light Kawase for subtle wave structure hints. Each blur serves a different aesthetic role.
  7. Check your texelSize components — the most painful bug was using .zw instead of .xy for a value packed as vec4(1/W, 1/H, W, H). The output looked reasonable (uniform color) so the bug was invisible for days.

Dev Notes.

Written with Three.js (ES modules, v0.164), pure GLSL fragment shaders, and zero build tools. The entire effect runs in a single HTML file with no external dependencies beyond Three.js and a canvas texture PNG. Sparkle species and ocean parameters were reverse-engineered from farayan.me/sparkles by Fara Yan.

Sparkles on a lake, swans afloat, and alas, you are here.