Foliage

Grass, bushes and trees that sway in the wind, bend away from whatever walks through them and fade out with distance: a model loaded with foliage: true takes the vegetation shader, and the copies of one asset share one material, so thousands of plants draw cheaply. It is a tier of the lightmap family; the ground they grow on is a terrain.

  • make grass and trees sway in the wind
  • make grass bend when the player walks through it
  • fade out grass in the distance
  • change the wind speed and direction, or start a storm
  • give each copy of a plant a slightly different colour
  • make leaf and grass cards cheaper to draw

Platforms: iOS, Android, desktop, the web player, the le.codes viewer app; on the web (Web Lite) and in headless tests a foliage model loads as a plain model and every Foliage call does nothing.

At a glance

TypeScript
// level.scene.ts — the grass loads as foliage; env.foliage is the level's wind
import { Hero } from './hero'

export default defineScene({
  env: {
    foliage: { wind: { direction: [1, 0.3], speed: 6, gust: 0.4 }, touch: 0.6, variation: 0.35 },
  },
  nodes: {
    grass: { model: asset('./assets/grass.glb'), foliage: { fade: [30, 40] } },
    player: { mesh: { kind: 'capsule' }, position: [0, 1, 0], aspects: [use(Hero)] },
  },
})

// hero.ts — the capsule parts the grass
export class Hero extends Aspect<'hero', Node> {
  onAttach(): void {
    Foliage.bend(this.node, 0.6)
  }
}

Load a model as foliage — foliage: true

TypeScript
Model.load(src, { foliage: true }): Promise<Model>     // code
{ model: asset('./grass.glb'), foliage: true }          // a scene-file node
{ model: asset('./grass.glb'), foliage: { fade: [30, 40] } }   // + a distance fade (below)

A foliage model needs no lightmap: true and no atlas rect of its own: its baked light is the ground's under each pixel (a baked terrain), and without a bake the scene's sky ambient and the sun light it. Clones inherit the tier. lecodes assets scene marks a Unity kind foliage: true on its own when one of its materials sits in a vegetation folder or is named so (Materials/Trees/…, M_Grass_01), never by alpha mode; --foliage <re> names the kinds explicitly.

The tier is chosen per asset: every alpha-MASK material (the cards) takes the masked twin, the opaque ones (trunks) the opaque one, and both sway.

TypeScript
// in code: grass as foliage that fades out past 40 m, and a storm on demand
const scene = new Scene()
scene.add(Light.sun())
const main = async () => {
  const grass = await Model.load(asset('./assets/grass.glb'), { foliage: true })
  Foliage.fade(grass, 30, 40)       // every copy of grass.glb thins out from 30 m, gone past 40 m
  scene.add(grass)
  scene.open()
}
main()
const startStorm = () => Foliage.wind({ speed: 20, turbulence: 1, gust: 0.8 })

Everything Foliage sets is level-wide state the engine keeps: the wind and the look options apply to every foliage model, present and future; benders are read from their live transforms each frame; a fade belongs to an asset, so every copy of that model shares it.

Host-gated

a host whose runtime carries no foliage material loads the model through the lightmap tier when it is also a lightmap static (lightmap: true, a static node of a baked scene), else the standard shader; a host without the foliageSet* bridge ignores every Foliage call. The desktop host has both.

Sway plants in the wind, leaf lighting — Foliage.configure / Foliage.wind

TypeScript
Foliage.configure(options: {
  wind?: FoliageWind | false   // below; false = calm
  touch?: number        // m the top of a small plant leans away from a bender at its centre; default 0.6, 0 = none
  variation?: number    // 0..1 per-copy tint drift; default 0.35
  sunWrap?: number      // 0..1 wrapped sun lighting (thin leaves let light through); default 0.5
  shiver?: number       // × every material's shiver drag (the leaves' own motion); default 1, 0 = none
  translucency?: number // 0..1 the sun through a thin leaf lit from behind (a back-lit crown glows); default 0.35
  leafShadow?: number   // 0..1 how much of a light's shadow a leaf card takes (bark takes all of it); default 0.6
  skyAo?: number        // 0..1 how far the baked per-vertex sky visibility darkens the ambient; default 0.7
  canopy?: number       // 0..1 a card's shading normal: its own plane (0) … the crown's hull (1); default 0.7
  normalMap?: number    // the cards' normal-map strength; default 0.5
  edgeFade?: number     // a card seen edge-on dissolves over this much of |N·V|; default 0.15, 0 = none
  coverage?: number     // alpha × (1 + coverage × mip level): a far crown does not thin out; default 0.25
}): void
Foliage.wind(wind: FoliageWind | false): void   // the wind alone, live

