Don't render grass behind the hill: terrain-aware occlusion culling
By Oleg Sidorkin, CTO and Co-Founder of Cinevva
Part 28 introduced Spike 57 and benched its four cull paths in a couple of paragraphs. This is the long version: why those four, what the other four were, and the reasoning behind the one we're shipping.
There's a hill between the camera and a meadow. You don't need to render the meadow. Every modern AAA engine knows this. Most browser-based 3D engines don't, including ours until last week.
This post is the result of doing the research properly, grounding it in our actual codebase, building a working spike, and rating the techniques by how much they help us specifically given that we ship to WebGL today and WebGPU tomorrow.
You can poke at the live spike below before reading on. T/Y/U/I switch between cull paths, C cycles the camera presets, B paints the chunks that get hidden in red wireframe so you can see what the test removes. The HUD reports how many instances each stage rejects.
Open Spike 57 in a new tab ↗ · View source
What our engine does today (and doesn't)
Our streaming chunk manager loads a ring of 64m chunks around the player at three LOD tiers. Each chunk owns instanced trees scattered onto the heightmap. The culling logic, which lives in Chunk.updateObjectVisibility, is a straight distance check: trees within 60m render in full mesh, between 60m and 140m render as billboards, beyond that nothing.
That cull misses two huge categories of waste:
- Anything inside the 60m disk but behind the camera. We re-sort the instance buffer every time the player moves more than 4m, but we don't test the view frustum, so half of the disk on average is fed to the GPU just to be clipped after vertex transform.
- Anything inside the 60m disk but behind a hill. Our terrain has 80m of vertical range and lots of valleys. When the camera is in a valley, most of the foliage inside the cull radius is geometrically hidden by the closest ridge. We draw it anyway.
The second category is the one this post focuses on. It's also the one that AAA engines fix with a stack of cleverness that does not, at first glance, translate cleanly to the browser.
Guerrilla's Gilbert Sanders walks through how Horizon Zero Dawn renders open-world vegetation. The cull stack is the bottom 20 minutes and it's a master class in why "draw less" beats "draw faster."
Eight techniques, rated for our case
I read through the modern occlusion-culling stack and rated each piece by how much it helps a procedural, heightmap-based, in-browser open world. Two axes: value (how much waste it removes given our scene) and cost (engineering effort, plus how much of our stack it forces to change). The full reasoning is in the research file behind this post; here's the short version.
1. Per-instance frustum culling. High value, low cost. Right now we don't test the frustum at all for our instanced foliage. Adding it cuts roughly half the work in any view, costs about thirty lines of code, and ships in WebGL today. This is the single largest cheap win and we should have done it months ago.
2. Heightmap horizon raycast. High value, low–medium cost. March a ray from the camera through each candidate instance, sampling the terrain height along the way. If the terrain ever rises above the ray, the instance is occluded. Works precisely because our world is a heightmap, which reduces visibility to a 1D problem along the horizontal direction. The dense version is O(steps) per instance per frame. The accelerated version (next item) drops it to O(log steps).
3. Max-height pyramid acceleration. High value, medium cost. A 2D mipmap of the heightmap where each texel stores the maximum terrain height inside its footprint. Lets the horizon raycast jump in coarse steps when the surroundings are flat and refine only over hills. This is the structure that makes #2 production-cheap.
4. Hierarchical-Z occlusion (Hi-Z / HZB). High value, high cost. Build a depth-buffer mipmap, project each instance's bounds to screen space, test against the right mip level. This is the modern GPU-side standard, used by Unreal's Nanite, Bevy's virtual geometry, and VTK's WebGPU port. It works for everything, not just terrain, but it requires WebGPU, indirect draws, and a compute pass. Pays off when we're rendering millions of grass blades, not thousands of trees.
5. Two-pass Hi-Z (Nanite-style). Marginal over #4, high cost. Re-render this frame's depth after pass 1 to avoid the one-frame disocclusion artifact. Only worth it once we're far enough along the GPU-driven path that the cost is incremental.
6. Software occlusion rasterizer (Frostbite/Intel MOC). Medium value, high cost. Rasterize a low-res depth buffer of large occluders on the CPU. Zero readback latency. The reference implementations are AVX/SSE C++; porting to WASM is a real project and our heightmap raycast captures most of the same wins for a fraction of the work.
7. Precomputed PVS. Low value, high cost. Beautiful for Quake-era static maps. Our terrain is procedural and infinite, so any preprocessing has to happen at chunk-stream time, which costs about as much as just doing runtime visibility. Skip.
8. Cesium-style horizon culling. None for us, medium cost. Designed for planet ellipsoids. Our world is flat-ish and bounded; the math doesn't apply and would either no-op or false-cull. Skip.
So: do #1 and #2+#3 now, on the CPU, in WebGL. Plan #4 for the WebGPU migration. Skip the rest.
Why heightmap raycast wins for browser terrain
The standard advice in any modern rendering talk is "build a Hi-Z buffer." Brian Karis's SIGGRAPH 2021 deep dive on Nanite is the canonical reference and you should watch it once.
It's the right answer for an engine that already runs everything through GPU-driven indirect draws. Most browser engines, including ours, are not that engine. We have CPU-side instance buffers, WebGL draw calls, and no compute stage. Bolting Hi-Z onto that stack means simultaneously porting to WebGPU, rewriting the vegetation pipeline as indirect draws, and adding a depth-pyramid build pass. That's a quarter of work for the first frame that proves the idea.
The heightmap raycast works on the CPU, in WebGL, with the data we already have. It uses the one fact about our world that AAA engines don't get to use: our occluders are described by a 1D height function. Sampling that function along a ray is two array lookups and a multiply. A Hi-Z buffer would have to discover the same fact pixel by pixel.
The technique was first published as "Horizon Occlusion Culling for Hierarchical Terrains" at IEEE Visualization 2002 (PDF). It's stayed in the toolbox for two decades because it has the right shape: cheap when the terrain is flat, expensive only where there are actual hills, and trivially parallel.
The max-height pyramid, drawn
The naive raycast samples terrainHeight at ~24 points along each ray and breaks early if the terrain crosses it. That's fine for thousands of trees. It collapses at hundreds of thousands of grass blades.
The fix is a mipmap of the heightmap where each texel stores the maximum height inside its footprint:
level 0 (256×256, 2.25m per texel): max h of 4×4 jittered samples
level 1 (128×128, 4.5m per texel): max(0,0), max(1,0), max(0,1), max(1,1)
level 2 ( 64×64, 9.0m per texel): same reduction one level up
...
level 8 ( 1×1, 576m per texel): global maxWhen the ray segment is long and flat, sample a coarse level: one lookup tells you "no terrain in this 9m square ever exceeds 12m altitude, and the ray is at 30m there, so move on." Only when a coarse texel says "terrain might be above the ray" do you descend a level and refine. The whole structure is a few hundred KB and builds in tens of milliseconds.
Diagrammatically:
ray from eye
eye 1.7m o─────────────────────►
o─────────────────────────·─·─·─·─·─·──────────────
│ \ │
│ level 3 (huge step) \ level 0 (refine) │
│ "no terrain above 8m" \ "9m hill here!" │
│ \ │
──────┴────────────/▔▔▔\─────────────/▔▔▔▔▔\─────────────
hill A (8m) hill B (12m)
↑
blocks hereFor the ray segment that runs over hill A's vicinity, the level-3 lookup ("max height in this 18m wide box is 8m") already tells us the ray at altitude 1.7m + a few metres of climb is clear. We skip 36m of march in one query. Over hill B, level 3 says "max here is 12m", we descend, level 0 says "yes, 12m at this exact texel", and we reject the instance.
The pyramid build is in height-pyramid.mjs, and the cull paths that consume it are in cull.mjs. Both are readable in the spike source browser.
What the spike actually shows
Open Spike 57 above and walk through the four paths:
- T0 is what production does today. Distance only. From C1 (valley floor) the HUD reports ~12,000 grass blades visible.
- T1 adds per-instance frustum culling. The visible count drops by roughly half because anything behind the camera or off to the side gets dropped before it reaches the GPU.
- T2 adds the brute-force heightmap raycast. In C1 the number drops by another 60-80%, because most of the field is on the other side of the closest ridge. The Cull-ms column climbs because we're sampling
terrainHeight~24 times per instance. - T3 swaps the brute force for the max-height pyramid. Cull-ms drops back close to T1 while keeping the visibility win. This is the path you actually ship.
The pattern matches what production engines see. Acerola's two-part series on grass rendering (How Do Games Render So Much Grass?, What I Did To Optimize My Game's Grass) is the most accessible walkthrough on YouTube of why the cull stage is where you spend your engineering, not the shading stage.
Camera C3 and the "hilltop" failure case
The spike's third camera preset puts the camera on top of a hill looking out across the playfield. This is the case where horizon culling does almost nothing, because there's no terrain between the camera and most of the world. The HUD shows T2/T3 reducing the visible count by maybe 5-10% over T1.
That's a feature, not a bug. The technique stops working exactly where it should: when there's nothing to occlude. Frustum culling is still doing real work, distance culling is still bounding the budget, and the horizon test gracefully falls back to a no-op. If you implement this you need to verify that the no-op case is also cheap, which is why the max-height pyramid matters even when no rays would be rejected: the coarsest level is often enough to confirm "nothing is blocked."
What we ship next
There are three things to do, in order.
First, port T1 and T3 into the production Chunk.updateObjectVisibility. The pyramid wants to live one level up, on the chunk manager, because it spans more than one chunk. The cull stays in Chunk so the existing per-chunk batching keeps working. Estimated effort: a day, including tests.
Second, do the same for grass once we have grass. The current spike scatters 12,000 blades over a 576m playfield; production density should be roughly an order of magnitude higher. The CPU cull paths handle 12,000 in under a millisecond, and the pyramid acceleration is what keeps that true at 120,000.
Third, when we migrate to THREE.WebGPURenderer, port the same loop into a compute shader. The metadata becomes a storage buffer. The cull writes drawIndirect args. The pyramid uploads as a 2D texture with the max-reduction baked in. The shape of the code stays nearly identical, which is the whole point: we're not betting the migration on a new algorithm, we're moving an algorithm we already have onto a faster runway.
Guerrilla covered the GPU-side version of this for Horizon Zero Dawn's procedural placement system; the rendering pipeline matters less than the data structures, and theirs are the same shape:
Hi-Z will still earn its place once we ship buildings and dense props that occlude in directions the heightmap can't describe. But the heightmap pyramid stays in the pipeline because it's strictly cheaper than Hi-Z for ground-hugging instances, and grass that straddles a ridge silhouette is exactly the case Hi-Z handles worst.
References
The full ranked rationale and the architectural decisions are above; here are the canonical sources for each technique, in roughly the order they appear in the stack.
- Yalim & Akman, Horizon Occlusion Culling for Hierarchical Terrains (IEEE Visualization 2002, the original terrain horizon-cull paper).
- Turitzin, Hierarchical Depth Buffers (the clearest writeup of how Hi-Z mip chains are built and queried).
- VKGuide, Compute-based Culling (GPU-driven culling pipeline reference; the WebGPU port is mechanical).
- Kruskonja, Two-Pass Occlusion Culling (the Nanite-style architecture explained outside Epic's slides).
- Karis et al., Nanite — A Deep Dive (SIGGRAPH 2021) (the modern reference for what GPU-driven cull looks like at AAA scale).
- Karis, HPG 2022 Keynote: The Journey to Nanite (broader context for why the cull stage matters).
- Sanders, Between Tech and Art: The Vegetation of Horizon Zero Dawn (the AAA-vegetation cull stack end-to-end).
- Guerrilla, GPU-Based Run-Time Procedural Placement in Horizon Zero Dawn (instanced placement and per-instance visibility).
- Scthe, Nanite WebGPU (a reference WebGPU implementation, complete with HZB).
- Kitware, WebGPU Occlusion Culling in VTK (the first browser-side production HZB I'm aware of).
- Acerola, How Do Games Render So Much Grass? and What I Did To Optimize My Game's Grass (the accessible YouTube tour).
- RasterGrid, Hi-Z Map Based Occlusion Culling (the still-canonical Hi-Z primer).
- Intel, Masked Software Occlusion Culling (the CPU rasterizer family, if you ever decide #6 is worth it).