Sep 1, 2026

No visible pop-in: dissolving a streamed forest

  • 15 min read

Question: Day Hike streams an endless forest around the player in a browser, with Babylon.js on WebGL2. The standing rule for its scenery is that the player must never see anything spawn: every tree, bush and tuft of grass in view must already be there. Level of detail, instance budgets and streaming all work against that rule. How do you turn "never visibly spawns" into something a renderer can check, and what does it cost?

Short answer: the rule becomes arithmetic. At the game's field of view, a 1080p frame has about 770 pixels per radian, so every place where an instance appears, disappears or changes shape has a size in pixels, and that size must be small. Every such edge becomes a dissolve band: a fragment-stage screen-door dither driven by interleaved gradient noise, evaluated per frame from the true eye distance. Where two versions of a tree meet, the outgoing and incoming draws keep complementary halves of the noise, so every pixel is drawn exactly once. Band membership is padded by the most the streaming grid's snap can move a distance, so the snap never shows. The trap is discard: putting it into opaque materials disables early depth rejection and cost 10 to 20 ms at 8× resolution, so the dither is confined to materials that already alpha-test, and opaque props hand off geometrically at a size of a few pixels. Shadows are the one place the dither cannot reach.

The shipped mechanism is distanceFadePlugin.ts in the public game repository; the band tables are in forestField.ts and clutterField.ts.

Nothing hides in haze close to the player. Before height shaping, the base fog term is exponential-squared and is solved for 5% transmittance at 4 km in clear weather, so at 1 km a tree keeps 83% of its contrast and at 2 km it keeps 47%. Popping cannot be buried in fog below a couple of kilometres.

The camera's vertical field of view is 1.4 radians. Spreading 1080 rows over that gives about 770 pixels per radian, so an object of height h at distance d is roughly h / d × 770 pixels tall. That is the one formula every number below uses. It is slightly generous: at the centre of the screen a perspective projection gives about 640 pixels per radian, so the true sizes there are about a fifth smaller, which errs on the safe side.

With that formula, "never visibly spawns" becomes a question with an answer for each class of scenery: at the distance where this instance appears, disappears or swaps meshes, how many pixels tall is it? The answer decides the mechanism:

  • Things with a silhouette (trees, saplings, snags) must exist until they are close to sub-pixel, or at least small and hazed, and every change on the way there must dissolve.
  • Carpet-like things (meadow, flowers, grass) may end where a tuft is a few pixels, as long as the edge dissolves into the ground texture rather than stopping at a line.

Before this work, the numbers were bad in several different ways.

Source Mechanism What the eye saw
Three giants in four The billboard band from 120 m to 2 km sampled one tree cell in four. The other three trees were not drawn at all until they entered the 120 m near band. A 41–70 m tree appearing from nothing at 120 m: 260–450 px tall
The fourth giant Billboard to low mesh at 120 m, to mid mesh at 85 m, to full mesh at 42 m, all hard swaps Silhouette and shading jumping three times on the approach
Saplings and snags Near band only, no billboard An 11 m sapling appearing at 120 m: 70 px. A 6–8 m snag: 40–50 px
Understory Ferns and shrubs inside 65 m, hard edge A 1 m fern appearing at 65 m: 12 px
Meadow carpet 20 m disc; the last 6 m shrank each tuft toward the ground Grass growing out of the soil 14–20 m ahead; a tuft at 17 m is 13 px
Bushes, flowers 45 m and 35 m discs with shrink ramps A 1.5 m bush visibly shrinking at 40 m, about 29 px

Hard LOD swaps read as spawning because a swap is a change of shape at a size the eye resolves easily. A tree at 85 m is still more than 50 px tall; switching its mesh changes its outline and its shading in one frame, and in motion the eye catches that as something happening.

The shrink fade read as spawning too. It had replaced hard pops at clutter disc edges in an earlier round: a vertex-stage plugin scaled each instance toward its base point over the last fifth of its disc (the last 30% for meadow). It worked exactly as designed, and what it designed was growth. A tuft that rises out of the ground as you walk toward it is a tuft spawning, only slower. Scaling was the right place for the fade (it had to live in the shader, because instance matrices are rewritten only when the camera crosses a streaming cell, so a CPU-side ramp would step every few metres), but the wrong effect.

The replacement is one material plugin that every fading instance uses. It changes visibility, never size.

The vertex stage computes one number per instance: the horizontal distance from the eye to the instance origin, distance(finalWorld[3].xz, fadeEye.xz). Using the origin rather than the fragment's own position means a whole tuft or tree fades as one unit, and ignoring height means a mesh standing on its base point and a billboard centred higher up, placed at the same ground position, compute the same distance.