type FoliageWind = {
  direction?: [x, z]    // on the ground, normalised by the engine; default [1, 0.3]
  speed?: number        // m/s — the plants' permanent bend and how fast the field drifts; default 8.3, 0 = still
  turbulence?: number   // 0..2 the random share of the speed; default 0.4
  gust?: number         // 0..1 the gusts' strength; default 0.35
  gustSpeed?: number    // m/s the gust pattern travels at; default 5.6
}

The wind is a field, not a sine: a scrolling noise field read at each plant's root bends the whole plant about its root — the angle grows with the distance from the root, so a spruce's crown moves further than a blade of grass — and a finer field read at each vertex moves the leaves (the shiver). There is no fixed period: turbulence turns a plant off the mean wind, the gusts ride a slow third field and travel over the level at gustSpeed. What differs per plant comes from the model: its materials' drag, stiffness, permanent lean and shiver drag ride the GLB's material extras (lecodes.wind, written by lecodes assets scene from the Unity material), else a default by the plant's size.

Every field given replaces the current value; the others stay. A scene file's env.foliage block goes through configure before the models load. All of it is live — Foliage.wind({ speed: 20 }) from the console tries a storm.

Note

the tier reads a vegetation GLB's COLOR_0 as DATA, never as a tint: (sky visibility, bark cavity, 1, wind weight). lecodes assets scene bakes it on every vegetation kind at import (the sky's visibility from each vertex — a crown's interior and the trunk under it take less ambient; the wind weight is the pack's own, what a tree pack bakes as trunk 0 / needles ~0.4, or the height over the plant squared). The shiver is weighted by that wind weight; a model without vertex colours gets no occlusion and the height weight: the tips move, the base never.

Note

the sway runs on the engine's wall clock, so it keeps moving while the game clock is paused — a pause menu over a swaying forest, by design.

Grass that bends when the player walks through it — Foliage.bend

TypeScript
Foliage.bend(node: Node, radius = 0.7): () => void   // returns the unregister function
Foliage.unbend(node: Node): void

A bender is a node whose live world position pushes the vegetation: the hero's capsule, an enemy, a rolling boulder. A plant leans away as a whole — rooted at its base, full at its top — when the bender comes within radius of the plant's footprint. The engine measures each asset when it loads: plants under ~2 m get the full lean, the reaction fades out by ~5 m, so grass and bushes part around you and a tree does not twist when you brush its trunk.

The engine also tracks each bender's velocity (smoothed over ~0.2 s) and, near a plant's centre, leans it along the direction of travel instead of straight away from the bender: a bush you run through is brushed forward and settles behind you rather than flipping as you cross its middle. A bender that stands still inside a plant leaves it nearly upright.

Any number of nodes may be registered; the 8 nearest the camera act each frame. A destroyed node drops out on its own.

Fade out grass in the distance — Foliage.fade

TypeScript
Foliage.fade(model: Model, start: number, end: number): void   // metres from the camera; end 0 = none

Between start and end the cards thin out (alpha falls under the mask cutoff texel by texel), and past end the copy leaves the frame entirely — the LOD pass takes it out of the scene, so it costs no draw. Meant for ground cover on a free camera: grass and clover at [30, 40], nothing on trees. The range belongs to the asset: every copy of that model, present and future, shares it; in a scene file foliage: { fade: [start, end] } on one node sets it for all. A camera that never sees past the range (a top-down game) gains nothing from it — off-screen copies are already culled.

A different colour per plant copy — variation

The tint drift (variation) comes from a hash of each copy's position inside the shader, not from a per-copy parameter: the copies of one asset keep sharing one material instance, which is what lets the engine draw thousands of them cheaply. Turn it down on a species where the texture already carries the colour range.

Cheaper leaf and grass cards — lecodes assets doctor --trim-cards

A foliage card is mostly transparent texels the masked shader discards after shading them, and on a level of thousands of cards that discarded fill is the frame. --trim-cards [--trim-margin N] cuts every alpha-MASK quad to the box of its opaque texels (+ N texels, default 2) and drops empty quads — nothing visible changes. It reads PNG/JPEG base colour, so run it together with --ktx2 rather than after it (lecodes assets scene does, on every kind).

Pitfalls

  • A model with foliage: true whose materials read as standard-shaded in the host log ([glb] material … -> ubershader) took the standard shader: either a material the tier declines (the rules of lightmap.md "Which materials" — a [lightmap] … line names what it lacks) or a host without the tier. It is not a scene-file mistake.
  • Foliage.bend on the hero's model rather than its controller capsule follows the visual root, which may lag or lead the capsule; register the node that physics moves.

See also

  • lightmap.md — the family this tier belongs to: baked shadows, the light volume, tiers
  • terrain.md — the ground whose baked light the plants take
  • model.md — Model.load options, clones, LOD
  • scene-files.md — foliage on a node, env.foliage