FIELD NOTES — RENDERING
Water you can float on: agreeing on the waterline, not the waves
Orebound's sea has waves, and you can swim in it. We wrote the wave function twice so that something could float on those waves one day. Nothing does yet, including you. Here is what the two copies agree on, and what the second one ended up doing.
Swimming arrived in Orebound on a Tuesday in July, twenty seconds after the first animated waves.
The rule is short. Where the ground drops away under water, you float with your eye 25 cm above the surface, and you cannot go under. The game takes the higher of two heights, the ground or that float level, so wading turns smoothly into swimming as the bottom falls away. There is no diving, no drowning and no oxygen meter. Water deep enough to swim in also breaks a fall from any height, which is the closest thing the sea has to a gameplay mechanic.
The obvious next question is whether the swimmer bobs on the waves. The short answer is no. The long one involves two copies of an ocean.
One sea, written twice
That same evening we replaced the water's first wave shader, which slid a flat 2D noise pattern across the surface, with a layered wave field. The field adds two layers of gradient noise together (smooth random hills, the Perlin kind):
- Swell: slow, with a 26 m wavelength and two octaves (an octave is a finer copy of the noise added on top).
- Chop: 2.1 times the frequency and 1.6 times the speed, heading roughly the other way, with four octaves.
Time is a third dimension of the noise, not only a scroll offset, so the sea never repeats and never resets.
The graphics card evaluates this at every vertex (a corner point of the water mesh), three times per vertex: once for the height, then 35 cm away along each of the two ground axes, to get a slope for the lighting.
We also wrote the same function in TypeScript, getHeightAt, so game code on the CPU could ask how high the water is at a given place and moment. The two versions do the same arithmetic with the same constants. They are not bit-for-bit identical, because the graphics card works in 32-bit numbers and the CPU in 64-bit. Each wave direction is scaled to length one once, when the presets are built, so neither side can do it differently. The CPU copy was meant for buoyancy. It had no caller then, and it has none now.
The waves nobody could see
The new field compiled, passed its tests and looked right in review. On screen, the sea was flat.
The tests checked three things:
- the function was deterministic;
- it varied across x, z and time;
- the ocean moved more than a lake.
All three were true, and all three are just as true of waves you cannot see. The CPU copy found the bug. We sampled it over a wide grid of positions and times and compared it with the old system:
| Mean displacement | Peaks | |
|---|---|---|
| Old waves | 5.3 cm | 17 cm |
| New waves | 0.76 cm | 4 cm |
Gradient noise spends most of its time near zero, and each octave has half the amplitude of the one before. Together they shrank a nominal 12 cm budget to almost nothing. The fix was one constant, AMPLITUDE_GAIN = 7, written into both versions, plus a test that keeps the ocean's mean displacement between 2 and 9 cm. It measures 5.3.
Six minutes later we brought back the sparkle. The old shader had a per-pixel ripple layer that did not match the geometry at all, but it made the water look alive. The new version lit the water from the real height field, per vertex, and the ocean's vertices were then more than 100 m apart. Detail finer than the mesh has to be computed per pixel or not at all.
The ocean that moved sideways
Two days later we looked properly at the ocean mesh. It was a flat sheet, built standing up and rotated a quarter turn to lie down. The shader read positions in the sheet's own coordinates, from before that rotation, and three things followed:
- Stripes: the shader's "x and z" were really x and a constant, so the noise collapsed onto a single line.
- Sideways waves: its "up" was sideways in the world, so every wave pushed the water along its own surface instead of lifting it.
- No room for a wave: the vertices were 132 to 176 m apart, against a 26 m wavelength, so no wave could form however we pushed.
Lakes were fine, because their mesh is built in world coordinates. The CPU copy works in world coordinates too, so for two days it disagreed with what the graphics card drew on the ocean. No test noticed, because only one side had tests.
The replacement is a dense square of mesh, 512 m across, that follows the camera:
- its vertices are 2, 4 or about 6 m apart, depending on the graphics setting;
- a flat ring around it reaches the horizon;
- it moves in steps of its own vertex spacing, so crests don't crawl along the beach as you walk;
- waves fade out between 150 and 240 m from the camera, so the seam with the flat ring is exactly flat.
It is one mesh and one draw call (one instruction to the graphics card). On the Low setting the sea is, by default, a single flat sheet.
What has to agree: the waterline
The swimmer floats on a level, not on the waves, so that level has to be the one we draw. Here the CPU and the graphics card agree by construction.
The sea is drawn 5 cm below the world's water level, and the swim code uses the same 5 cm.
Lakes took longer. In July their flat surface sat a few centimeters above the surrounding beach and ended in mid-air in a sawtooth edge. We dropped the drawn surface 1 m below the lake's fill level, so the ground cuts the waterline the way it already did at the sea, and gave the swim code the same drop. Three minutes later we cut the drop to half a meter, because a full meter shrank the lakes more than we liked.
Rivers got the same drop in September, because a river that meets a lake should not arrive half a meter above it.
The drop is now one constant, INLAND_LEVEL_DROP, read both by the code that builds the water mesh and by the code that floats the player. One gap of three centimeters is deliberate: the drawn surface of lakes and rivers is lifted that much so it does not flicker against the ground where the two meet, and the swimmer is not. On a lake your eye is 22 cm above the water; on the sea, 25 cm. We can live with this.
Why you don't ride the swell (yet)
For this post we sampled the ocean again, at about two million points over 800 m by 800 m and 200 seconds:
| Ocean | Height above rest |
|---|---|
| Mean displacement | 5.3 cm |
| 99th-percentile crest | 15 cm |
| Tallest crest | 31 cm |
A crest reaches above eye height in about 3 samples in 100,000. Lakes are calmer: 1.5 cm nominal, and never above 4 cm in our sample. Riding them would move your horizon a few centimeters, and so far that has not been worth the trouble.
There is also the question of time. The waves run on each browser tab's own animation clock, which starts when the game loads. The game's simulation counts ticks and knows nothing about waves, so two players on the same beach are looking at different crests. That is fine for scenery. Anything that floats in a shared world would first need everyone to agree on what time it is.
In water, the only bob you get is the 5 cm head bob from walking. We are calling it a stroke.
What the second copy is for
The CPU copy of the sea has never moved anything. It found a sevenfold bug, and it holds the test that stops that bug coming back. Had anything compared it with the graphics card, it would have caught the sideways ocean too. A promise that two copies match, tested on one side only, is a promise with one signature.
The lesson we kept: for noise and visual effects, sample the numbers instead of reviewing the formula. Formulas are very persuasive.
If something ever needs to ride the swell, the function is written, calibrated and tested. It just has to agree on the time.