2026-09-23 · 8 MIN READ
Where the ripples went
Three weeks of making the water in my game look real were mostly a search for detail the math already had and the screen never showed. Twice the culprit was the same one-line mistake, and once the fix was to draw less.

"The river next to the village still looks like complete ass." That was my whole playtest note on the evening of September 8, after a week in which every water test in the suite had gone green. I had followed the river out of the village and it ran as straight as a canal: trench walls, a flat collar of sand along each side like a strip of carpet, the same width and depth for a hundred meters, and water that looked like tinted glass. Everything the tests measured was true. None of it was what I was looking at.
Spaceframe is the survival game I have been building since July, on a sphere 10 km in radius, in Godot, with my coding agent writing most of the code against probes I insist on. The water had had its big week at the start of the month. The sea got refraction and absorption, caustics in the shallows, an underwater pass with Snell's window, a patch of Gerstner waves that follows the camera, and a CPU copy of those waves so a floating crate bobs on the crest you see. It also got screen-space reflections, for about a day. On a moving sea they drew picket-fence stripes that survived a distance gate and two plausible fixes, and only switching them off completely, one suspect at a time, proved they were the cause, so they shipped switched off. The video below is where all of it stands three weeks later.
Open sea, whitecaps, surf, shallows, the rowboat and the village river, played back to back. A capture scene flies the camera and saves every frame at 3840x2160 under a fixed 30 fps clock; the clips are encoded at 1440p, cuts only, no music, no narration.
The straightness turned out to be built in. A river's course is drawn through its drainage nodes, a reach apart, and each reach was drawn as three straight chords with a gentle meander on top to hide them. That meander was only ever given to streams, and the reach past the village is classed as a river mouth, so it had never bent at all. Once every reach got one, timed so neighboring bends join without a seam, the longest straight run dropped from 121 m to 14, and the banks were rebuilt the same night to meet the water at the waterline instead of at a wall. The color was taste rather than a bug: I wanted a glassy, saturated teal you can see into, which physically absorbed color never gives you, so the color ramp is authored by hand now.
Two days later a new ruler for wave height found something worse: the sea had no waves at all. Each wave train's phase was the dot product of a direction lying flat along the surface with the point's position, and on a sphere that product is zero, because a position points straight out from the center and a flat direction is at right angles to it. So the whole ocean rose and fell in unison, like a piston, and had done so for eight days. The height difference across the surface at one moment was 0.0004 m. With the heading made a direction in world space it was 2.04 m, and the CPU copy agreed with the shader to 0.3 mm. The best line was in the buoyancy code's own comments: the crate had matched the rendered crest perfectly, not because the copy was good, but because a flat sea has no crest to be wrong about.
The sea still moved as four wave trains, and last week I wrote here that it would stay that way, because a full wave spectrum had cost the one studio that published a number about 8 ms a frame. It went in a week later anyway: three cascades of FFT waves, the smallest 16 m across, steered by the biome's wind, with whitecaps that form where the surface folds and fade with a half-life of about six seconds. The frame-time probe could not tell it apart from the old sea on this machine, and the copy that swimmers and boats read stays within 3 cm of the GPU's surface. How it vanished from the screen three times on the way in is a different post.
The next evening I sent the agent the reference I had been looking at, a commercial tropical-water shader, and one line: "it does not look as high res as what I sent you. I need the water to look real, fix all findings." It was a fair complaint: up close the new sea read soft, smooth swells with no small ripples on them, like a render at a quarter of the resolution (the middle frame below). The agent's first move was not a shader change but a ruler: a capture that shoots the same handful of framings every run with the sun pinned to full daylight, and a script that scores each region for fine detail, foam coverage, how lacy the foam is, and how visible the bed is through the shallows. The waves animate on engine time, so two runs share a framing, not pixels, and every number from here on is a region statistic for that reason.



