← workEGK

Streaming a mountain: 9.6 million points to any browser

How this site's hero loads — delete-before-compress, Morton-shuffled streaming, and render-on-demand with an 8 GB no-GPU floor.

2026-07-07 · 6 min read · [draft]

The front page of this site is a mountain: Kyrkja in Jotunheimen, rendered live in the browser as 9,606,928 points baked from Kartverket’s 1 m LiDAR terrain data. The point count was the easy part — a bake script will happily emit as many points as you ask for. The hard part was the constraint I set for the whole site: it has to run on an 8 GB machine with integrated graphics. Not degrade into a spinner — actually run.

The rendering architecture was already sound — one draw call, uint16-quantized positions dequantized on the object transform — so rebuilding it would have bought nothing. The waste was in two other places: delivery (bytes that encode nothing) and idle behavior (frames that change nothing). This is what fixing both looked like.

Delete redundancy before compressing

The previous format stored 9 bytes per point: uint16 x/y/z plus one rgb byte each. At 9.6 million points, that’s 86.5 MB. My first instinct was compression. The better question was what the bytes actually encode.

Every point’s color was computed in the bake as ramp(elevation)— a five-stop gradient over height. I checked every path that could touch color before trusting that: the far-tier falloff only thins point density, the back-face ghosting only thins density, and the format has no per-point alpha. Color was a pure function of a value already in the file. 28.8 MB of the payload was re-shipping the answer to a lookup.

So v3 of the format (KYK3) stores positions only — 6 bytes per point:

per point: uint16 x, uint16 y, uint16 z     // 6 B, little-endian
dequantize: local[axis] = min[axis] + q/65535 * span[axis]
file = 68 B header + N * 6 B                // 9,606,928 pts -> 57.64 MB

Quantization error tops out at 0.23 m horizontal over the 30 km tile — visually lossless. The color ramp moved into the vertex shader, keyed off two constants from the file header:

float t   = clamp((position.y - uRampYmin) / (uRampYmax - uRampYmin), 0.0, 1.0);
vec3  rgb = kyrkjaRamp(t);   // 5-stop piecewise-linear mix chain

Before deleting the rgb block, I made the bake prove the claim: it recomputes color from the new format and diffs against the old rgb bytes. The exact formula reproduces them with a max per-channel delta of zero. Through the realistic path — deriving color from the dequantized uint16 positions — the delta is ±1/255 on 0.27% of points, which is the already-accepted position quantization showing through, not a color defect. The live render is actually more accurate than before, because the shader keeps float color instead of the old truncated uint8 bytes.

One deletion, three wins: 86.5 MB became 57.6 MB (−33%), the GPU holds a third less attribute data, and — since the ramp now lives in shader uniforms — a palette is data. Recoloring the whole mountain is a uniform update, not a re-bake.

Ordering as a format guarantee

57.6 MB still takes time to arrive. What matters is what you can render before it finishes — and that depends entirely on the order the points are written. The old file was written in bake order, near tier then far, so a partial download was a hole-riddled fragment. I wanted the opposite property: any prefix of the file should be a complete, thinner version of the whole mountain.

KYK3 guarantees that by construction:

1. Morton-sort all N points (bit-interleave qx,qy,qz -> one code per point)
2. cut the sorted sequence into batches of 128 consecutive points
3. shuffle the batch ORDER (fixed seed 42) -- batch contents untouched

=> any byte prefix is a spatially uniform subsample, at 128-point granularity

A pure random shuffle gives the same statistical property, but it destroys GPU vertex-cache locality — Schütz measured up to ~4× slower GL_POINTS rasterization. Keeping 128-point Morton runs intact preserves near-Morton locality, while the shuffled batch order makes every prefix spatially uniform. At 9.6 million points, 128-point granularity is invisible.

This is written into the format spec as a guarantee, not an implementation detail, because device policy depends on it: the floor tier — the 8 GB, integrated-graphics machines — simply cancels the stream at 3,000,000 points, about 17 MB on the wire, and gets a uniform subsample with the density shape intact. No second file, no LOD pyramid. Weak devices just stop reading.

Streaming into a preallocated buffer

The loader never awaits the full download. It reads the response as a ReadableStreamand appends into one position attribute preallocated up front. Every ≤32 ms it flushes what arrived: mark the new byte range with addUpdateRange, extend setDrawRangeto the points received so far, request a render. The mountain materializes as bytes arrive — at 20% streamed the full vista already reads whole, just thinner. That is the Morton guarantee paying off visually.

Progress is real: bytes received over Content-Length, printed as “loading the mountain — NN%” in the corner. Deterministic progress from actual bytes; no spinner. Teardown runs through an AbortController, so navigating away mid-stream cancels cleanly — the floor tier’s early stop is the same mechanism.

There is one more first-paint trick: a canvas is not an LCP candidate. As far as the browser’s paint metrics are concerned, a WebGL hero is a blank page until something else paints. So the page ships a 772 KB poster — a still of the finished mountain from the exact live camera — that paints instantly as a plain image. The live cloud streams in underneath and crossfades in once three things are true: shaders compiled (compileAsync), at least 15% streamed, and one real frame rendered. Measured on the dev server: LCP 331 ms, CLS 0.00. The load stopped being a wait and became the opening shot — the finished mountain, then the same mountain coming alive point by point.

Render-on-demand

Getting the bytes down was half the constraint. The other half: the old loop redrew 9.6 million points at 60 fps forever, even with nothing moving. On an integrated GPU, that is the difference between a usable page and a hot, throttling laptop.

Now the loop renders only when something changes. Interaction and the click ripple run at 60 fps. The ambient marker breathing runs on a 30 fps cadence at idle, accumulating time across skipped frames so its phase stays exact. When nothing animates at all, the requestAnimationFrameloop parks — zero frames — and wakes on wheel, pointer, resize, or tab visibility.

That required killing the infinite idle orbit, which by definition never lets the loop rest. It’s replaced by a 12-second settle arc that eases the camera to a full stop. The verification harness reads the renderer’s own frame counter rather than rAF ticks: settle at 60 fps, idle at exactly 30.0, drag back to 60, floor tier parked at 0 frames until a wheel event wakes it. The exact 30.0 is itself the proof — any residual ambient motion would smear it.

What I rejected, and why

gzip.Measured before deciding: gzip-9 takes the file to 45.36 MB — 1.271×. Quantized positions are high-entropy; there is little left for an entropy coder to find. That ratio doesn’t buy its serving complexity. If the bytes ever truly matter, the right tool is a Morton-neighbor delta precoder ahead of the entropy stage, not gzip on raw positions.

Octree / BVH.The classic answer to large point clouds, and the wrong fix here. The floor problem is bandwidth, fill, and continuous rendering — the prefix cap fixes bandwidth, the pixel-ratio cap and square points fix fill (fragment discardfor round points kills early-z exactly on the iGPUs that need it), and demand-render fixes the loop. A spatial hierarchy adds an asset pipeline and runtime complexity to solve a problem this scene doesn’t have.

The order of operations was the real lesson: delivery first, then the render loop, then design on top. Palettes and camera choreography now iterate in minutes because they sit on mechanisms that don’t move. Foundations before design.