Quiver trees

8 000 quiver trees and boulders use CC0 scans from Poly Haven, with 12 000 to 50 000 triangles per model. Meshes draw within 40 m, and captured impostors draw beyond that distance. Across 8 m around that distance, each tree and boulder blends from mesh to card by multisampled samples. Try nearest, blended or parallax sampling. Both meshes and impostors receive lighting and shadows. The gravel uses height-map ray marching.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. ground.tsCreates a ground texture, mipmaps and sampler from an image spanning TILE metres.
  3. plain.tsScatters trees and boulders between the clearing and plain edge with random rotations.
  4. assets.ts

Kit modules bloom, cameras, finish, images, impostors, instances, lighting, materials, meshPool, owner, passes, pipelines, shading, targets. Also @typegpu/noise, typegpu.

scene.ts

import {  bloom,  cameras,  finish,  impostors,  instances,  lighting,  materials,  meshPool,  owner,  passes,  pipelines,  targets,  type Frame,  type Surface,} from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { d, type TgpuRoot } from 'typegpu';import type { Picture } from '../contract.ts';import type { Settings } from '../settings.ts';import { createGround, Ground } from './ground.ts';import { CLEARING, MODELS, plant, RADIUS, type ModelName } from './plain.ts';const SAMPLES = 4;const HDR = 'rgba16float';const HAZE = [0.71, 0.51, 0.32] as const;const COUNT = 8_000;/** Mesh-to-impostor transition distance and transition width in metres from the camera. */const NEAR = 40;const FADE = 8;/** Metres along each side of the ground, past the plain's edge into the haze. */const GROUND_SIDE = RADIUS * 3;/** Impostor roughness. Captures do not include the scan textures' roughness. */const CARD_ROUGHNESS = 0.85;/** Texels per atlas view. Leaves need more than boulders to hold up where cards meet meshes. */const CARD_TEXELS: Readonly<Record<ModelName, number>> = {  'quiver-tree-01': 256,  'quiver-tree-02': 256,  'boulder-02': 128,  'boulder-05': 128,};export interface SceneOptions {  /** Each model as `gltf.read` returns it. */  readonly models: Readonly<Record<ModelName, meshPool.ModelData>>;  /** Ground texture with RGB albedo and alpha height. */  readonly ground: Picture;  /** Trees and boulders on the plain. Default 8 000. */  readonly count?: number;  /**   * Each impostor atlas's views per side and texels per view. Default 8, and 256 texels for trees   * and 128 for boulders.   */  readonly atlas?: { readonly views?: number; readonly size?: number };  /** Receives compilation progress as a fraction after each part finishes. */  readonly onProgress?: (fraction: number) => void;}/** * Draws nearby trees and boulders as meshes, with captured impostors farther away. GPU culling * tests camera and shadow views separately. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) {  const { count = COUNT } = 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, 2.5, 0],    distance: CLEARING * 0.75,    yaw: 0.6,    pitch: 0.12,    pitchRange: [0.03, 1.3],    distanceRange: [4, 150],  });  const light = own(    lighting.create(root, {      settings: {        sunDirection: [0.75, 0.35, 0.45],        sunColor: [1, 0.82, 0.62],        sunIntensity: 2.6,        skyTop: [0.45, 0.55, 0.75],        skyBottom: [0.35, 0.25, 0.15],        skyIntensity: 0.5,        fogColor: HAZE,        fogDensity: 0.006,      },      shadow: { extent: 60 },    }),  );  const scene = passes    .create(      root,      screen,      { scene: passes.color, depth: passes.depth },      { resolve: { scene: 'hdr' }, clear: [...HAZE, 1] },    )    .pipe(view.inject())    .beneath(light.inject());  const plain = meshes.plane({ width: GROUND_SIDE, depth: GROUND_SIDE });  const pool = own(    meshPool.create(      root,      // The ground, the models and the card their atlases share      meshPool.roomFor([plain, ...MODELS.map((name) => options.models[name]), impostors.CARD]),    ),  );  const groundMesh = pool.add(plain);  const models = MODELS.map((name) => pool.add(options.models[name]));  const cardModel = pool.add(impostors.CARD);  // Wait for model textures before capturing them  await Promise.all([...models, cardModel].map((model) => model.ready));  const card = cardModel.meshes[0]!;  const atlases = models.map((model, i) =>    own(      impostors.create(root, pool, model, {        card,        views: 8,        size: CARD_TEXELS[MODELS[i]!],        ...options.atlas,      }),    ),  );  const capturing = root.createCommandEncoder();  for (const atlas of atlases) atlas.capture(capturing);  capturing.submit();  const planted = plant(count);  const rows = planted.reduce((sum, { model }) => sum + models[model]!.meshes.length, 0);  const gravel = own(createGround(root, options.ground));  const groundSet = own(instances.create(root, pool, { kind: Ground, capacity: 1 }));  groundSet.add(groundMesh, materials.matte(d.vec3f(1)));  const things = own(instances.create(root, pool, { capacity: rows }));  const cards = models.map((_, i) =>    own(      instances.create(root, pool, {        kind: impostors.Impostor,        capacity: planted.filter(({ model }) => model === i).length,      }),    ),  );  for (const { model, position, rotation, scale, shade } of planted) {    const placed = { position, rotation, scale, color: [shade, shade, shade] } as const;    things.add(models[model]!, placed);    cards[model]!.add(card, { ...placed, roughness: CARD_ROUGHNESS });  }  const instanceSets = [groundSet, things, ...cards];  /** A renderer with its drawings in the scene and in the shadow. */  const lit = (renderer: instances.Renderer) => ({    renderer,    shown: scene.add(renderer),    cast: light.shadow.add(renderer),  });  type Lit = ReturnType<typeof lit>;  const drawingsOf = ({ shown, cast }: Lit) => [shown, cast];  /** The renderers a frame culls, shadows and draws. */  const drawList = (renderers: readonly Lit[]) => ({    culled: renderers.map(({ renderer }) => renderer),    casters: renderers.map(({ cast }) => cast),    shown: renderers.map(({ shown }) => shown),  });  type DrawList = ReturnType<typeof drawList>;  // The ground casts no shadow, and nothing below it receives one  const ground = scene.add(groundSet.renderer().pipe(gravel.inject()));  const everywhere = lit(things.renderer({ cull: true }));  const near = lit(things.renderer({ cull: true, to: NEAR, fade: FADE, eye: view }));  // Cards overlap across the plain, so their depth goes first and each pixel shades once  const far = { cull: true, from: NEAR, fade: FADE, eye: view, prepass: true } as const;  const readings = new Map<impostors.Sampling, Promise<DrawList>>();  /** Compiles a card renderer per model reading its atlas by `sampling`, once per mode. */  const readAs = (sampling: impostors.Sampling) => {    const known = readings.get(sampling);    if (known) return known;    const renderers = cards.map((set, i) =>      lit(set.renderer(far).pipe(atlases[i]!.inject(sampling))),    );    const reading = pipelines      .compile(renderers.flatMap(drawingsOf))      .then(() => drawList([near, ...renderers]));    readings.set(sampling, reading);    return reading;  };  const glow = own(bloom.create(root, { source: () => screen.textures.hdr }));  const final = own(    finish.create(root, { format: surface.format, source: () => screen.textures.hdr, glow }),  );  const values = { impostors: true, sampling: 'blended' as impostors.Sampling };  const firstReading = readAs(values.sampling);  await pipelines.compile(    [ground, glow, final, ...[everywhere, near].flatMap(drawingsOf)],    options,  );  const meshesOnly = drawList([everywhere]);  let withCards = await firstReading;  const settings: Settings<typeof values> = {    values,    knobs: { impostors: {}, sampling: { options: ['nearest', 'blended', 'parallax'] } },    /**     * Compiles each atlas sampling mode on first use. Cards keep the last mode while     * compilation runs. Frames read `impostors` directly.     */    async write(key) {      if (key !== undefined && key !== 'sampling') return;      const { sampling } = values;      const reading = await readAs(sampling);      if (values.sampling === sampling) withCards = reading;    },  };  return {    view,    settings,    frame(frame: Frame) {      view.update(frame.dt);      screen.fit();      for (const set of instanceSets) set.flush();      const drawn = values.impostors ? withCards : meshesOnly;      const culling = frame.encoder.beginComputePass();      for (const renderer of drawn.culled) renderer.cull(culling);      culling.end();      light.shadow.record(frame, drawn.casters);      const pass = scene.begin(frame);      ground.draw(pass);      for (const drawing of drawn.shown) drawing.draw(pass);      pass.end();      glow.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

