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
// 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
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.
// 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.
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
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.
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.
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
Foliage.bend(node: Node, radius = 0.7): () => void // returns the unregister function
Foliage.unbend(node: Node): voidA 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
Foliage.fade(model: Model, start: number, end: number): void // metres from the camera; end 0 = noneBetween 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: truewhose 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.bendon 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.loadoptions, clones, LOD - scene-files.md —
foliageon a node,env.foliage