Forest
20 000 trees sway in gusts of Perlin noise from @typegpu/noise. An instance kind deforms them within declared bounds. GPU culling runs each frame, drawing trees visible to the camera and shadows visible to the sun.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- air.ts
- ground.tsLays a mossy plain
sizemetres across, drawn bydrawing. - trees.tsAdds the tree meshes to
pool: a trunk centred vertically, and crowns with their bases at 0.
Kit modules bloom, cameras, environment, finish, instances, lighting, materials, meshPool, owner, passes, pipelines, shading, targets. Also @typegpu/color, @typegpu/noise, math/noise, math/random, typegpu.
scene.ts
import { bloom, cameras, finish, instances, lighting, meshPool, owner, passes, pipelines, targets, type Frame, type Inject, type Surface,} from '@typegpu/kit';import { d, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { aerial, sky, SUN } from './air.ts';import { createGround } from './ground.ts';import { addShapes, drift, PARTS, plant, RADIUS, Tree } from './trees.ts';const SAMPLES = 4;const HDR = 'rgba16float';/** Metres across the ground, past the forest's edge into the haze. */const PLAIN = RADIUS * 4;const TREES = 20_000;/** Noise cells a second the gusts travel downwind. */const GUSTS = 0.4;export interface SceneOptions { /** Trees in the forest. Default 20 000. */ readonly trees?: number;}/** * Draws swaying trees across a plain. GPU culling limits drawings to the camera view and shadow * casters to the sun view. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions = {}) { const { trees = TREES } = options; const own = owner(); const screen = own( targets.create(root, surface, { scene: { format: HDR, samples: SAMPLES }, depth: { format: 'depth24plus', samples: SAMPLES }, hdr: { format: HDR, sampled: true }, }), ); const view = cameras.orbit(root, { size: surface, target: [0, 4, 0], distance: 30, yaw: 0.4, pitch: 0.24, pitchRange: [0.06, 1.45], distanceRange: [8, 160], }); const light = own( lighting.create(root, { settings: { sunDirection: SUN, sunColor: [1, 0.66, 0.4], sunIntensity: 3.2, skyTop: [0.26, 0.38, 0.6], skyBottom: [0.09, 0.08, 0.05], skyIntensity: 0.85, fogDensity: 0.008, fogFalloff: 0.05, }, shading: { fog: aerial }, shadow: { extent: 80 }, }), ); const scene = passes .create( root, screen, { scene: passes.color, depth: passes.depth }, { resolve: { scene: 'hdr' } }, ) .pipe(view.inject()) .beneath(light.inject()); const pool = own(meshPool.create(root, { capacity: 4 })); const ground = own(createGround(root, pool, PLAIN)); const shapes = addShapes(pool); const things = own(instances.create(root, pool, { kind: Tree, capacity: trees * PARTS })); plant(things, shapes, trees); // How far the gusts have travelled, read through `drift` const blown = root.createUniform(d.f32); const gusts: Inject = (cfg) => cfg.with(drift, blown); const values = { cull: true, wind: 1 }; const culling = things.renderer({ cull: true }).pipe(gusts); const plain = things.renderer().pipe(gusts); // The sky fills what the ground and the trees leave const floor = scene.add(ground.drawing); const skyDrawn = scene.add(sky); const culled = { shown: [floor, scene.add(culling), skyDrawn], cast: [light.shadow.add(culling)], }; const unculled = { shown: [floor, scene.add(plain), skyDrawn], cast: [light.shadow.add(plain)] }; const glow = own(bloom.create(root, { source: () => screen.textures.hdr })); const final = own( finish.create(root, { format: surface.format, source: () => screen.textures.hdr, glow, settings: { exposure: 1.15 }, }), ); await pipelines.compile([scene, light.shadow, glow, final]); const settings: Settings<typeof values> = { values, knobs: { cull: {}, wind: { min: 0, max: 3, step: 0.01 } }, }; let travelled = 0; return { view, settings, frame(frame: Frame) { view.update(frame.dt); screen.fit(); if (values.wind !== 0) { travelled += frame.dt * values.wind * GUSTS; blown.write(travelled); } things.flush(); const drawn = values.cull ? culled : unculled; if (values.cull) { const pass = frame.encoder.beginComputePass(); culling.cull(pass); pass.end(); } light.shadow.record(frame, drawn.cast); scene.record(frame, drawn.shown); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}air.ts
import { environment, shading } from '@typegpu/kit';import { srgbToLinear } from '@typegpu/color';import { d, std } from 'typegpu';/** Unit direction towards the low evening sun. */export const SUN = d.vec3f(0.6, 0.31, -0.737);/** The air towards the sun, away from it and straight up. */const WARM = srgbToLinear(d.vec3f(0.98, 0.74, 0.5));const COOL = srgbToLinear(d.vec3f(0.5, 0.62, 0.68));const ZENITH = srgbToLinear(d.vec3f(0.24, 0.44, 0.78));/** The sun's disc, bright enough to bloom. */const DISC = d.vec3f(40, 28, 16);/** The colour of the air along a unit direction near the horizon. */const horizon = (direction: d.v3f) => { 'use gpu'; const facing = std.saturate(std.dot(direction, SUN) * 0.5 + 0.5); return std.mix(COOL, WARM, std.pow(facing, 5));};/** Fogs towards the horizon colour along the view, by the lighting's height fog. */export const aerial = (color: d.v3f, point: shading.Point) => { 'use gpu'; return std.mix(color, horizon(point.view * -1), shading.haze(point));};/** The sky's light along a unit direction: the air, deepening overhead, and the sun's glow. */const skyAt = (direction: d.v3f) => { 'use gpu'; const sun = std.saturate(std.dot(direction, SUN)); const glow = WARM * (std.pow(sun, 12) * 0.6) + DISC * std.smoothstep(0.9994, 0.9997, sun); return std.mix(horizon(direction), ZENITH, std.smoothstep(0, 0.45, direction.y)) + glow;};/** Draws the sky where the trees leave it. */export const sky = environment.background(skyAt);ground.ts
import { instances, materials, type meshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { perlin2d } from '@typegpu/noise';import { d, std, type TgpuRoot } from 'typegpu';const MOSS = srgbToLinear(d.vec3f(0.24, 0.26, 0.1));/** Leaf litter showing through the moss in patches. */const LITTER = srgbToLinear(d.vec3f(0.42, 0.33, 0.17));const Moss = instances.define('Moss', { material: (point) => { 'use gpu'; const at = point.position.xz; const patch = perlin2d.sample(at * 0.07) + perlin2d.sample(at * 0.3) * 0.5; return materials.matte(std.mix(MOSS, LITTER, std.smoothstep(0, 0.8, patch) * 0.7)); },});/** Lays a mossy plain `size` metres across, drawn by `drawing`. */export function createGround(root: TgpuRoot, pool: meshPool.MeshPool, size: number) { const plain = instances.create(root, pool, { kind: Moss, capacity: 1 }); plain.add(pool.add(meshes.plane({ width: size, depth: size }))); plain.flush(); return { drawing: plain.renderer(), destroy: () => plain.destroy() };}trees.ts
import { instances, materials, shading, type meshPool } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { perlin2d } from '@typegpu/noise';import { perlin2d as cpuPerlin } from 'math/noise';import { mulberry32 } from 'math/random';import { d, std, tgpu } from 'typegpu';import { SUN } from './air.ts';const TAU = Math.PI * 2;/** Forest radius in metres. */export const RADIUS = 220;/** * The glade around the centre: its mean radius, how far its edge wanders as a fraction of that, * the edge noise frequency per metre and the width over which trees thin out towards it. */const GLADE = { radius: 15, wander: 0.5, frequency: 0.06, fringe: 10 };/** Turning trees grow only beyond this distance from the centre. */const NEAR = 70;const SEED = 7;/** Instances per tree, one trunk and one crown. */export const PARTS = 2;const TRUNK = { radius: 0.14, height: 1.8 };/** Spruce crown dimensions. `seat` is its base height as a fraction of trunk height. */const SPRUCE = { radius: 1.5, height: 5, seat: 0.6, roughness: 0.9 };/** * Oak crown proportions. `squash` is height divided by width, and `lift` offsets its centre * above the trunk in crown heights. */const OAK = { radius: 1.6, squash: 0.8, lift: 0.7, roughness: 0.8 };/** Spruce crown tier count, and its mesh's columns around and rows up. */const TIERS = 3;const COLUMNS = 14;const ROWS = 24;/** The share of trees that are spruces; the rest are oaks. */const SPRUCES = 0.7;/** Minimum and maximum tree scale. */const SIZES = [0.7, 1.4] as const;/** Spruce crown height variation over its width. */const STRETCH = [0.85, 1.3] as const;/** Crown brightness variation around the picked colour. */const SHADES = [0.7, 1.2] as const;/** The share of broadleaf trees already turning. */const TURNING = 0.025;/** Which way the wind blows, along the ground. */const WIND = d.vec3f(0.8, 0, 0.6);/** Bend per square metre of height before applying gust strength. */const SWAY = 0.004;/** Gust spatial frequency in noise cells per metre. */const GUST = 0.05;/** Warm light passing through sunlit crown edges, before crown colour. */const THROUGH = d.vec3f(3.6, 2.4, 1.2);/** How far the gusts have travelled downwind, in noise cells. */export const drift = tgpu.accessor(d.f32);/** * Deforms trunks and crowns by squared height above ground, modulated by gusts. Trees have no * rotation, so scaling local deformation gives the world-space bend. */export const Tree = instances.define('Tree', { deform: (vertex, tree) => { 'use gpu'; const height = tree.position.y + vertex.position.y * tree.scale.y; const gust = std.clamp(perlin2d.sample(tree.position.xz * GUST - WIND.xz * drift.$), -1, 1); const bend = WIND * (SWAY * height * height * (1 + gust)); return vertex.position + bend / tree.scale; }, // The sphere top bounds every vertex height and therefore the maximum bend bounds: (_tree, base) => { 'use gpu'; const top = base.center.y + base.radius; return instances.Sphere({ center: base.center, radius: base.radius + 2 * SWAY * top * top }); }, material: (point, _tree, base) => { 'use gpu'; // Sunlight through the edges of crowns between the eye and the sun const lit = shading.Material(base); const rim = 1 - std.saturate(std.dot(point.normal, point.view)); const behind = std.saturate(-std.dot(point.view, SUN)); const through = rim * rim * behind * behind * shading.sunShare(point); lit.emission = base.emission + base.color * THROUGH * through; return lit; },});const BARK = materials.matte(srgbToLinear(d.vec3f(0.36, 0.29, 0.23)));/** Crown colours, each tree mixing a random pair of its species' palette. */const NEEDLES = [d.vec3f(0.07, 0.2, 0.12), d.vec3f(0.14, 0.27, 0.13), d.vec3f(0.2, 0.3, 0.12)];const LEAVES = [d.vec3f(0.3, 0.4, 0.13), d.vec3f(0.38, 0.47, 0.15), d.vec3f(0.24, 0.34, 0.12)];const AUTUMN = [d.vec3f(0.5, 0.32, 0.15), d.vec3f(0.52, 0.45, 0.2), d.vec3f(0.42, 0.25, 0.14)];const linear = (palette: d.v3f[]) => palette.map((color) => srgbToLinear(color));const PALETTES = { needles: linear(NEEDLES), leaves: linear(LEAVES), autumn: linear(AUTUMN) };/** Adds the tree meshes to `pool`: a trunk centred vertically, and crowns with their bases at 0. */export function addShapes(pool: meshPool.MeshPool) { const spruce = meshes.parametric( { at: (u, v) => { 'use gpu'; // Clockwise winding from +z toward +x makes the crown face outward const tier = 0.5 + 0.5 * (1 - std.fract(v * TIERS + 0.02)); const radius = (1 - v) * tier * std.min(v * ROWS, 1) * SPRUCE.radius; const angle = u * TAU; return d.vec3f(std.sin(angle) * radius, v * SPRUCE.height, std.cos(angle) * radius); }, }, { cols: COLUMNS, rows: ROWS }, ); return { trunk: pool.add(meshes.cylinder({ ...TRUNK, radialSegments: 8 })), spruce: pool.add(spruce), oak: pool.add(meshes.icosphere({ radius: OAK.radius, segments: 2 })), };}/** Creates trunk and crown instances at seeded positions between the glade and forest edge. */export function plant( things: instances.Instances<typeof Tree>, shapes: ReturnType<typeof addShapes>, count: number,) { const state = mulberry32.create(SEED); const next = () => mulberry32.sample(state); const between = ([low, high]: readonly [number, number]) => low + next() * (high - low); const pick = <T>(list: readonly T[]) => list[Math.floor(next() * list.length)]!; const edge = cpuPerlin.create(SEED); const color: [number, number, number] = [0, 0, 0]; for (let planted = 0; planted < count;) { const out = Math.sqrt(next()) * RADIUS; const angle = next() * TAU; const x = Math.cos(angle) * out; const z = Math.sin(angle) * out; const wander = cpuPerlin.sample(edge, x * GLADE.frequency, z * GLADE.frequency); const glade = GLADE.radius * (1 + GLADE.wander * wander); if (next() > (out - glade) / GLADE.fringe) continue; planted++; const size = between(SIZES); const spruce = next() < SPRUCES; const turning = !spruce && out > NEAR && next() < TURNING; const palette = spruce ? PALETTES.needles : turning ? PALETTES.autumn : PALETTES.leaves; const a = pick(palette); const b = pick(palette); const mix = next(); const shade = between(SHADES); color[0] = (a.x + (b.x - a.x) * mix) * shade; color[1] = (a.y + (b.y - a.y) * mix) * shade; color[2] = (a.z + (b.z - a.z) * mix) * shade; things.add(shapes.trunk, { position: [x, (TRUNK.height / 2) * size, z], scale: size, ...BARK }); if (spruce) { things.add(shapes.spruce, { position: [x, TRUNK.height * SPRUCE.seat * size, z], scale: [size, size * between(STRETCH), size], color, roughness: SPRUCE.roughness, }); } else { const crown = OAK.radius * OAK.squash * size; things.add(shapes.oak, { position: [x, TRUNK.height * size + crown * OAK.lift, z], scale: [size, size * OAK.squash, size], color, roughness: OAK.roughness, }); } }}