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.
Module wiring turned out to be more annoying than any of the geometry. 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.
External reality check: Vuntra City devlogs
After finishing this series, we reviewed the @VuntraCity devlogs as an external implementation check against our own open-world assumptions. It's a native UE5 project, not a browser stack, but the system patterns map well enough that the comparison is useful.
The first signal is that traversal speed has to be treated as a streaming control, not just gameplay. In Vuntra City, high-speed transit is intentionally routed above most interiors, and detail range scales with movement speed to avoid spawn churn and stalls (transport system, performance techniques). That matches our policy-layer direction: movement mode should directly influence chunk radius, interior activation, and allowed work per frame.
The second signal is architecture. Their maps and address system required separating world topology from rendered objects so global queries can run for unloaded regions (maps and addresses). That's the same separation we need in browser for world search, quest routing, moderation scans, and POI indexing without forcing render-bound data paths.
The third signal is simulation tiering. Their million-NPC design keeps coarse schedule state cheap and global, then spends expensive behavior budget only near the player (million-NPC overview, system deep dive). That reinforces our own AOI-first simulation model, where near-field fidelity and far-field determinism are separate concerns with separate budgets.
And the fourth signal is design quality, not raw scale. Their strongest exploration moments come from weighted distributions, rare outliers, and diegetic navigation clues instead of constant UI overlays (procedural environment notes, no minimap loop). For us, this is a reminder that technical systems should be tuned to produce discoverable variation, not just maximal throughput.
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