Lucid Lake Support

Blog#velia#engine

Why our ocean flickered, and the 30-frame test that proved the fix

The water in Velia's new WebGPU engine flashed with tiny white dots every frame. A simple 30-frame test showed the cause was not the sun glint I suspected, and the fix brought the share of flashing pixels from 7.5 % down to 2.5 %, level with the first version, for 0.6–1.0 ms of GPU time at 2560×1600.

The new engine is not public yet. The live game at velia.lucidlakelabs.com is still the first version, built on three.js.

The complaint

The first playtest note was blunt: small white dots flashed across the water all the time, enough to make people dizzy and uncomfortable. The first version reflects the sky and clouds as broad, soft patches on the wave faces. The new engine, drawing the same FFT ocean, turned the middle distance into glitter.

A still picture hides most of this. The problem is what changes between frames, so I needed a number for it before touching any shader.

Measuring flicker

The test is small:

  1. Fix the camera: the follow camera behind the boat, a fixed seed, a fixed time of day.
  2. Render 30 consecutive frames, stepping the simulation at 60 Hz.
  3. For every water pixel, take the standard deviation of its luminance over the 30 frames (in 0–255 levels).
  4. Count the share of pixels whose luminance jumps more than 20 levels from one frame to the next.

The first number says how much the water shimmers overall. The second says how many pixels visibly flash. Both run headless in Chrome on an M5 Pro MacBook, at 1280×720 and at a phone-shaped 390×844, at 13:00 and at 18:45.

The cause, measured

My first guess was the sun's specular glint. Switching the glint off changed almost nothing: the temporal deviation went from 13.5 to 13.6 levels.

So I made the reflection's inputs visible one at a time. Replacing the mirrored scene with a constant colour dropped the deviation from 13.5 to 4.8 levels, and the share of flashing pixels from 9 % to 0.06 %. Reflecting the sky gradient alone, without clouds or land, gave 4.2 levels and 0.007 %. The flicker lived in what the water reflected, not in how bright the highlight was.

The mirror held two hard-edged things: the ridge line of the mountains, where a reflected ray either hits land or it doesn't, and the edges of the clouds. The engine shot one reflected ray per pixel from one wave normal. At mid distance a pixel covers many small waves, and the normal it happened to sample swung the ray by about ±11° in elevation from one pixel to the next and from one frame to the next. A ray pointing 11° higher misses the ridge and hits the sky; the pixel flips from dark to bright. Thousands of pixels did this every frame.

The engine already computed a slope variance from the wave maps' mip levels, but it only widened the glint's highlight. It could not help a single ray that had to hit either land or sky.

The fix

Each change makes a pixel reflect what its whole patch of water would reflect, not what one random wave in it reflects:

  • Footprint variance. Read the waves' slope moments at the mip level that matches the pixel's footprint along its longer axis, and add a screen-space spread term. This gives the variance of the normals inside the pixel.
  • Mean-normal mirror. Send the reflected ray from the footprint's mean normal. The glint keeps the pixel's own normal, so a low sun still draws a glitter path.
  • Five taps. Average the clouds and the land over five elevations spread across that variance (0, ±0.8 and ±1.6 of two standard deviations).
  • Rough Fresnel and glint. Use the first version's rough Fresnel and its Beckmann glint with a Cox–Munk floor, so the reflection's strength also follows the roughness.

The deep water colour, the lake bed and the exposure did not change.

The middle distance at 13:00: the first version (top), the new engine before the fix (middle) and after (bottom)
The same pose in all three; the sparkle in the middle row is gone in the bottom row

The numbers

Temporal deviation of water luminance (levels, 0–255) / share of pixels jumping more than 20 levels between frames. 30 frames, fixed follow pose, headless Chrome, M5 Pro.

Time, resolutionFirst versionNew engine beforeNew engine after
13:00, 1280×7207.9 / 2.3 %14.0 / 7.5 %8.4 / 2.5 %
13:00, 390×8449.0 / 3.3 %14.7 / 7.7 %10.3 / 3.2 %
18:45, 1280×72017.4 / 4.0 %20.3 / 6.3 %14.7 / 3.4 %
18:45, 390×8448.0 / 1.5 %21.9 / 9.8 %14.5 / 5.1 %

Three of the four rows now match or beat the first version. The phone-shaped frame at 18:45 is better than before but still flashes more than the first version does, and stays on the list.

8 seconds of the same sail; watch the middle distance

What didn't work

  • Turning the glint off or down. It barely moved the numbers, because the glint was never the main cause.
  • Three taps instead of five. It cost about 0.4 ms instead of 0.6–1.0 ms, but the flicker came back to 9.5 levels and 3.9 % at 13:00. I kept five taps: the complaint was about comfort, so less flicker came first.
  • A GGX glint. Its long tails washed the dusk glitter path into a flat gradient. The Beckmann lobe kept the path's shape.

What it costs

On frame totals at 2560×1600, measured as six interleaved on/off pairs and taking medians, the frame went from 9.37 to 10.17 ms: 0.6 to 1.0 ms more, depending on the pair. Most of it is reading the cloud view, a 2048×512 HDR panorama, five times per pixel. The cheaper road is a pre-blurred, mip-mapped cloud view, so two filtered reads replace the five taps. That is the next step, not done yet.

The lesson

A shimmer complaint is a measurement problem first. Thirty frames and two numbers turned "it feels dizzying" into a target, ruled out the obvious suspect in one run, and showed when the fix was done. The general rule for any reflection: a pixel should reflect what its whole footprint sees, never one random sample of it.

This post is about Velia: A sailing game on a real lake, with real wind and real-time water and sky. Free, no sign-up.