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.
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(0) = 0, smoothstep(1) = 1, smoothstep'(0) = smoothstep'(1) = 0
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:
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.
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).
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.
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:
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:
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);
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:
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:
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.
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 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.
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.
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:
- Regular grid — uniform spacing, obviously mechanical, visible pattern
- White noise — random per pixel, causes visible clumps and voids
- Blue noise — appears random but points are evenly spaced; no visible low-frequency patterns
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.
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:
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)
);
}
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);
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.
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:
- A hash-based seed from the cell coordinate → determines sparkle lifetime, species, and offset within the cell
- The ocean luminance at the cell center → determines whether a sparkle appears at all
- 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:
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:
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:
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.
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:
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)
));
});
6. Putting It All Together
The complete pipeline
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
- 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.
- Highlight tinting — bright specular samples tinted toward
#c8dbe0at 89% keeps the entire palette cohesive. Without it, white specular highlights create harsh holes in the halftone. - 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.
- 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.
- Canvas overlay with color-burn — a single PNG with CSS
mix-blend-modeadds more physical quality than any amount of shader trickery. The trick is that it happens outside the shader pipeline entirely. - 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.
- Check your texelSize components — the most painful bug was using
.zwinstead of.xyfor a value packed asvec4(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.