The fragment stage runs at the very start of main, before any texture fetch, so a dropped fragment costs nothing else:

float dfN   = dfNoise(gl_FragCoord.xy);
float dfIn  = smoothstep(vFadeBands.x, vFadeBands.y, vFadeDist);       // 0 before the in-band, 1 after
float dfOut = 1.0 - smoothstep(vFadeBands.z, vFadeBands.w, vFadeDist); // 1 before the out-band, 0 after
if (dfIn < 1.0 - dfN || dfOut < dfN) discard;

dfNoise is interleaved gradient noise (IGN), from Jorge Jimenez's 2014 post-processing work on Call of Duty: Advanced Warfare:

fract(52.9829189 * fract(0.06711056 * x + 0.00583715 * y))

It maps each pixel coordinate to a value in [0, 1) with neighbouring pixels spread across the whole range, so thresholding it at v keeps close to a fraction v of the pixels in any small patch, evenly scattered rather than in clumps. It needs no texture and costs one line. Because it is a fixed function of the pixel position, the pattern is pinned to the screen and needs no temporal filtering. Scenery does slide under a screen-fixed pattern as the camera moves, the effect Return of the Obra Dinn had to fix for its whole image, but a dissolve band covers a few to a few tens of pixels for a moment, so it goes unnoticed.

The bands are per instance, not per material. fadeBands is a four-float thin-instance attribute (in-band start and end, out-band start and end), written beside each instance's matrix. It has to be per instance: billboards inside and outside 1 km share one material per species but fade out at different ranges (section 6), and a tree drawn by two LOD buckets carries each bucket's bands. For every clutter class the value is constant across the bucket, so the cost is 16 bytes per instance. A band that should never fire is (-2, -1) for the in-band and (1e8, 2e8) for the out-band, never two equal edges, because GLSL leaves smoothstep(e, e, x) undefined.

Wherever one representation of a tree hands over to another (full mesh to mid mesh, mesh to billboard), the tree is drawn by both buckets across a band, and their fades run in opposite directions. Both draws hit the same pixels and read the same noise value there, because the noise depends only on gl_FragCoord. That shared noise is what makes an exact cross-fade possible, and also what makes a naive one fail.

The naive version computes one visibility v = dfIn * dfOut and keeps a fragment when n < v. At a seam, the outgoing bucket keeps n < 1 − s and the incoming bucket keeps n < s, where s is the smoothstep across the band. Both sets start at zero: they are nested, not complementary. At mid-band, where both visibilities are 0.5, the pixels with noise below 0.5 are drawn twice and the pixels above 0.5 are drawn by neither, so half the tree is a hole. An earlier version used exactly this predicate, and the mid-band speckle it produced looked plausible in still images.

The fix tests each term on its own side of the noise:

  • the outgoing (out-band) term keeps a fragment when dfOut ≥ n;
  • the incoming (in-band) term keeps a fragment when dfIn ≥ 1 − n.

At a seam both buckets use the same band edges and the same distance, so dfOut = 1 − s for the outgoing bucket and dfIn = s for the incoming one. The outgoing bucket keeps n ≤ 1 − s; the incoming bucket keeps n ≥ 1 − s. Those two sets partition [0, 1): at every pixel and every point in the band, exactly one bucket draws. No hole at mid-band, nothing drawn twice, and the tree's coverage never changes during the handover. A pure TypeScript mirror of the predicate (fadeKeeps) sits beside the shader so the partition can be tested numerically rather than judged from screenshots.

The fade is evaluated per frame from the true eye position. Band membership (which buckets a tree is emitted to) is not: the forest's CPU side walks the tree grid around a cell-snapped origin and rebuilds only when the camera crosses a 10 m tree cell. Between rebuilds, the snapped origin can sit up to √2 × 10 m, about 14 m, from the real eye.

If membership used the snapped distance against the exact band, a tree whose true distance was inside a seam band, but whose snapped distance was just outside it, would be in only one bucket. The shader would then fade it out with nothing fading in, or fade it in from nothing: a pop exactly where the band was meant to hide one.

The fix decouples the two. The shader's band keeps its designed width; the field emits a tree to both neighbouring buckets whenever its snapped distance is within the band padded by √2 × the cell size on each side, the most the snap can shift it. A padded duplicate that turns out to be outside the true band is at visibility 1 in one bucket and 0 in the other, so it costs its vertices but still covers each pixel exactly once. The same rule pads the outer edge of every disc, so a tree that joins the list because of the snap is already at visibility 0.