ground.ts

import { cameras, images, instances, shading, unfilled, type Inject } from '@typegpu/kit';import { perlin2d, randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import type { Picture } from '../contract.ts';/** Ground-tile width and gravel depth in metres. */const TILE = 2.5;const DEPTH = 0.05;/** Maximum ray steps, one per crossed texel. */const STEPS = 32;/** Minimum step in texture tiles, equal to one of 1024 texels. */const FINEST = 1 / 1024;/** * Minimum downward component of the unit view ray. Prevents grazing rays marching to the * horizon. */const GRAZING = 0.05;/** Flattening start and end distances in metres from the camera. Skip marching beyond the end. */const MARCHED = { from: 12, to: 30 };/** Sand-variation frequency in noise cells per metre and shade variation amplitude. */const PATCHES = 0.03;const PATCHINESS = 0.25;/** * Samples at one of `OFFSETS` texture offsets. Noise at `SHUFFLE` cells per metre selects the * offset, blending through the middle `SEAM` of each transition to hide repetition. */const SHUFFLE = 0.08;const OFFSETS = 8;const SEAM = 0.2;/** Cached noise-grid side length in cells. Repeats beyond the grid. */const CACHE = 128;/** Stony and sandy ground presets with sand tint and gravel depth. */const STONY = { tint: d.vec3f(0.74, 0.76, 0.8), depth: 1.4 };const SANDY = { tint: d.vec3f(1.1, 1.0, 0.88), depth: 0.4 };const missing = unfilled("the ground's texture", 'gravel', 'a `createGround`');/** RGB albedo and alpha height, with 1 at the gravel top. */const sand = tgpu.accessor(d.texture2d(d.f32), missing);/** Anisotropic sampler for colour. Height uses trilinear sampling to limit samples per ray step. */const colorSampler = tgpu.accessor(d.sampler(), missing);const heightSampler = tgpu.accessor(d.sampler(), missing);/** Two texture offsets and the blend fraction between them. */const Shuffle = d.struct({ first: d.vec2f, second: d.vec2f, blend: d.f32 });/** Precomputed texture offsets. */const offsetOf = (index: number) => {  'use gpu';  randf.seed(index);  return d.vec2f(randf.sample(), randf.sample());};const shuffleAt = (place: d.v2f) => {  'use gpu';  const pick = (perlin2d.sample(place * SHUFFLE) * 0.5 + 0.5) * OFFSETS;  const first = std.floor(pick);  return Shuffle({    first: offsetOf(first),    second: offsetOf(first + 1),    blend: std.smoothstep(0.5 - SEAM / 2, 0.5 + SEAM / 2, pick - first),  });};/** Samples `uv` through `sampler` at one offset, or blends two at a transition. */const shuffled = (  sampler: d.sampler,  uv: d.v2f,  dx: d.v2f,  dy: d.v2f,  shuffle: d.Infer<typeof Shuffle>,) => {  'use gpu';  let at = uv + shuffle.first;  if (shuffle.blend >= 1) at = uv + shuffle.second;  const one = std.textureSampleGrad(sand.$, sampler, at, dx, dy);  if (shuffle.blend <= 0 || shuffle.blend >= 1) return one;  const other = std.textureSampleGrad(sand.$, sampler, uv + shuffle.second, dx, dy);  return std.mix(one, other, shuffle.blend);};const heightAt = (uv: d.v2f, dx: d.v2f, dy: d.v2f, shuffle: d.Infer<typeof Shuffle>) => {  'use gpu';  return shuffled(heightSampler.$, uv, dx, dy, shuffle).a;};/** Ground type from -1, stony, to 1, sandy. Varies over metres to hundreds of metres. */const strewn = (place: d.v2f) => {  'use gpu';  return (    perlin2d.sample(place * 0.006) * 0.4 +    perlin2d.sample(place * 0.022 + 17) * 0.25 +    perlin2d.sample(place * 0.08 + 41) * 0.18 +    perlin2d.sample(place * 0.3 + 73) * 0.17  );};/** * Draws unrotated, unscaled ground at y = 0. Each pixel marches through the height map along * the view ray to find the gravel surface. */export const Ground = instances.define('Ground', {  surface: (point, _ground, base) => {    'use gpu';    const start = point.position.xz / TILE;    const dx = std.dpdx(start);    const dy = std.dpdy(start);    const texel = std.max(FINEST, std.max(std.length(dx), std.length(dy)));    const shuffle = shuffleAt(point.position.xz);    const sandy = std.saturate(strewn(point.position.xz) * 1.5 + 0.5);    const gravel = DEPTH * std.mix(STONY.depth, SANDY.depth, sandy);    // March below y = 0 through the gravel depth, moving by `shift` across the texture. Flatten    // depth with distance    const view = point.view;    const away = std.distance(cameras.current.$.position, point.position);    const relief = std.saturate((MARCHED.to - away) / (MARCHED.to - MARCHED.from));    const shift = (view.xz / std.max(view.y, GRAZING)) * ((gravel * relief) / TILE);    const steps = d.i32(std.min(std.ceil(std.length(shift) / texel), STEPS));    let uv = d.vec2f(start);    let depth = d.f32(0);    let below = 1 - heightAt(uv, dx, dy, shuffle);    let above = d.f32(below);    const step = 1 / d.f32(std.max(steps, 1));    for (let i = d.i32(0); i < steps; i++) {      if (depth >= below) break;      above = below - depth;      uv -= shift * step;      depth += step;      below = 1 - heightAt(uv, dx, dy, shuffle);    }    // Interpolate the intersection between the last step above the gravel and the first below    // it    const after = below - depth;    const met = std.saturate(      std.select(d.f32(0), after / (after - above), std.abs(after - above) > 1e-5),    );    uv += shift * step * met;    const height = std.select(1 - below, 1 - depth + step * met, steps > 0);    const across = heightAt(uv + d.vec2f(texel, 0), dx, dy, shuffle) - height;    const along = heightAt(uv + d.vec2f(0, texel), dx, dy, shuffle) - height;    const rise = gravel / (texel * TILE);    const normal = std.normalize(d.vec3f(-across * rise, 1, -along * rise));    const texture = shuffled(colorSampler.$, uv, dx, dy, shuffle);    const flat = uv * TILE;    const position = d.vec3f(flat.x, (height - 1) * gravel * relief, flat.y);    const patch = 1 + perlin2d.sample(position.xz * PATCHES) * PATCHINESS;    const tint = std.mix(STONY.tint, SANDY.tint, sandy);    const material = shading.Material(base);    material.color = base.color * texture.rgb * tint * patch;    return instances.Shaded({      point: shading.Point({ position, normal, view, uv, uv1: point.uv1 }),      material,    });  },});/** Creates a ground texture, mipmaps and sampler from an image spanning `TILE` metres. */export function createGround(root: TgpuRoot, image: Picture) {  const texture = images.create(root, image);  const view = texture.createView(d.texture2d(d.f32));  const forColor = images.sampler(root, { anisotropy: 16 });  const forHeight = images.sampler(root);  // Cache gradients because each pixel samples six noise octaves, twice with multisampling  const noise = perlin2d.staticCache({ root, size: d.vec2u(CACHE, CACHE) });  return {    inject: (): Inject => (cfg) =>      noise        .inject()(cfg)        .with(sand, () => view.$)        .with(colorSampler, () => forColor.$)        .with(heightSampler, () => forHeight.$),    destroy: () => {      texture.destroy();      noise.destroy();    },  };}

plain.ts

const TAU = Math.PI * 2;/** * Poly Haven CC0 scans `quiver_tree_01`, `quiver_tree_02`, `namaqualand_boulder_02` and * `namaqualand_boulder_05`. These files have about one third of the source triangles and * 512-texel textures. */export const MODELS = ['quiver-tree-01', 'quiver-tree-02', 'boulder-02', 'boulder-05'] as const;export type ModelName = (typeof MODELS)[number];/** Plain and initial clearing radii in metres. */export const RADIUS = 260;export const CLEARING = 16;/** Model frequencies and minimum and maximum scales. */const MIX: readonly { share: number; scale: readonly [number, number] }[] = [  { share: 0.35, scale: [1.6, 3.2] },  { share: 0.25, scale: [1.8, 3.6] },  { share: 0.2, scale: [0.8, 2.4] },  { share: 0.2, scale: [1.2, 3.5] },];/** Minimum and maximum brightness multipliers relative to the model. */const SHADES = [0.8, 1.2] as const;export interface Planted {  readonly model: number;  readonly position: [number, number, number];  readonly rotation: [number, number, number, number];  readonly scale: number;  /** Brightness multiplier relative to the model. */  readonly shade: number;}const between = ([low, high]: readonly [number, number]) => low + Math.random() * (high - low);/** Scatters trees and boulders between the clearing and plain edge with random rotations. */export function plant(count: number): Planted[] {  return Array.from({ length: count }, () => {    const out = Math.sqrt(between([CLEARING ** 2, RADIUS ** 2]));    const angle = Math.random() * TAU;    const yaw = Math.random() * TAU;    let pick = Math.random();    let model = 0;    while (model < MIX.length - 1 && pick >= MIX[model]!.share) pick -= MIX[model++]!.share;    return {      model,      position: [Math.cos(angle) * out, 0, Math.sin(angle) * out],      rotation: [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)],      scale: between(MIX[model]!.scale),      shade: between(SHADES),    };  });}

assets.ts

import { each, glb, image } from '../assets.ts';import { MODELS } from './plain.ts';/** * The trees' and boulders' scans, and Poly Haven's CC0 `gravelly_sand` by Dario Barresi, whose RGB * holds albedo and alpha holds height. */export const assets = {  models: each(MODELS, (name) => glb(`models/${name}.glb`)),  ground: image('textures/gravelly-sand.webp'),};