Benthos

Light falls through deep water onto 24 000 flecks of marine snow and a few glowing motes, and the pointer stirs them. Particles, bloom and the final pass draw it.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. abyss.tsDraws deep water with light shafts, surface glints, marine snow and glowing motes.

Kit modules bloom, cameras, clock, environment, finish, owner, particles, passes, pipelines, targets, tone. Also @typegpu/noise, typegpu.

scene.ts

import {  bloom,  cameras,  clock,  finish,  owner,  passes,  pipelines,  targets,  tone,  type Frame,  type Surface,} from '@typegpu/kit';import { d, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createAbyss, defaults as waterDefaults } from './abyss.ts';const HDR = 'rgba16float';export interface SceneOptions {  /** Flecks of marine snow. Default 24 000. */  readonly snow?: number;}/** Deep water with marine snow, bloom and a final pass. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions = {}) {  const own = owner();  const screen = own(targets.create(root, surface, { hdr: { format: HDR, sampled: true } }));  const view = cameras.orbit(root, {    size: surface,    target: [0, 0, 0],    distance: 5,    pitch: -0.35,    pitchRange: [-1.3, 0.6],    distanceRange: [1, 12],    damping: 4,  });  const scene = passes.create(root, screen, { hdr: passes.color }).pipe(view.inject());  const values = {    ...waterDefaults,    exposure: 1.4,    threshold: 0.6,    glowIntensity: 0.2,    spread: bloom.defaults.spread,    vignette: 0.55,    paused: false,  };  const glow = own(bloom.create(root, { source: () => screen.textures.hdr, settings: values }));  const final = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      tone: tone.aces,      settings: values,      glow,    }),  );  const sim = clock.fixedStep({ step: 1 / 60, maxSteps: 3 });  const time = root.createUniform(d.f32);  const abyss = own(createAbyss(root, { camera: view, time, step: sim.step, snow: options.snow }));  const drawn = [scene.add(abyss.water), scene.add(abyss.snow)];  await pipelines.compile([scene, glow, final]);  abyss.fill();  const look = { folder: 'look' } as const;  const water = { folder: 'water' } as const;  const settings: Settings<typeof values> = {    values,    knobs: {      paused: {},      exposure: { ...look, min: 0.2, max: 4, step: 0.05 },      threshold: { ...look, min: 0, max: 3, step: 0.05 },      glowIntensity: { ...look, min: 0, max: 1, step: 0.01 },      spread: { ...look, min: 0.25, max: 2, step: 0.05 },      vignette: { ...look, min: 0, max: 1, step: 0.01 },      shafts: { ...water, min: 0, max: 2, step: 0.01 },      glints: { ...water, min: 0, max: 10, step: 0.1 },      snow: { ...water, min: 0, max: 2, step: 0.01 },      motes: { ...water, min: 0, max: 4, step: 0.05 },      fog: { ...water, min: 0, max: 0.4, step: 0.005 },      focus: { ...water, min: 1, max: 12, step: 0.1 },      blur: { ...water, min: 0, max: 0.15, step: 0.005 },      stir: { ...water, min: 0, max: 2, step: 0.05 },    },    write(key) {      if (key === undefined || key === 'paused') sim.paused = values.paused;      if (key === 'paused') return;      final.settings.patch(values);      glow.settings.patch(values);      abyss.write(values);    },  };  const pointing: Pointing = { move: abyss.point };  return {    view,    settings,    pointing,    frame(frame: Frame) {      time.write(frame.time);      view.update(frame.dt);      abyss.update(frame.dt);      screen.fit();      const compute = frame.encoder.beginComputePass();      abyss.spawn(compute);      for (let steps = sim.advance(frame.dt); steps > 0; steps--) abyss.step(compute);      abyss.compact(compute, sim.ahead);      compute.end();      scene.record(frame, drawn);      glow.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

abyss.ts

import { cameras, clock, environment, owner, particles, type CameraBase } from '@typegpu/kit';import { perlin2d, randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuRoot, type TgpuUniform } from 'typegpu';// Colours are in linear lightconst ABYSS = d.vec3f(0, 0.002, 0.007);const DEEP = d.vec3f(0.003, 0.025, 0.045);const SURFACE = d.vec3f(0.3, 0.62, 0.66);const SNOW = d.vec3f(0.55, 0.7, 0.75);const CYAN = d.vec3f(0.1, 1, 0.8);const BLUE = d.vec3f(0.1, 0.35, 1);/** Direction toward the light, above and right of a camera resting on +z. */const SUN = std.normalize(d.vec3f(0.75, 1, -0.45));const SIDE = std.normalize(std.cross(SUN, d.vec3f(0, 0, 1)));const FRONT = std.cross(SIDE, SUN);/** Half-extents of the snow volume. Large enough to contain the camera. */const BOX = d.vec3f(14, 8, 14);/** Height of the water's surface, which glints far above. */const SURFACE_Y = 9;/** The share of flecks that are bioluminescent motes. */const MOTES = 0.006;/** Minimum and maximum snow-fleck width in focus, and mote width, in world units. */const FLECK_SIZE = [0.018, 0.058] as const;const MOTE_SIZE = 0.22;/** Minimum and maximum snow sinking speed in world units per second. */const SINK = [0.08, 0.2] as const;/** World units a second the pointer drags a fleck at most. */const DRAG = 1.5;/** Drag decay rate per second. After one second, `exp(-SETTLE)` of the pointer drag remains. */const SETTLE = 1.3;/** Flecks of marine snow, unless the options say otherwise. */const FLECKS = 24_000;/** Maximum pointer speed in view widths per second. Limits impulses when the pointer enters. */const FLING = 3;/** Pointer-velocity smoothing rate per second. */const EASING = 10;/** Pointer speed threshold in view widths per second. Slower motion counts as still. */const STILL = 1e-3;export const Water = d.struct({  /** Brightness of the light shafts. */  shafts: d.f32,  /** Brightness of the light glinting through the waves on the surface far above. */  glints: d.f32,  /** Brightness of the marine snow. */  snow: d.f32,  /** Brightness of the bioluminescent motes. */  motes: d.f32,  /** Water extinction per world unit. Transmitted light is `exp(-fog × distance)`. */  fog: d.f32,  /** Focus distance in world units. Nearer flecks blur. */  focus: d.f32,  /** Blur growth in world units per unit closer than the focus distance. */  blur: d.f32,  /** How strongly the pointer drags the flecks it passes. */  stir: d.f32,});export const defaults: d.Infer<typeof Water> = {  shafts: 0.55,  glints: 3,  snow: 0.35,  motes: 1,  fog: 0.13,  focus: 5,  blur: 0.035,  stir: 0.45,};const Pointer = d.struct({  /** Pointer coordinates from 0 to 1, measured from the view top left. */  at: d.vec2f,  /** Pointer velocity in view units per second. */  motion: d.vec2f,});/** * Light-shaft brightness along a direction. Shafts run parallel to the light and fan outward in * perspective. Surface motion makes them sway. */const shafts = (ray: d.v3f, t: number) => {  'use gpu';  const toward = std.saturate(std.dot(ray, SUN));  const across = ray - SUN * std.dot(ray, SUN);  const around = std.normalize(d.vec2f(std.dot(across, SIDE), std.dot(across, FRONT)) + 0.0001);  const broad = perlin2d.sample(around * 2.5 + d.vec2f(t * 0.021, t * -0.013));  const fine = perlin2d.sample(around * 13 + d.vec2f(t * -0.04, t * 0.05) + 11);  const beams = std.smoothstep(-0.15, 0.6, broad) * std.saturate(0.35 + fine * 1.2);  // Blur shafts near the light instead of letting them pinch to a point  const blurred = std.mix(0.35, beams, std.smoothstep(0.03, 0.35, std.length(across)));  return blurred * toward * toward * toward * std.smoothstep(-0.2, 0.6, ray.y);};/** Snow-fleck colour and blur flattening from 0 to 1. */const Fleck = particles.define('Fleck', { flat: d.f32 });type Fleck = d.InferGPU<typeof Fleck>;const isMote = (fleck: Fleck) => {  'use gpu';  return fleck.seed > 1 - MOTES;};/** Draws a fleck as a soft dot flattening into a blurred disc, or a glowing mote core and halo. */const speck = (fleck: Fleck, local: d.v2f) => {  'use gpu';  const r2 = std.dot(local, local);  const dome = std.saturate(1 - r2);  const snow = std.mix(dome * dome, std.smoothstep(1, 0.55, r2) * 0.55, fleck.flat);  const mote = std.exp(-r2 * 9) + dome * dome * 0.08;  const shape = std.select(snow, mote, isMote(fleck));  return d.vec4f(fleck.color.xyz * shape, 0);};export interface AbyssOptions {  readonly camera: CameraBase;  readonly time: TgpuUniform<d.F32>;  /** The length of a step in seconds; see `particles.Options.step`. */  readonly step: number;  /** Flecks of marine snow. Default 24 000. */  readonly snow?: number | undefined;}/** * Draws deep water with light shafts, surface glints, marine snow and glowing motes. The * pointer stirs the particles. */export function createAbyss(root: TgpuRoot, options: AbyssOptions) {  const { time, step, snow: count = FLECKS } = options;  const own = owner();  const settings = root.createUniform(Water, defaults);  const pointer = root.createUniform(Pointer);  // Cached Perlin gradients avoid per-sample hashing  const noise = own(perlin2d.staticCache({ root, size: d.vec2u(64, 64) }));  /** The water's light along `ray`: deep blue below, shafts and the glinting surface above. */  const backdrop = (ray: d.v3f) => {    'use gpu';    const toward = std.saturate(std.dot(ray, SUN));    const t = time.$;    let color = std.mix(ABYSS, DEEP, std.smoothstep(-0.5, 0.8, ray.y));    color += SURFACE * (std.pow(toward, 6) * 0.08 + std.pow(toward, 24) * 0.25);    color += SURFACE * (shafts(ray, t) * settings.$.shafts);    if (ray.y > 0.3) {      const distance = (SURFACE_Y - cameras.current.$.position.y) / ray.y;      const at = cameras.current.$.position.xz + ray.xz * distance;      const swell = perlin2d.sampleWithGradient(at * 0.8 + d.vec2f(t * 0.11, t * 0.06)).yz;      const ripple = perlin2d.sampleWithGradient(at * 5 + d.vec2f(t * -0.21, t * 0.15) + 5).yz;      const slope = swell * 0.2 + ripple * 0.09;      const bent = std.normalize(ray + d.vec3f(slope.x, 0, slope.y));      const glint = std.pow(std.saturate(std.dot(bent, SUN)), 50);      const fade = std.exp(-distance * settings.$.fog) * std.smoothstep(0.3, 0.8, ray.y);      color += SURFACE * (glint * fade * settings.$.glints);    }    return color;  };  /**   * Wraps sinking snow and hovering motes around the volume. Pointer drag decays toward their   * normal drift. Distance from the eye controls size, colour and blur from birth.   */  const drift = (fleck: Fleck, dt: number) => {    'use gpu';    randf.seed(fleck.seed);    const grain = randf.sample();    const phase = randf.sample() * 40;    const sink = randf.sample();    const hue = randf.sample();    const rate = randf.sample();    const mote = isMote(fleck);    const age = fleck.age;    const sway = d.vec3f(std.sin(age * 0.5 + phase) * 0.07, 0, std.cos(age * 0.4 + phase) * 0.05);    const hover = std.sin(age * 0.23 + phase) * 0.03;    const calm = sway + d.vec3f(0, std.select(-std.mix(SINK[0], SINK[1], sink), hover, mote), 0);    // Drag water near the pointer ray only while the pointer moves    const view = cameras.current.$;    const offset = fleck.position - view.position;    const ray = cameras.rayOf(view, d.vec2f(pointer.$.at.x, 1 - pointer.$.at.y) * 2 - 1).direction;    const along = std.dot(offset, ray);    const aside = offset - ray * along;    const near = std.exp(-std.dot(aside, aside) / (0.006 * along * along + 0.001));    const depth = std.smoothstep(0.5, 2, along) * std.smoothstep(14, 5, along);    const stirring = std.min(1, std.length(pointer.$.motion) * 30);    const motion = (pointer.$.motion * d.vec2f(2, -2)) / cameras.lensOf(view);    const pushed = (view.right * motion.x + view.up * motion.y) * (along * settings.$.stir);    const drag = pushed * std.min(1, DRAG / (std.length(pushed) + 0.0001));    const pull = std.saturate(near * depth * stirring * 7 * dt);    const velocity = calm + (fleck.velocity - calm) * std.exp(-SETTLE * dt);    const position = std.fract((fleck.position + BOX) / (BOX * 2)) * (BOX * 2) - BOX;    const inside = BOX - std.abs(position);    const distance = std.length(offset);    // Keep motes away from the eye to prevent large foreground halos    const fade =      std.exp(-distance * settings.$.fog) *      std.smoothstep(0.4, std.select(d.f32(1.4), 3.5, mote), distance) *      std.smoothstep(0, 1.5, std.min(inside.x, std.min(inside.y, inside.z)));    // Conserve light as blur spreads a fleck over a larger disc. Keep distant flecks at least a    // pixel or two wide to prevent flicker    const sharp = std.select(std.mix(FLECK_SIZE[0], FLECK_SIZE[1], grain * grain), MOTE_SIZE, mote);    const blurred = sharp + settings.$.blur * std.max(settings.$.focus - distance, 0);    const size = std.max(blurred, distance * 0.0025);    const spread = (sharp * sharp) / (size * size);    const direction = offset / distance;    const backlit = std.pow(std.saturate(std.dot(direction, SUN)), 6);    const light = 0.5 + 3 * shafts(direction, time.$) + backlit * 1.5;    const height = 0.7 + 0.5 * std.smoothstep(-BOX.y, BOX.y, position.y);    const glow = 0.5 + 0.5 * std.sin(age * (0.4 + rate * 0.6) + phase);    const pulse = 0.25 + 2 * glow * glow * glow * glow;    const snow = SNOW * (settings.$.snow * light * height);    const lit = std.mix(CYAN, BLUE, hue) * (settings.$.motes * pulse);    const shade = std.select(snow, lit, mote) * (fade * spread);    const flat = std.saturate((size - sharp) / (size * 0.6));    return Fleck({      position,      age,      velocity: velocity + (calm + drag - velocity) * pull,      life: fleck.life,      color: d.vec4f(shade, 1),      size,      seed: fleck.seed,      flat,    });  };  const snow = own(    particles.create(root, options.camera, {      capacity: count,      particle: Fleck,      step,      blend: 'add',      update: drift,      born: (fleck: Fleck) => {        'use gpu';        return drift(fleck, d.f32(0));      },      shade: speck,      // `drift` controls particle movement, so disable gravity and drag      settings: { gravity: [0, 0, 0], drag: 0 },    }),  );  // Write pointer position and smoothed velocity only when they change  const pointed = { at: d.vec2f(), motion: d.vec2f() };  const aim = { x: 0.5, y: 0.5, seen: false };  const last = { x: 0.5, y: 0.5 };  const eased = { x: 0, y: 0 };  let moving = false;  return {    /** Writes the water settings. Call when they change. */    write: (values: d.Infer<typeof Water>) => settings.write(values),    /** Pointer coordinates from 0 to 1, measured from the view top left. */    point(x: number, y: number) {      if (!aim.seen) {        last.x = x;        last.y = y;      }      aim.x = x;      aim.y = y;      aim.seen = true;    },    /** Smooths pointer velocity and writes changes. Call once per frame. */    update(dt: number) {      if (!(dt > 0) || !aim.seen) return;      const dx = aim.x - last.x;      const dy = aim.y - last.y;      const limit = FLING / Math.max(FLING, Math.hypot(dx, dy) / dt);      const ease = clock.ease(dt, EASING);      eased.x += ((dx / dt) * limit - eased.x) * ease;      eased.y += ((dy / dt) * limit - eased.y) * ease;      last.x = aim.x;      last.y = aim.y;      const still = Math.hypot(eased.x, eased.y) < STILL;      if (still) eased.x = eased.y = 0;      if (still && !moving && dx === 0 && dy === 0) return;      pointed.at.x = aim.x;      pointed.at.y = aim.y;      pointed.motion.x = eased.x;      pointed.motion.y = eased.y;      pointer.write(pointed);      moving = !still;    },    /** Queues initial snow for the next `spawn`. */    fill() {      snow.burst({ count, position: [0, 0, 0], area: [BOX.x, BOX.y, BOX.z], life: 1e9 });    },    spawn: snow.spawn,    step: snow.step,    /** Lists live snow, drawn `ahead` seconds along its drift. */    compact: snow.compact,    /** The water behind everything, for a description piped with the camera. */    water: environment.background(tgpu.fn(backdrop).pipe(noise.inject())),    /** Add snow after water. */    snow: snow.drawing,    destroy: own.destroy,    [Symbol.dispose]: own.destroy,  };}