The padding is not free. The near band's flat list, duplicates included, runs about 47–49% over its unique trees at the cameras scanned in September 2026, and padding the understory disc raised its instance count 1.48×. Clutter uses the same idea at its own 3 m cell: every fade ramp has a floor of √2 × 3 m, about 4.2 m, so a narrow ramp on the low quality tier (which scales every radius by 0.6) cannot become narrower than the snap jitter it has to hide.

Every forest seam is now a band:

Seam Band (m) Fades out Fades in
Full mesh / mid mesh 36 → 42 full mesh mid mesh
Mid mesh / low mesh 76 → 85 mid mesh low mesh
Low mesh / billboard 100 → 120 low mesh billboard
Every cell / one cell in four 900 → 1000 billboards not on the thinned grid nothing (the thinned grid is already drawn)
Billboard / nothing 1800 → 2000 billboard nothing
Understory edge 52 → 65 ferns and shrubs nothing

Saplings and snags now get billboards of their own and ride the same bands as the giants.

The billboard grid changed too. Beyond 1 km the band still samples one tree cell in four, but inside 1 km it samples every cell. A 41–70 m giant is 32–54 px tall at 1 km, which is clearly resolvable, so the three missing trees in four would otherwise appear from nothing there; the extra trees instead dissolve out across the 900 to 1000 m band. At 2 km the same giant is 16–27 px and has lost about half its contrast to haze, so the outer band only has to finish erasing an already faint shape.

For clutter, each class dissolves over the last fifth of its disc (the last 30% for meadow), and its near-to-far mesh switch at 45% of the radius is also a band, closing a swap pop that the shrink fade had left alone. With the shipped radii:

Class Height Ends at Size there Handover
Meadow carpet ~0.3 m 40 m (was 20) 6 px dissolve
Flower ~0.4 m 50 m (was 35) 6 px dissolve
Grass ~0.4 m 110 m 2.8 px dissolve
Bush 1.5 m 110 m (was 45) 10 px dissolve
Understory ~1 m 65 m 12 px dissolve
Rock ~0.4 m 110 m 2.8 px opaque: disappears at that size
Driftwood ~0.5 m 110 m 3.5 px opaque: disappears at that size
Fungus ~0.15 m 70 m 1.7 px opaque: disappears at that size
Boulder ~2 m 400 m 4 px opaque: disappears at that size

Two classes are compromises and are named as such: bushes at 10 px and understory at 12 px dissolve at sizes that are visible, among grass and under canopy respectively. The levers, if they are ever needed, are a billboard for bushes and a wider understory disc (65 to 90 m would cost 1.9× the instances). The opaque classes do not dissolve at all, for the reason in the next section.

Widening the discs was only affordable because the edges now dissolve. The meadow budget went from about 2,700 instances to 10,600, bushes from 445 to 2,500, and the forest draws 1.68× as many giants as before at a dense test viewpoint (the estimate had been 1.75×). The billboard budget is two clamps: 26,000 for the thinned grid and 20,000 for the every-cell fill inside 1 km, split so a dense fill cannot starve the outer grid or the reverse.

The first build attached the dither to every material in the scene, and frame time went up far more than the added instances could explain.

How it was measured. Paired back-to-back samples of 300 frames against the previous build, in both orders, three rounds, at two viewpoints: a roadside and an open meadow. The page rendered at 5485×3085, 8.16× the pixels of 1080p, so fragment costs rise well above noise. At native resolution both builds held a vsync-locked 16.67 ms at both viewpoints.

With the dither on every material: every pair was positive, so the gap was a real fragment cost, not drift.

Viewpoint Round 1 Round 2 Round 3 Mean
Roadside +9.1 ms +10.5 ms +12.6 ms +10.7 ms
Meadow +22.4 ms +23.1 ms +15.2 ms +20.2 ms

Bisecting the instance counts in place changed nothing. With the meadow carpet back at 20 m, all three widened radii reverted, and the 1 km billboard fill switched off, the meadow gap stayed at about +20 ms.

The cause was the plugin itself. A discard anywhere in a fragment shader means the fragment's depth cannot be written before the shader has run, so the GPU stops rejecting hidden fragments early for that draw, and tile-based GPUs lose their equivalent hidden-surface removal too (unverified: inferred from the measured cost, not from a citable primary source). The dither had put a discard into every opaque material: bark, rock, boulder, driftwood and fungus. In a dense forest, trunks hidden behind other trunks were being shaded in full.

