Skip to content

Building an open world in the browser, part 28: Grass to the horizon, and ground that hides itself

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.

Part 27 built the island and gave it ground that doesn't look tiled. This part covers the two things that make terrain feel inhabited rather than empty. Spike 56 is grass, the surface detail that turns a textured slope into a place you'd walk through, and the problem is making a field read as a field instead of scattered streaks. Spike 57 is the opposite of drawing more: it's about not drawing what a hill is already hiding, and the interesting result is that the fast way to test for it turned out to be the correct way too.

Grass that reads as a field, not confetti

Open Spike 56 in a new tab ↗ · View source

The core decision is geometric. A single thin tapered blade is sub-pixel from most camera angles, so half a million flat blades read as sparse green confetti rather than a field. The fix is a cross-quad clump: three tapered quads rotated sixty degrees apart around the local up axis, so from any viewing direction at least one quad sits nearly perpendicular to the camera and each instance covers roughly three blade-widths of actual screen area. That's the difference between seeing green streaks and seeing grass.

A WebGPU compute kernel places every clump once at init. It hashes the instance index into five decorrelated random streams, picks an XZ position inside the patch, samples ground height from the same FBM the CPU ground mesh uses (a TSL port with sign-preserving mod so the values match exactly and blades sit on the surface rather than floating above it), takes a central-difference normal, and rolls a per-clump width, height, and hue. The render side is a TSL vertex graph that bypasses the instance matrix entirely and writes straight to clip space: it scales the unit cross-quad, rotates local up onto the ground normal with Rodrigues' formula, and translates to the clump position. Distance LOD comes for free without a culling pass, because the vertex graph multiplies the clump's height by 1smoothstep(fadeNear,fadeFar,dist), so far clumps collapse flat to zero height and stop costing fill. Wind is two octaves of sin and cos over the clump's world XZ plus time, applied horizontally and gated by the height fraction so the base stays anchored while the tips move, and a tiny camera-aware lean tips each clump toward the viewer so they open up perspectivally instead of reading as flat cards.

The color recipe is borrowed from NedMakesGames' Breath of the Wild URP shader: flat shading with the blade color a lerp from a root tone to a tip tone along the height fraction, where the root and tip tones are themselves lerped between two palettes by the per-clump hue value. That gives the dappled two-tone look a real BotW grass field has. Diffuse uses the ground normal against the sun with an ambient floor, because the cross-quad's per-quad normals are too noisy to shade individually for a stylized look. The whole field is one draw, placed and animated entirely on the GPU.

Four ways to cull what a hill is hiding

Open Spike 57 in a new tab ↗ · View source

Spike 57 benches four culling paths over the same procedural world the production client streams, scaled 1.8x vertically so hills actually occlude foliage. T0 is distance-only, matching what the shipping chunk visibility already does. T1 adds frustum culling, the single biggest cheap win, dropping everything outside the view cone. T2 adds a terrain-aware horizon test: march a ray from the eye to each instance and reject it if the heightmap rises above the ray anywhere along the way, so a tree behind a ridge gets culled even though it's inside the frustum. T3 keeps the same horizon test but accelerates it with a max-height pyramid, and T4 ports the whole distance-plus-frustum-plus-horizon test to a TSL compute kernel that writes a per-instance visibility scale the foliage material reads to collapse hidden vertices. A two-frame stay-visible counter smooths the single-frame flicker when a sample point lands just inside or outside a texel as the camera nudges, kept short on purpose because anything longer masks algorithmic bugs instead of fixing them.

The pyramid is where the spike earns its keep, and the lesson is about matching the test to what's actually on screen. The rendered terrain is a triangle mesh with vertices on a fixed two-meter grid, and between vertices the rasterizer interpolates linearly, so the real rendered height inside any rectangle is the max of the vertices inside it, never the continuous noise peak between them. If the occlusion pyramid samples the noise at a finer grid, it finds phantom peaks the mesh never displays and starts blocking rays the camera can plainly see through. So the pyramid's base texels sit exactly on the vertex grid, each storing the max of its four corner vertices, and upper levels are textbook 2x2 max reductions, which makes it exact with respect to the rendered mesh. For the per-step ray test the code deliberately point-samples the mesh with bilinear interpolation rather than querying the pyramid, because a sub-texel AABB query returns the whole texel's max and inflates the height by several meters on steep ridges, which is exactly the over-occlusion that would hide visible props.

The surprising result is that T2, the brute-force reference, is the one that's wrong. Because T2 point-samples the continuous noise directly, it catches peaks between mesh vertices that don't render, so it over-occludes slightly and hides foliage the player can actually see. The pyramid path is both faster, because of the O(logN) AABB reduction for chunk-level tests, and more geometrically correct, because it can only ever return heights the mesh truly displays. That's the spike's whole point: the accelerated structure isn't a quality compromise you accept for speed, it's the version that matches reality. Chunk culling runs a five-point test per chunk (four top corners plus center at the chunk's max height) and excludes each chunk's own footprint from the occluder set so a chunk never occludes itself. The recommended production path is T4: push the instance metadata and height field into storage buffers and run the identical loop in a compute shader with an indirect draw, since the renderer is moving to WebGPU anyway.

Technology referenced in this chapter

Cross-quad GPU grass. Three tapered quads rotated sixty degrees apart per clump guarantee one near-perpendicular quad from any angle, so half a million instances read as a field rather than sub-pixel confetti. A compute kernel places every clump (FBM ground height sampled with the same sign-preserving mod as the CPU mesh, central-difference normal, per-clump size and hue), and a TSL vertex graph bypasses the instance matrix to scale, Rodrigues-rotate onto the normal, translate, and apply height-gated wind. Distance LOD is free: clumps collapse to zero height by 1smoothstep(fadeNear,fadeFar,dist). Color follows NedMakesGames' BotW recipe, a root-to-tip gradient over two hue-mixed palettes. See GPU-driven LOD.

Terrain horizon occlusion culling. Four benched paths over the production world: distance, plus frustum, plus a heightmap ray test that rejects instances behind a ridge, plus a max-height pyramid that accelerates it, plus a TSL compute port. The pyramid samples on the exact two-meter vertex grid the mesh tessellates on (base texel = max of four corner vertices, 2x2 max-reduce upward), so it returns only heights the rasterizer actually displays.

Accelerated and correct, not a trade. Point-sampling continuous noise (the brute-force T2 path) finds peaks between mesh vertices that never render, over-occluding visible foliage. The vertex-grid pyramid is both faster (O(logN) AABB reduction) and geometrically exact, so per-step ray tests bilinear-sample the mesh rather than querying the pyramid's per-texel max, which would inflate heights by meters on steep ridges. Chunk culling uses a five-point test and excludes each chunk's own footprint so it never self-occludes. The production path pushes metadata and the height field into storage buffers for a compute-shader cull with indirect draw.


Part 28 of 29. Previous: Part 27 - An island from noise, ground that looks like ground Next: Part 29 - One controller, any body Series guide: /blog/2026-02-25-open-world-browser-series-guide