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

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. air.ts
  3. ground.tsLays a mossy plain size metres across, drawn by drawing.
  4. 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,      });    }  }}