The fix: attach only where a discard already exists. Alpha-tested materials (foliage and grass cards, flowers, bushes, understory, the billboards) discard anyway, so the dither's extra discard costs them almost nothing. Restricting the plugin to those, 18 of the scene's 44 materials, brought the gap to +1.4 ms at the roadside and +9.6 ms at the meadow in the first experiment. Clean pairs on fresh pages then measured:

Viewpoint At the fix After the seam and padding fixes Native-equivalent, after
Roadside +1.37 ms (±0.2) +1.99 ms about +0.24 ms
Meadow +6.27 ms (±0.8) +8.83 ms about +1.08 ms

The roadside figure is at the noise level of earlier rounds on the same machine. What is left at the meadow is the widened carpets' own alpha overdraw, plus more billboards and the seam duplicates; the later rise of about 2.5 ms at 8× (0.3 ms native) matches the one change that adds fragments there, the padded understory. The attach function enforces the rule: attachDistanceFade returns early unless material.needAlphaTesting() is true.

Opaque materials hand off geometrically instead. Mesh to mesh at the LOD rings, and at their disc edges at the sizes in the table above: rock 2.8 px, driftwood 3.5 px, boulder 4 px, fungus 1.7 px. There is one forced exception: the deadwood material, which is opaque, dithers anyway, because a snag's low-mesh-to-billboard handover at 120 m is 40–50 px and visibly pops. It covers about 24 instances, and it was judged worth the cost in the browser rather than by the pixel formula.

One measurement lesson came out of the bisection. The absolute gap in the bisection runs (+11 to +26 ms) was larger than the clean run's +6.3 ms because it grew each time the new build's page was reloaded while the old build's numbers stayed put: each load baked five billboard render targets. Comparisons between configurations need a fresh page for each configuration.

Sampling every tree cell to 1 km was the one change whose cost was not obviously negligible, so it had a planned fallback: sample every cell only to 600 m, with the same dissolve band at its edge. A 41–70 m giant is 50–90 px at 600 m, so the fallback would have been an honest dissolve into canopy at a size clearly worse than 1 km.

It was measured rather than assumed. With the fill radius at 1000 m, 600 m and switched off, interleaved twice each in one session at the meadow, the frame-time gap moved by 3 ms or less out of about 26 ms at 8× resolution. Neither the carpets nor the fill dominated the remaining cost; it was spread across everything the design added. The 600 m fallback was not taken, and the every-cell radius stays at 1 km.

The dither never reaches the shadow map. Babylon.js renders shadow depth with its own shadowMap shader, and that shader has no hook inside its main function for material plugin code (checked against the installed @babylonjs/core 9.18.0 source), so plugin discards do not run there. A shadow caster's shadow is therefore all or nothing: it follows membership in the caster list, not the dissolve.

In practice this matters little because very few things cast. Only the giants' full meshes (inside 42 m) and boulders are shadow casters; mid and low meshes, billboards, saplings, deadwood and understory never cast. The one visible artefact is a giant's shadow that ends at the full-mesh seam while its mesh dissolves. It existed before this work and the work did not change it.

The route to fix it is ShadowDepthWrapper, covered in Babylon.js material plugin traps. It was left out here because it recompiles every wrapped material and would need its own frame-time measurement.

Every check above is a still image, a count or a frame-time sample. "Nothing spawns" is a claim about walking, and only motion judges it. The places to look are the ones the arithmetic names: the far rim of the meadow carpet, which should thicken as speckle and never grow; the forest edge at 100 to 120 m, where a tree should never appear whole; bushes near 100 m and ferns near 60 m, the two compromise classes; and the opaque handovers (a trunk changing to its billboard at 120 m, a boulder vanishing at 400 m), which are a few pixels by the formula.

Source Covers
Next Generation Post Processing in Call of Duty: Advanced Warfare (Jimenez, SIGGRAPH 2014 Advances in Real-Time Rendering) Interleaved gradient noise
distanceFadePlugin.ts The dither, the partition predicate, the alpha-test-only attach rule
forestField.ts Forest seam bands, snap padding, billboard budgets
clutterField.ts Clutter radii, fade fractions, the ramp floor
forestMeshes.ts Shadow casters, the deadwood exception
Installed @babylonjs/core 9.18.0 source: Shaders/shadowMap.fragment.js, Materials/shadowDepthWrapper.js Section 9

Cyrus Sarkosh

Senior software engineer, founding engineer and lead on several zero-to-one products at DoorDash.