Building an open world in the browser, part 12: Rings, sky fog, and what we would do again
By Oleg Sidorkin, CTO and Co-Founder of Cinevva
New here? Use the series guide. It explains what a spike is and links all parts.
Spike 24 was supposed to be "add clipmap rings to the terrain." It became a full finale that touched rendering, shaders, module infrastructure, and visual integration all at once.
The core terrain task was generating concentric clipmap rings in the vertex shader. Each ring is a flat grid mesh centered on the camera, with vertices displaced by heightmap samples. The inner ring uses full resolution. Each subsequent ring doubles the vertex spacing and covers a larger area. The tricky part is the boundary between rings: where a high-resolution ring meets a low-resolution ring, edge vertices on the finer mesh need to snap to the midpoint of the coarser mesh's edge. We did 2:1 edge morphing by detecting boundary vertices (those whose grid coordinate is odd along the ring edge) and interpolating their height between the two neighboring even vertices. This produces watertight seams without transition geometry.
Open Spike 24 in a new tab ↗ · View source
Then came the fog and sky integration. We wanted distant terrain to fade into the actual sky color, not a flat constant. That meant the fog shader needed to know what color the sky would be in the direction of each fragment. We loaded an equirectangular HDR skybox texture and sampled it in the fragment shader using the view direction from camera to fragment, converted to equirectangular UV coordinates via TSL's equirectUV node. The fog factor was distance-based using positionView.z.negate() for camera-space depth, blended with smoothstep between a near and far distance.
The most annoying problem in this spike wasn't geometric. It was module wiring. We upgraded to Three.js 0.183.1, which restructured the build outputs. The three/tsl import needed to resolve to three.tsl.js, and TSL internally imported three/webgpu as a bare specifier. Both mappings had to be explicit in the HTML import map. Missing either one produced cryptic "does not provide an export" or "failed to resolve module specifier" errors with no indication of which mapping was wrong. Once both were in the import map, the shader graph loaded correctly.
We also had a skybox orientation issue where the texture rendered upside down. The fix was flipY = true on the equirectangular texture, which is the Three.js default for loaded textures but was set to false in our initial code.
The original fog implementation sampled the sky at a near-constant direction, producing a thin horizon-colored band instead of a natural gradient. The fix was computing the actual camera-to-fragment world direction per pixel using positionWorld.sub(cameraPosition).normalize() and passing that into equirectUV for the fog color lookup. This made terrain fragments fade into the sky color that's actually behind them, which looks correct from any camera angle.
Underneath all the individual fixes, the core outcome held. We now have a terrain system that combines near-field volumetric editing (marching cubes with Transvoxel seams), mid-field heightmap chunks, and far-field clipmap rings, all governed by a policy layer that decides mode, LOD, and transition behavior.
If I had to name the patterns I'd repeat on the next project, they'd be these:
Start with risk spikes before feature work. Spike 1 killed the "can we even render fast enough" question before we invested in content pipelines.
Freeze known-good baselines before integration jumps. Spikes 13 and 14 saved us days of bisecting regressions.
Force policy and observability before optimization marathons. Spike 23 turned mystery bugs into named conditions with trigger rules.
Test under motion, not screenshots. Clipmap pops, seam flicker, and streaming hitches all hide in still frames.
Measure frame-time cost per feature, not average FPS. Averages hide the spikes that users actually feel.
And publish the messy parts. The wrong turns, the stale-buffer ghost hunts, the two days blaming transition logic when the draw range was wrong. Those are the parts people can actually learn from.
Technology referenced in this chapter
Clipmap ring geometry. Each ring is a flat grid mesh centered on the camera with vertices displaced by heightmap samples. The inner ring uses full resolution. Each subsequent ring doubles vertex spacing and covers a larger area. The tricky part is the boundary: where a high-res ring meets a low-res ring, edge vertices on the finer mesh snap to the midpoint of the coarser mesh's edge. The technique originates from Losasso and Hoppe's SIGGRAPH 2004 paper (PDF) and is detailed in GPU Gems 2, Chapter 2. See our landscape guide on geometry clipmaps.
2:1 edge morphing. At the boundary between two clipmap rings, the finer ring has vertices at positions the coarser ring doesn't share. Boundary vertices whose grid coordinate is odd along the ring edge are detected and their height is interpolated between the two neighboring even vertices. This produces watertight seams without dedicated transition geometry. The interpolation runs in the vertex shader: morphedHeight = mix(heightLeft, heightRight, 0.5) for boundary vertices, using the same geomorphing framework described in our guide.
Equirectangular skybox mapping. A single 2D image that maps the full sphere of sky directions using longitude-latitude projection. The horizontal axis covers 0-360 degrees, the vertical axis covers 0-180 degrees. In Three.js, setting texture.mapping = EquirectangularReflectionMapping with SRGBColorSpace enables this as a scene background. In TSL, equirectUV(direction) converts a 3D view direction into the 2D UV coordinates for sampling the texture.
Per-fragment fog color from sky. Standard fog blends fragments toward a single constant color. For a scene with a detailed skybox, this looks wrong because the sky color varies by direction. The fix is to compute the camera-to-fragment world direction per pixel (positionWorld.sub(cameraPosition).normalize()) and sample the skybox at that direction for the fog color. Each fragment fades toward the sky color that's actually behind it, producing correct blending from any camera angle. The fog factor uses smoothstep(nearDist, farDist, viewDepth) with positionView.z.negate() for camera-space depth.
Import maps for ES modules. A browser-native mechanism (<script type="importmap">) that maps bare module specifiers (like three/tsl) to actual URLs. When Three.js 0.183.1 restructured its build outputs, three/tsl needed to resolve to three.tsl.js and TSL internally imported three/webgpu as a bare specifier. Both mappings had to be explicit in the import map, or the browser produced "does not provide an export" or "failed to resolve module specifier" errors.
Further reading
For deeper coverage of the technologies used throughout this series, see our companion guides:
- Landscape Generation with Dynamic LOD and Streaming for Browser Open Worlds covers heightmaps, SDFs, marching cubes, Transvoxel, geometry clipmaps, geomorphing, streaming architecture, terrain materials, and vegetation rendering.
- Browser 3D Open World Tech for Multiplayer Creator Worlds covers rendering stacks, WebGPU, physics, networking, multiplayer architecture, and lessons from Skyrim, The Witcher 3, Breath of the Wild, and GTA V.
Thank you for following this twelve-part ride.
Part 1: We started by trying to break it
Part 2: Worker physics and the input lag fear
Part 3: The unflashy spikes that saved us
Part 4: Streaming before fancy terrain
Part 5: Budgeting the pretty stuff
Part 6: Clipmaps changed the plot
Part 7: Marching cubes and the first real caves
Part 8: Integration without losing our baseline
Part 9: Transvoxel started with a scaffold
Part 10: Seam chaos and the corner boss fight
Part 11: Policy mode, not hardcoded mode
Part 12 of 12.
Previous: Part 11 - Policy mode, not hardcoded mode
Series guide: /blog/2026-02-25-open-world-browser-series-guide