FPS: --
Writing
2026-06-128 min

Rendering an Ocean in the Browser

WebGLGraphicsFrontend

The background of this site is a real-time ocean. No video, no image — it’s a WebGL shader drawing water sixty times a second, right in the browser. I wanted to write down how it works, partly because it’s a fun problem and partly because the final version taught me a few things I didn’t expect.

Raymarching water

Most 3D on the web uses meshes — triangles you move around. The ocean doesn’t. It’s a single full-screen rectangle, and every pixel asks one question: if I shoot a ray from the camera through here, where does it hit the water?The answer comes from raymarching — stepping along the ray until you cross the wave surface, which is itself a sum of moving sine waves with a little noise so it doesn’t look too regular. Once you know where the ray lands you can work out how light reflects, how much you see through the surface, and how the color drifts toward the horizon. It’s more math than mesh, which is exactly why it runs anywhere without shipping a single 3D asset.

The look is a trick

Here’s the part I like most: the ocean isn’t really “colored” at all. The raymarcher renders a normal lit scene, and then a second pass throws almost all of it away — it reduces the whole frame to a single brightness value and re-maps that between two tones. At night, near-black to white, which gives the monochrome starfield feel. That one post-process is what makes it feel designedinstead of just “a graphics demo.”

It also bit me later. When I went to make a daytime version blue, nothing I changed upstream did anything — because the duotone was quietly discarding every color I fed it. The fix was a one-line change, but in the right line. The lesson I keep relearning: find the last thing that touches the pixel.

Day and night, one switch

The site has a light and a dark theme, and I wanted the ocean to be part of it rather than a separate thing bolted on. So the theme toggle is the single source of truth. It drives a night value in the shader — blending the water from a bright blue day to the dark starfield — and at the same moment flips the rest of the UI between light and dark. The colors of the page and the colors of the water always agree, because they read from the same switch.

Making it behave

A perpetual full-screen animation is the kind of thing that’s easy to ship and easy to regret. A few things so it doesn’t ruin anyone’s day:

  • Reduced motion. If your OS asks for less motion, the shader never loads — you get a calm static gradient instead. Right default, basically free.
  • No WebGL, no problem. If the browser can’t hand me a WebGL context, the code bails out cleanly to that same gradient instead of throwing into a blank black screen.
  • Adaptive quality. The shader watches its own frame rate and quietly drops resolution on slower machines rather than stuttering.
  • Clean teardown. When the component unmounts it actually stops — cancels the render loop, drops its listeners, releases the GPU context. Animations that keep running after you’ve left are a classic invisible leak.

The dumbest bug, last

The shader is a static file, so the browser caches it by URL. Which means after I’d deploy a new ocean, anyone who’d visited before would keep seeing the old one — indefinitely. The fix is embarrassingly simple (a version string on the URL), but it’s the kind of thing you only catch while staring at a change that’s “definitely deployed” and “definitely not showing up.”

None of this is novel — raymarched water and duotone post-processing are well-trodden. But assembling it into something that loads fast, respects the person looking at it, and ties into the rest of the site was a good reminder that the interesting work is usually in the seams, not the centerpiece.