Building an open world in the browser, part 19: The imposter that has to survive a forest
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 18 gave creators a brush that fills a hillside with trees. The catch is what those trees cost when there are tens of thousands of them on screen. A distant tree doesn't need 2,000 triangles to contribute four pixels. This part is the deepest LOD: the imposter, a flat quad wearing a photo of the tree, and the road from "one tree that looks right" to a million of them on the GPU.
A tree is two textures on a billboard
Open Spike 38 in a new tab ↗ · View source
An imposter pre-renders a prop from a grid of viewing angles into two texture atlases, one for color and one for world-space normals, then at runtime shows a single camera-facing quad sampling whichever tile matches the current view. The bake is two passes per tile: diffuse with an unlit material so no lighting bakes into the texture, and normals encoded as normalWorld × 0.5 + 0.5 with alpha forwarded from the source so the silhouette matches pixel-for-pixel. The runtime material is a full MeshStandardNodeMaterial, so the imposter still takes the scene's sun and IBL like any other surface. The win is the geometry: one quad instead of thousands of triangles, with the detail living in a 1 MB texture.
Pulling this into its own spike was itself a lesson. The imposter started as the deepest LOD inside spike 37's scatter system, and every iteration on the bake had to be tested through the full scatter pipeline, with bake correctness tangled up in instance-matrix migration and LOD swaps. Splitting it out to one prop, one quad, side-by-side with the original, dropped iteration time from minutes to seconds.
When the textbook answer is the wrong answer
The first implementation used octahedral encoding, the textbook mapping for packing sphere directions into a square. It passed numerical roundtrip tests, and yet the user kept screenshotting the imposter snapped to a tile that looked like the tree from slightly above instead of straight on. Six rounds of fixes followed (a per-quad view-direction uniform, a square bake aspect, debug materials, static billboarding) and every one was genuinely needed but none was the actual bug. The fix only came with "rethink it from scratch, KISS, no band-aids."
The rewrite threw out octahedral folding for plain azimuth by elevation: az = atan2(dir.x, dir.z), el = asin(dir.y), uv = (az/2π, el/π + ½). That's the whole encoding, no L1 normalize, no sign-of-zero corner cases. The reason it's better here isn't that it's more accurate (it samples a less uniform sphere), it's that the CPU cell-picker and the GPU shader use the same primitives, so they can't disagree at a boundary direction the way the octahedral pair quietly did. The atlas reads like a contact sheet: column is the angle around the prop, row is elevation, obvious at a glance in the overlay.
Even after that, the "looks from slightly above" complaint survived, and the cause was a quantization choice, not the encoding. With a 4×4 grid the row centers land at ±22.5° and ±67.5°, so there is no row at exactly 0° elevation. A viewer looking horizontally, the overwhelmingly common case, always falls into a row baked at a tilt. The fix is odd N: a 5×5 grid puts row centers at 0° and ±36° and ±72°, so the horizontal viewer gets a tile baked at exactly horizontal. The same family of "off-by-half-a-cell" mistake shows up again in the next part's parallax work, and the cure is the same question: does my discrete sample point actually land where I think it does for the canonical input?
Two more pieces mattered. The billboard has to be piecewise-static, not continuously facing the camera. The imposter is a flat photo taken from a specific bake direction, so the runtime quad's image plane must match that bake camera's plane, which means it holds orientation across the arc where one cell stays selected, then snaps at the boundary. And the budget should follow where players actually look: a later pass dropped the tilted top-down rows entirely in favor of 24 horizontal ring slots at 15° apart plus one straight-down tile, because trees are viewed from eye height roughly all the time.
The snap, and how blending erased it
Open Spike 42 in a new tab ↗ · View source
The piecewise-static billboard is invisible at distance and pops at close range, which is fine until you orbit. Spike 42 put four variants side by side (the original prop, the az/el 5×5 baseline, and two hemi-octahedral grids) to isolate the flicker and kill it. Two artifacts drive the snap. The cell pop happens because the fragment shader quantizes the view direction into one of 25 cells, so crossing a boundary swaps the sampled tile and re-aims the quad on the same frame. The pole degeneracy is the top-down view, where every azimuth collapses onto one point and the bridge between the ring and the top tile is the worst transition in the atlas.
Hemi-octahedral mapping fixes both. It maps the upper hemisphere onto the unit square continuously, so adjacent 3D directions land at adjacent UVs and there's no pole singularity and no need for a special top-down tile. The flicker cure is bilinear cell blending: instead of snapping to the nearest tile, find the 2×2 group of tiles bracketing the encoded direction and blend all four, 8 texture taps total (4 diffuse, 4 normal). Adjacent views now cross-fade instead of popping. The normal blend of two unit vectors isn't itself unit length, so it gets renormalized, which behaves like a slerp for the small angles between neighboring tiles. The cost is real (a 12×12 atlas is about 9 MB versus the az/el 1.6 MB, and the bake runs roughly 5× longer at 288 render-target passes) but the bake is a one-shot at load and the blend buys a pop-free result, which is what makes imposters usable while the camera is actually moving.
A million trees, one camera-position copy per frame
Open Spike 41 in a new tab ↗ · View source
Spike 38's runtime does one CPU lookAt per quad per frame, which is fine for one tree and fatal for a forest. At a million trees the per-frame matrix updates and instance-buffer upload would dominate everything. Spike 41 moves the entire per-frame pipeline onto the GPU. Each instance's center, yaw, and scale upload once at build time as instanced attributes and never change. The vertex shader builds the billboard basis from the world-space view direction camPos − center and expands a shared unit quad into world space. The fragment shader does the hemi-octahedral encode and the bilinear blend per pixel. The only per-frame CPU work for the whole forest is one Vector3.copy to update the camera-position uniform, which doesn't scale with tree count at all.
A nice piece of the math is that the per-instance yaw cancels out of the normal decode. The bake stores normals in the bake camera's frame, and because a yaw rotation about world up preserves +Y and the cross product is rotation-equivariant, the runtime basis built with a world-up reference already equals the rotated bake basis. So the shader decodes normals straight through the runtime basis varyings without ever touching the per-instance yaw. Placement uses a jittered grid rather than pure random scatter: partition the area into cells, drop one tree per cell at the center plus a bounded offset, which guarantees a minimum spacing (no two trees on top of each other) while still reading as a natural forest. One detail that's easy to miss is the bounding sphere. The geometry template is just a unit quad, so three.js would frustum-cull the whole forest the moment the camera looked away from the origin. Setting an explicit bounding sphere covering the full area plus a quad's margin keeps the corner trees from getting chopped at glancing angles.
Technology referenced in this chapter
Octahedral and azimuth-elevation imposter atlases. An imposter bakes a prop from a grid of view directions into a diffuse atlas and a world-space normal atlas, then renders a single billboard sampling the matching tile, replacing thousands of triangles with two textures. The textbook octahedral mapping gives uniform sphere coverage but is prone to CPU/GPU divergence at fold boundaries; a plain azimuth-by-elevation grid samples a less uniform sphere but guarantees the cell-picker and shader agree by construction. Use odd N so a row lands at exactly 0° elevation, and spend the tile budget on the horizontal ring since props are mostly viewed from eye height.
Piecewise-static billboard orientation. An imposter is a photo from a specific bake direction, so the runtime quad's image plane must match the bake camera's plane, not continuously face the runtime camera. The quad holds orientation across the arc where one cell stays selected, then snaps at the boundary, which is invisible at imposter distance and only pops up close where imposters aren't used.
Hemi-octahedral atlas with bilinear cell blend. Mapping the upper hemisphere onto the unit square continuously removes the pole singularity and the special top-down tile. The snap is killed by sampling the 2×2 tile group bracketing the encoded view direction and bilinear-blending all four tiles (8 taps), so adjacent views cross-fade. Blended normals are renormalized, approximating a slerp over the small inter-tile angle. Cost is a larger atlas (about 9 MB at 12×12) and a longer one-shot bake, traded for pop-free shading under camera motion.
GPU-driven instanced imposters. Per-instance center, yaw, and scale upload once as instanced attributes; the vertex shader builds the billboard basis and expands a shared unit quad, and the fragment shader does the encode and blend per pixel. Per-frame CPU cost for the whole forest is a single camera-position uniform copy, independent of instance count. Per-instance yaw cancels out of the normal decode because yaw about world up is preserved by the cross-product basis construction. An explicit forest-wide bounding sphere prevents three.js from frustum-culling the entire instanced mesh when the camera looks away from the unit-quad template's origin. See GPU-driven LOD.
Part 19 of 29. Previous: Part 18 - A scatter brush that feels AI-placed Next: Part 20 - Faking depth on a flat plane Series guide: /blog/2026-02-25-open-world-browser-series-guide