The first two share one vantage and one frozen time: the old wave model against the new one on the day it landed. The third is the evidence capture's 3 m frame after the fixes, a different spot on the same sea.
The softness had two causes, and the first was the piston again. Every fine ripple term, the near detail, the far detail and the sun glitter, was keyed on the same kind of dot product, a surface-flat direction against a position, which on a sphere is close to zero everywhere. Instead of ripples, the whole view got one constant tilt. The second cause was a sampling rate. The finest FFT cascade stores texels 6 cm apart, but its slopes were read once per mesh vertex, every 0.75 m, and blended in between, so that detail never reached a pixel. The fix was a baked ripple normal map in four layers, laid on the FFT's own coordinates and read per pixel, plus the cascades themselves sampled per pixel. Fine detail near the camera nearly tripled, and the far water gained the most.
High-frequency energy of the water region (the metric script's own definition), from the same capture on the same framings before and after. The criterion was at least 2× near and no loss at the horizon.
Foam was the second finding in the same pass, and the one where failing a criterion was the right answer. The shoreline foam had been a smooth amount painted almost opaque white, so every meter of water under 3 m deep was a white sheet with blotches in it. The replacement uses that amount as a threshold on a baked lace texture instead of as opacity, so foam appears as a broken line rather than a coat of paint. The plan said coverage had to stay between half and one and a half times the old number, to stop a fix from simply deleting foam, and it came in at a tenth.
Coverage in the reef frame, 50 m offshore looking back at the coast. Over the same change the lace score, how broken the foam reads, went from 0.050 to 0.516.
I kept it anyway, because the old 31 percent was the defect, and holding coverage near it would have meant redrawing a white sheet with holes cut in it. Whether there is enough foam now is a question for my next playtest.
The first pass merged a little after one in the morning, and its new detail ran straight into the anti-aliasing. With temporal anti-aliasing on, the game's default, the near water kept about 39 percent of the detail the same frame showed with it off, by the first pass's own results table. Before the fix it had cost almost nothing, because there had been almost nothing fine to lose. The obvious remedy, motion vectors for the water, does not exist, since Godot 4.7 writes none for transparent surfaces, and motion was not the problem anyway: grass loses about half its fine detail the same way, so the loss comes from how the anti-aliaser rebuilds each frame. The agent tried every candidate at one framing and scored each on two things, detail and shimmer, the frame-to-frame flicker on thin edges that a sharpening pass tends to buy its detail with.
Bar length is fine detail on the 3 m water frame. Shimmer is the frame-to-frame change on thin grass edges across four consecutive frames, relative to TAA alone; above 1.2× failed. Same run, same framing, TAA on in every row. For scale, the first pass measured the TAA frame at 0.39 of the same frame with TAA off, where before the ripple fix it had been 0.99.
Sharpening the whole frame could not win both scores, because the contrast of the water's ripples and of the grass edges where the flicker lives overlap almost completely. The water is the one surface in the frame that is transparent and writes depth, though, so comparing depth before and after the transparent pass marks exactly its pixels. A contrast-adaptive sharpen that runs after the anti-aliasing, on those pixels only, doubled the near detail at no measured shimmer cost. It merged as a graphics setting, on by default, in a second pass before three in the morning.
That second pass also fixed the first lace's own flaw, round holes that made the foam read as Swiss cheese. It rebuilt the texture from the edges of Voronoi cells, which gives veins and filaments, the way foam actually tears. Then the open-sea whitecaps came out as thin wiry scribbles, and a milky glowing patch sat over the shallow shelf. The patch was three effects stacked, and the worst was a crest sparkle added in the river week, an unlit near-white glow keyed to wave height, so every tall crest wore a haze that ignored the sun. The third pass, an hour later, deleted it outright. The whitecaps got an opaque churned core where the FFT surface folds hardest, with a rim that frays into the lace, and the share of each cap that reads as solid white went from a fifth to three fifths.

Same run and framing, a few seconds apart, with the core switched off (the second pass's lace-only caps) and on. Across the five largest caps the solid share went from 0.20 to 0.60.
The same night, the rowboat's own probes were going red, on physics that had to stay honest while the look changed. Swimmers, crates and boats read the CPU copy of the waves, and every look change reran the probes that hold it to the rendered surface. The rowboat arrived the day after the river post said there were still no boats, and it failed the way any vehicle fails before anyone has driven it. Parked with the rudder hard over, it spun in place, because steering did not depend on the hull moving through the water; now it scales with the square of the speed relative to the current. At full throttle the bow dove, because the self-righting force corrected roll but not pitch, and a long hull resists pitching several times harder than rolling. Rapids now capsize a boat while a calm current only pushes it, and the bow fix turned out to be the steering fix too: with its nose under water the hull had slowed almost to a stop, which is why it barely turned at cruise.
From the boat probe on a navigable river reach. Bar length is the factor of improvement, not a physical quantity.
None of these numbers is the verdict. The look gate on this project is my playtest; the metrics make each finding checkable, and they are noisy: the whitecap figure swung by half again across seven runs, one shallow-water target was written against a before-frame that a re-shoot could not reproduce, and a river-foam target was missed by one thousandth. All three misses sit in the repo's results table next to the passes. The water's GPU cost rose from 1.15 to 1.49 ms across the three passes, inside a 2 ms budget set before the first change.
Frame GPU time with the sea drawn minus without it, averaged over 90 frames at 1920x1080 on an RTX 4080 at the 3 m vantage. The FFT compute runs either way and is not included. Pass 3 read 1.39 to 1.54 ms over seven runs.
The video near the top is the game, not a trailer: a capture scene flies a scripted camera through seven shots and saves every frame at 3840 by 2160, with game time locked to a thirtieth of a second per frame, so a frame that takes half a second to render and save still lands on time. What I keep noticing is how little of the three weeks went into inventing water. The waves were in the math from the first day while the sea stood flat. The ripples were in the texture and never reached a pixel. When they finally did, the anti-aliaser took most of them back. Most of the work was finding where the detail went.






Frames straight from the capture that made the video, downscaled from 3840x2160 for the page. Tap any of them to open it full size.
- 01No river runs uphill: the rivers under all of this
Last week's drainage and water-table post, including the 8 ms reason the sea was going to stay four wave trains.
/blog/no-river-runs-uphill
- 02Nothing merges on its own word: how the FFT sea vanished three times
The same spectrum sea's arrival, told from the coordination side: three separate ways it passed every check and still was not on screen.
/blog/nothing-merges-on-its-own-word
- 03Tessendorf, Simulating Ocean Water (course notes)
The standard reference for building an ocean surface from a wave spectrum with FFTs, which is what the three cascades implement.
https://jtessen.people.clemson.edu/reports/papers_files/coursenotes2004.pdf
- 04AMD FidelityFX Contrast Adaptive Sharpening
The sharpening kernel the water pass re-implements. It lifts low-contrast detail hard and hard edges less, which is why it suits TAA's softening.
https://gpuopen.com/fidelityfx-cas/
- 05Godot Engine
Version 4.7, Forward+ renderer. It writes no motion vectors for transparent materials, which is the constraint the water sharpen works around.
https://godotengine.org/