Skip to content

Building an open world in the browser, part 26: Three ways to make water

By Oleg Sidorkin, CTO and Co-Founder of Cinevva

New here? Use the series guide. It explains what a spike is and links all parts.

Part 25 dressed the avatars. This part is about water, and it's three spikes because water is the surface where a cheap shortcut and the correct answer look identical in a screenshot and completely different in motion. Spike 51 builds reflection the screen-space way, the tempting one, and runs straight into its built-in limitation. Spike 52 switches to the method every shipping game actually uses. Spike 53 drops in a finished water library to see how far "done" is from where we are. All three share one refraction layer, so the only variable that moves between the first two is how the reflection gets computed.

Reflection from the screen you already have

Open Spike 51 in a new tab ↗ · View source

Screen-space reflection reuses the frame you already rendered. For each water pixel, you reflect the view ray off the surface, march that reflected ray through the depth buffer, and when the ray passes behind a recorded surface you've found what the water reflects, sampled straight from the color buffer. The port follows three.js's SSRNode line by line, which in turn follows lettier's SSR primer. The march is a DDA walk in screen space: project the ray's start and end to pixel coordinates and step along whichever axis is longer, one tap per pixel. The reflected ray's depth at each step needs perspective-correct interpolation, 11/z0+s(1/z11/z0), because linearly interpolating view-Z is simply wrong and produces hits in the wrong place.

Two things make this usable rather than a slideshow. The coarse march is capped at 64 steps, because a long ray that projects across a thousand pixels would otherwise run hundreds of iterations per fragment, and a million-fragment water plane times hundreds of iterations times a few texture samples is a 30 fps scene. Quality controls the effective stride within that cap instead of the iteration count. And because a capped coarse march leaves visible stair-step banding, a six-iteration binary refinement bisects the interval between the last miss and the hit, which is 64x sub-step accuracy, enough that neighboring water fragments stop locking onto the same coarse hit position. A final point-to-line distance check confirms the candidate is genuinely on the reflection ray rather than just at the same depth, with a thickness tolerance that auto-scales to the view-space width of one pixel at that depth, tighter up close and looser far away.

The honest part of this spike is written into its own comments: SSR cannot reflect what the main camera never sampled. A tree's underside, anything off-screen, anything occluded, none of it exists in the buffers, so it can't appear in the reflection. That's the "wrong-side information loss" that no amount of march quality fixes, and it's exactly why the next spike exists.

The mirror that can't lie

Open Spike 52 in a new tab ↗ · View source

A planar reflection renders the scene a second time from a camera mirrored across the water plane, into an offscreen target, and the water shader samples that target. This is the canonical pattern, what UE5 Water, three.js's own WaterMesh, ABZÛ, and Sea of Thieves all use, because it has every pixel of the scene to draw from, including the geometry SSR can never see. In TSL it's almost anticlimactic: reflector() allocates the mirrored helper camera and its render target, you add its target to the mesh so it updates each frame, and you sample its color. When waves come later, the reflection wobbles by adding a distortion offset to the reflector's UV node, which is exactly the line WaterMesh uses.

The bug worth recording was in the safety net, not the mirror. An earlier version blended the reflector's output with a procedural sky as a fallback, weighted by the magnitude of the reflected color, on the theory that a near-zero reflection meant the target had nothing there. But a dark canopy shadow also has a low magnitude, so the clamp never reached full strength and those genuinely dark pixels got mixed with bright sky. The symptom the user caught was precise: the raw mirror target in debug mode showed perfect dark trees, while the composed render had washed-out reflections, and the diagnosis was that the shader "vanishes dark colors." The fix was deletion. The reflector target is reliable after the first frame, so there's no need for a fallback at all. Both spikes share the same refraction underneath: sample the scene behind the surface, reconstruct how far below the waterline each pixel sits, and apply per-channel Beer-Lambert extinction so red dies within a few meters while blue persists, with a sky mask so the far-plane backdrop doesn't get fogged. Schlick Fresnel mixes refraction when you look straight down into the water with reflection when you look across it.

What finished looks like

Open Spike 53 in a new tab ↗ · View source

Spike 53 is the build-versus-buy check. It drops in threejs-water-pro as it ships, with its tropical preset, default ocean clipmap, camera tracking, and Rayleigh sky, and loads the same island glTF the library's own demo uses. The point is to see the gap between a hand-rolled flat-water shader and a full ocean system, and the gap is large: this one has a buoyancy system that samples wave height at several points under a ship's hull so it pitches and rolls instead of just bobbing, a wake generator, shoreline and surface foam, and a mask pass that suppresses water rendering inside the ship's hull so ripples don't bleed through the deck.

Integrating it surfaced the kind of detail you only learn by using a library rather than reading its README. Foam textures are referenced by filename in the preset but loading them is the consumer's job, and without them the shoreline reads as a hard waterline edge instead of breaking surf. The island is positioned so its underwater geometry drops past the ocean floor, letting the library's floor mesh occlude the model's outer ring with no visible plane edge, which is the library's intended pattern: bring a 3D model, don't synthesize terrain. And antialiasing is deliberately off on the renderer in favor of a post-process FXAA pass, because MSAA blends edge fragments against the background before the depth-aware atmospheric fog runs, leaving a thin dark fringe wherever geometry meets fog. Resolving aliasing after the fog instead of before it removes the fringe. What this de-risks is the decision itself: a production ocean is a large, specialized system, and for the cases where we need one, adopting a maintained library beats rebuilding wakes and buoyancy and foam from scratch, while the planar-mirror shader from spike 52 stays the right answer for the smaller inland water a creator places in their own world.

Technology referenced in this chapter

Screen-space reflection water. A reflected view ray marches the depth buffer in screen space via DDA, using perspective-correct 1/z interpolation, a hard step cap to bound the per-fragment cost, and a binary refinement pass to remove the banding a capped march leaves. A point-to-line confirmation with depth-scaled thickness rejects false hits. The method's hard limit is that it can only reflect geometry the main camera already sampled, so off-screen and wrong-side surfaces never appear. See terrain materials.

Planar mirror reflection. A helper camera mirrored across the water plane renders the scene into an offscreen target that the water shader samples, giving pixel-perfect reflections including geometry SSR can't see. This is the pattern UE5 Water and three.js WaterMesh use, with wave distortion applied as an offset to the reflector's UV node. A magnitude-weighted sky fallback wrongly erased dark reflection pixels; the reflector target is reliable after the first frame, so removing the fallback was the fix.

Beer-Lambert depth tint refraction. Both shaders sample the scene behind the surface, reconstruct each backdrop pixel's depth below the waterline, and apply per-channel exponential extinction (red dies in meters, blue persists) composited toward a water-fog color, with a sky mask so the far plane isn't fogged. Schlick Fresnel blends refraction at normal incidence with reflection at grazing angles.

Adopting a production water library. threejs-water-pro ships an ocean clipmap, Rayleigh sky, multi-point buoyancy for ship pitch and roll, wakes, foam, and a hull mask pass. Consumer-side details matter: foam textures must be loaded explicitly, island underwater geometry should drop past the ocean floor so the floor occludes its edges, and antialiasing should run as a post-process FXAA pass rather than MSAA to avoid a dark fog fringe at geometry edges.


Part 26 of 29. Previous: Part 25 - One skeleton, every outfit Next: Part 27 - An island from noise, ground that looks like ground Series guide: /blog/2026-02-25-open-world-browser-series-guide