Jellies

A sea nettle and a few moon jellies drift through dark water under light from the surface. Every bell, oral arm and tentacle is translucent and drawn in any order: the passes add them into accumulation and revealage targets, and one composite lays the result over the water. Each bell pulses on its own rhythm, and kernels pull its tentacles and arms behind it as follow-the-leader chains. Drag to stir the water. Tap a jelly to make it contract, dart away and flash. Marine snow, bubbles, bloom and the final pass finish the tank.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. swarm.tsCreates the jellies, the kernels that move them and their strands, and their drawings.
  3. jelly.ts
  4. tank.ts
  5. snow.ts
  6. bubbles.tsBubbles rising in thin streams from the floor, wobbling and stirred by the current.
  7. water.ts
  8. tissue.tsDraws the jellies' bells, tentacles and oral arms as translucent tissue.

Kit modules bloom, cameras, clock, environment, finish, lines, owner, particles, passes, picker, pipelines, targets, tone. Also @typegpu/noise, math/random, typegpu, wgpu-matrix.

scene.ts

import {  bloom,  cameras,  clock,  finish,  owner,  passes,  pipelines,  targets,  tone,  type Frame,  type Surface,} from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { vec3n } from 'wgpu-matrix';import { d, type Configurable, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createSwarm } from './swarm.ts';import { MAX } from './jelly.ts';import { water } from './tank.ts';import { snow } from './snow.ts';import { createBubbles } from './bubbles.ts';import { Look, lookOf, Stir, stirOf, timeOf } from './water.ts';type Vec3 = [number, number, number];const zero = (): Vec3 => [0, 0, 0];/** Where the camera looks, and from how far. */const MIDDLE: Vec3 = [0, 0.15, 0];const DISTANCE = 3;const PITCH = -0.06;/** World units of push a metre of drag gives, its most, and how fast it settles a second. */const STIR = { gain: 1.4, most: 2.5, settle: 1.8 };/** Steps the jellies take before the first frame, so the tank opens with its strands settled. */const SETTLING_STEPS = 240;export const defaults = {  /** How many jellies swim in the tank. */  count: 12,  /** How strongly dragging stirs the water. */  current: 1,  /** Brightness of the jellies' own rim glow. */  glow: 1,  /** Draws the jellies through the translucency targets, or blended over in draw order. */  oit: true,};/** A lit tank of jellies, drawn translucent without sorting, with bloom and a final pass. */export async function createScene(root: TgpuRoot, surface: Surface) {  const values = { ...defaults };  const own = owner();  const screen = own(    targets.create(root, surface, {      hdr: { format: 'rgba16float', sampled: true },      accum: { format: 'rgba16float', sampled: true },      reveal: { format: 'r16float', sampled: true },    }),  );  const view = cameras.orbit(root, {    size: surface,    target: MIDDLE,    distance: DISTANCE,    pitch: PITCH,    fov: 0.85,    distanceRange: [2, 9],    damping: 3,  });  const time = root.createUniform(d.f32);  const look = root.createUniform(Look, { glow: values.glow, current: values.current });  const stir = root.createUniform(Stir);  const noise = own(perlin3d.staticCache({ root, size: d.vec3u(32, 32, 32) }));  const fill = (cfg: Configurable) =>    noise.inject()(cfg).with(timeOf, time).with(lookOf, look).with(stirOf, stir);  const layered = passes    .create(root, screen, {      hdr: passes.color,      accum: passes.accumulation,      reveal: passes.revealage,    })    .pipe(view.inject())    .pipe(fill);  const ordered = passes.create(root, screen, { hdr: passes.color }).pipe(view.inject()).pipe(fill);  const sim = clock.fixedStep({ step: 1 / 60, maxSteps: 4 });  const swarm = own(    createSwarm(root, view, { size: surface, water: fill, step: sim.step, count: values.count }),  );  const bubbles = own(createBubbles(root, { step: sim.step, camera: view, water: fill }));  /** Adds the tank's drawings, in draw order, to either description. */  const tankIn = (description: typeof ordered) => [    description.add(water),    description.add(snow),    description.add(swarm.tentacles),    description.add(swarm.arms),    description.add(swarm.bells),    description.add(bubbles.drawing),  ];  const drawings = { layered: tankIn(layered), ordered: tankIn(ordered) };  const glow = own(    bloom.create(root, {      source: () => screen.textures.hdr,      settings: { threshold: 1.1, spread: 1.2 },    }),  );  const final = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      tone: tone.aces,      settings: { exposure: 1.15, glowIntensity: 0.18, vignette: 0.3 },      glow,    }),  );  await pipelines.compile([layered, swarm, glow, final]);  // `ordered` compiles the first time `oit` turns off, and frames draw `layered` until it has  let orderedReady = false;  let ordering: Promise<void> | undefined;  const compileOrdered = () =>    (ordering ??= ordered.initAsync().then(() => {      orderedReady = true;    }));  const settings: Settings<typeof values> = {    values,    knobs: {      count: { min: 1, max: MAX, step: 1 },      current: { min: 0, max: 2, step: 0.05 },      glow: { min: 0, max: 2, step: 0.05 },      oit: { label: 'order-independent' },    },    async write(key) {      if (key === undefined || key === 'count') swarm.show(values.count);      if (key === undefined || key === 'glow' || key === 'current') look.write(values);      if (!values.oit) await compileOrdered();    },  };  const ray = { origin: zero(), direction: zero() };  const toward = zero();  const normal = zero();  const point = zero();  const motion = zero();  /** Where the stir is: the drag's latest point. */  const at = zero();  const push = zero();  let stirring = false;  /** Writes where the ray through x, y meets the plane through `MIDDLE` facing the eye. */  const planeAt = (x: number, y: number, out: Vec3) => {    view.rayAt(x, y, ray);    vec3n.sub(MIDDLE, ray.origin, toward);    vec3n.normalize(toward, normal);    const t = vec3n.dot(toward, normal) / vec3n.dot(ray.direction, normal);    vec3n.addScaled(ray.origin, ray.direction, t, out);  };  /** A tap startles the jelly under it; a drag pushes the water along the plane `planeAt` meets. */  const pointing: Pointing = {    press: swarm.tap,    drag(x, y, start) {      planeAt(x, y, point);      if (!start) {        vec3n.addScaled(push, vec3n.sub(point, at, motion), STIR.gain, push);        const size = vec3n.length(push);        if (size > STIR.most) vec3n.mulScalar(push, STIR.most / size, push);      }      vec3n.copy(point, at);      stirring = true;    },  };  let elapsed = 0;  let unsettled = SETTLING_STEPS;  /** Sways the view slowly about wherever the host's orbit left it. */  const sway = (dt: number) => {    const yaw = Math.sin(elapsed * 0.15) * 0.22;    const pitch = Math.sin(elapsed * 0.11) * 0.04;    elapsed += dt;    view.goal.yaw += Math.sin(elapsed * 0.15) * 0.22 - yaw;    view.goal.pitch += Math.sin(elapsed * 0.11) * 0.04 - pitch;  };  /** Lets the stir's push die away, writing it until it has. */  const settleStir = (dt: number) => {    if (!stirring) return;    vec3n.mulScalar(push, Math.exp(-dt * STIR.settle), push);    stir.write({ at, push });    stirring = vec3n.length(push) > 1e-4;  };  return {    view,    settings,    pointing,    frame(frame: Frame) {      sway(frame.dt);      view.update(frame.dt);      screen.fit();      time.write(elapsed);      settleStir(frame.dt);      const compute = frame.encoder.beginComputePass();      bubbles.vent(frame.dt);      bubbles.spawn(compute);      swarm.startle(compute);      for (; unsettled > 0; unsettled--) swarm.step(compute);      for (let steps = sim.advance(frame.dt); steps > 0; steps--) {        swarm.step(compute);        bubbles.step(compute);      }      bubbles.compact(compute, sim.ahead);      compute.end();      if (values.oit || !orderedReady) {        layered.record(frame, drawings.layered);        layered.composite(frame);      } else {        ordered.record(frame, drawings.ordered);      }      glow.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

swarm.ts

import { cameras, picker, type CameraBase, type Inject, type SizeSource } from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { mulberry32 } from 'math/random';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import {  ARMS,  bellPoint,  contraction,  depthOf,  frameOf,  Jelly,  MAX,  MOON,  NETTLE,  placed,  POINTS,  Strand,  STRANDS,  TENTACLES,} from './jelly.ts';import { createTissue } from './tissue.ts';import { timeOf, stirAt } from './water.ts';type Vec3 = [number, number, number];type Cast = { home: Vec3; size: number; kind: number };/** * The jellies the tank opens with: a sea nettle close to the eye, moon jellies behind it at * middle distance, and a nettle further back. The rest are small and far, where the haze takes * them. */const CAST: Cast[] = [  { home: [0.72, 0.5, 1.3], size: 0.32, kind: NETTLE },  { home: [-0.55, 0.3, 0.55], size: 0.36, kind: MOON },  { home: [0.3, -0.45, 0.35], size: 0.28, kind: MOON },  { home: [1.2, 0.75, -0.3], size: 0.3, kind: MOON },  { home: [-1.45, -0.5, -0.2], size: 0.22, kind: MOON },  { home: [-0.15, 0.8, -1], size: 0.22, kind: MOON },  { home: [-1.7, 0.55, -2.4], size: 0.32, kind: NETTLE },];/** * Each kind's look and rhythm, indexed by kind. Strand lengths are the least and the most added at * random, and widths are at the root, all per unit of bell radius. */const KINDS = [  {    /** Moon jellies' milky blue-whites, taken in turn. */    tints: [      d.vec3f(0.84, 0.9, 1),      d.vec3f(0.9, 0.88, 1),      d.vec3f(0.8, 0.92, 1),      d.vec3f(0.92, 0.9, 1),    ],    /** What a moon jelly's gonads and rim glow with. */    glow: d.vec3f(0.6, 0.72, 1),    size: 0.12,    period: 2.6,    tentacles: { reach: [0.25, 0.75], width: 0.004 },    arms: { reach: [0.7, 0.35], width: 0.16 },  },  {    /** The sea nettles' amber, and their warm rim. */    tints: [d.vec3f(1, 0.55, 0.18)],    glow: d.vec3f(1, 0.5, 0.2),    size: 0.24,    period: 3.4,    tentacles: { reach: [2.2, 4], width: 0.0045 },    arms: { reach: [3.4, 1.6], width: 0.36 },  },] as const;/** Seconds a contraction takes, and the relaxation after it. */const CLOSING_SECONDS = 0.35;const OPENING_SECONDS = 1.6;/** A pulse's push and a bell's weight per unit of its radius, and the pull of the water. */const THRUST = 8;const SINK = 0.5;const DRAG = 1.8;/** Seconds after a fright before a jelly can be startled again. */const CALMING_SECONDS = 1.5;/** Whether a jelly last startled at `startled` can be startled again at `time`. */const canStartle = (startled: number, time: number) => {  'use gpu';  return time - startled > CALMING_SECONDS;};/** Threads a trail workgroup runs, one a strand. */const TRAIL_WORKGROUP = 64;/** The flow the jellies drift in, slow and wide. */const drift = (at: d.v3f, time: number) => {  'use gpu';  const p = at * 0.35 + d.vec3f(0, 0, time * 0.03);  return d.vec3f(    perlin3d.sample(p),    perlin3d.sample(p + d.vec3f(5.2, 1.3, 0)) * 0.4,    perlin3d.sample(p + d.vec3f(9.7, 3.1, 0)),  );};/** Places `MAX` jellies and their strands. */function seeded() {  const rng = mulberry32.create(11);  const next = () => mulberry32.sample(rng);  const jellies: Jelly[] = [];  const strands: Strand[] = [];  for (let i = 0; i < MAX; i++) {    const cast = CAST[i];    const kind = cast?.kind ?? (i % 5 === 3 ? NETTLE : MOON);    const look = KINDS[kind]!;    const home: Vec3 = cast?.home ?? [      (next() * 2 - 1) * 3.2,      (next() * 2 - 1) * 1.2,      -2.4 - next() * 4,    ];    const size = cast?.size ?? look.size + next() * 0.08;    const tilt = [(next() - 0.5) * 0.4, 1, (next() - 0.5) * 0.4];    const length = Math.hypot(...tilt);    jellies.push({      position: d.vec3f(...home),      phase: next(),      velocity: d.vec3f(),      size,      axis: d.vec3f(tilt[0]! / length, tilt[1]! / length, tilt[2]! / length),      squeeze: 0,      tint: look.tints[i % look.tints.length]!,      kind,      glow: look.glow,      seed: next(),      home: d.vec3f(...home),      // The far jellies pulse more slowly      period: look.period * (0.85 + next() * 0.35) * (cast ? 1 : 1.2),      startled: -100,    });    for (let s = 0; s < STRANDS; s++) {      const arm = s >= TENTACLES;      const around = arm        ? ((s - TENTACLES + 0.5) / ARMS) * Math.PI * 2        : ((s + (next() - 0.5) * 0.6) / TENTACLES) * Math.PI * 2;      const { reach, width } = arm ? look.arms : look.tentacles;      strands.push({        around,        length: (reach[0] + next() * reach[1]) * size,        width: width * size,        arm: arm ? 1 : 0,        curl: 0.3 + next() * next() * 2.4,        pace: 0.6 + next() * 0.9,      });    }  }  return { jellies, strands };}/** Each strand's points, hanging straight down from under its jelly's bell. */function hanging(jellies: readonly Jelly[], strands: readonly Strand[]) {  const points = new Float32Array(MAX * STRANDS * POINTS * 4);  for (let i = 0; i < MAX * STRANDS; i++) {    const jelly = jellies[Math.floor(i / STRANDS)]!;    const strand = strands[i]!;    const out = strand.arm > 0.5 ? 0.1 : 1;    for (let p = 0; p < POINTS; p++) {      const at = (i * POINTS + p) * 4;      const down = jelly.size * 0.3 + (strand.length * p) / (POINTS - 1);      points[at] = jelly.position.x + Math.cos(strand.around) * jelly.size * out;      points[at + 1] = jelly.position.y - down;      points[at + 2] = jelly.position.z + Math.sin(strand.around) * jelly.size * out;      points[at + 3] = 1;    }  }  return points;}export interface SwarmOptions {  /** The view the jellies are drawn in, for picking what a tap lands on. */  readonly size: SizeSource;  /** Fills the water's accessors. */  readonly water: Inject;  /** Seconds per `step`. */  readonly step: number;  /** How many jellies swim at first, up to `MAX`. */  readonly count: number;}/** Creates the jellies, the kernels that move them and their strands, and their drawings. */export function createSwarm(root: TgpuRoot, camera: CameraBase, options: SwarmOptions) {  const { water, step: dt } = options;  const start = seeded();  const jellyBuffer = root.createBuffer(d.arrayOf(Jelly, MAX), start.jellies).$usage('storage');  const strandBuffer = root    .createBuffer(d.arrayOf(Strand, MAX * STRANDS), start.strands)    .$usage('storage');  // Each strand's points this step and last, for Verlet integration  const Points = d.arrayOf(d.vec4f, MAX * STRANDS * POINTS);  const hung = hanging(start.jellies, start.strands);  const pointBuffer = root.createBuffer(Points).$usage('storage');  const lastBuffer = root.createBuffer(Points).$usage('storage');  pointBuffer.write(hung.buffer);  lastBuffer.write(hung.buffer);  const counted = root.createUniform(d.u32, options.count);  /** The latest tap, from -1 to 1 across the view with +y up. */  const tapped = root.createUniform(d.vec2f);  const jellies = jellyBuffer.as('mutable');  const strands = strandBuffer.as('readonly');  const points = pointBuffer.as('mutable');  const last = lastBuffer.as('mutable');  // Each jelly's position and size as the step begins, so jellies keep apart from where the others  // were rather than from wherever their threads have moved them  const crowd = tgpu.workgroupVar(d.arrayOf(d.vec4f, MAX));  // One workgroup holds every jelly  const swim = tgpu.computeFn({    workgroupSize: [MAX],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    const jelly = Jelly(jellies.$[i]!);    crowd.$[i] = d.vec4f(jelly.position, jelly.size);    std.workgroupBarrier();    if (i >= counted.$) return;    const time = timeOf.$;    const stirred = stirAt(jelly.position);    // A hard stir through the bell startles the jelly, as a tap does    let startled = jelly.startled;    if (std.length(stirred) > 0.6 && canStartle(startled, time)) startled = time;    const fright = std.exp(-std.max(time - startled, 0) * 1.2);    // A quick contraction pushes the bell up; it relaxes, glides and sinks until the next    const phase = jelly.phase + dt / jelly.period;    const closing = CLOSING_SECONDS / jelly.period;    const beat = phase - std.floor(phase);    const opening = OPENING_SECONDS / jelly.period;    const squeeze = contraction(beat, closing, opening) * (1 + fright * 0.25);    const thrust = std.select(0, std.sin((beat / closing) * Math.PI), beat < closing);    // Lean toward home, with the flow and the way it is going, always mostly up    const home = jelly.home - jelly.position;    const wander = drift(d.vec3f(jelly.seed * 40, 0, 0), time);    // Each moon jelly leans its own way, most of them a little toward the eye    const own = d.vec3f(std.fract(jelly.seed * 13) - 0.5, 0, std.fract(jelly.seed * 7) * 0.8 - 0.1);    const leaning = own * std.select(d.f32(0.6), d.f32(0.2), jelly.kind === NETTLE);    const going = d.vec3f(jelly.velocity.x, 0, jelly.velocity.z) / jelly.size;    const lean =      d.vec3f(home.x, 0, home.z) * 0.4 + wander * 0.25 + stirred * 0.5 + leaning + going * 0.25;    // Never tipping further than about 30 degrees    const sideways = std.length(lean.xz);    const tilt = lean.xz * (std.min(sideways, 0.6) / std.max(sideways, 0.0001));    const heading = std.normalize(d.vec3f(tilt.x, 1, tilt.y));    const axis = std.normalize(jelly.axis + (heading - jelly.axis) * (dt * (0.8 + fright * 3)));    // Keep apart from the jellies around    let apart = d.vec3f();    for (let j = d.u32(0); j < counted.$; j++) {      if (j === i) continue;      const other = crowd.$[j]!;      const away = jelly.position - other.xyz;      const near = (jelly.size + other.w) * 1.2;      const distance = std.length(away);      if (distance < near && distance > 0.0001) {        apart += (away / distance) * (1 - distance / near);      }    }    const accel =      axis * (thrust * THRUST * jelly.size) +      d.vec3f(0, -SINK * jelly.size, 0) +      home * d.vec3f(0.12, 0.06, 0.12) +      drift(jelly.position, time) * 0.03 +      stirred +      apart * 0.5;    const velocity = jelly.velocity * std.exp(-DRAG * dt) + accel * dt;    jellies.$[i] = Jelly({      position: jelly.position + velocity * dt,      phase,      velocity,      size: jelly.size,      axis,      squeeze,      tint: jelly.tint,      kind: jelly.kind,      glow: jelly.glow,      seed: jelly.seed,      home: jelly.home,      period: jelly.period,      startled,    });  });  const trail = tgpu.computeFn({    workgroupSize: [TRAIL_WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    const owner = std.intdiv(i, d.u32(STRANDS));    if (owner >= counted.$) return;    const time = timeOf.$;    const jelly = Jelly(jellies.$[owner]!);    const strand = strands.$[i]!;    const arm = strand.arm > 0.5;    const frame = frameOf(jelly.axis);    // A tentacle hangs from the margin, an oral arm from round the middle under the bell    let local = bellPoint(jelly, time, 0.99, strand.around);    if (arm) {      local = d.vec3f(        std.cos(strand.around) * 0.2,        -depthOf(jelly.kind) * 0.4,        std.sin(strand.around) * 0.2,      );    }    const anchor = placed(jelly, local);    const base = i * POINTS;    points.$[base] = d.vec4f(anchor, 1);    last.$[base] = d.vec4f(anchor, 1);    const segment = strand.length / (POINTS - 1);    const keep = std.select(0.97, 0.94, arm);    const sink = std.select(d.f32(0.3), d.f32(0.45), arm);    // A curl that travels down each strand, and the water a pulse draws in and the glide lets out    const curl = std.select(strand.curl * 1.6, d.f32(0.15), arm);    const spread = std.select((0.3 - jelly.squeeze) * 0.45, d.f32(0.12), arm);    // Strands resist bending, so a bunched strand curls instead of folding into corners    const stiff = std.select(d.f32(0.04), d.f32(0.03), arm);    let grand = anchor + jelly.axis * segment;    let parent = d.vec3f(anchor);    let held = d.vec3f(anchor);    // Follow the leader, then take back from each point's speed the pull its follower got    // (Müller's dynamic follow-the-leader), so a pulse doesn't fling the strand past the bell    for (let p = d.u32(1); p < POINTS; p++) {      const now = points.$[base + p]!.xyz;      const before = last.$[base + p]!.xyz;      const down = d.f32(p) / (POINTS - 1);      const wave =        d.f32(p) * (0.3 + strand.pace * 0.3) -        time * 2.6 * strand.pace +        strand.around * 7 +        jelly.seed * 6.28;      const across = frame * d.vec3f(std.cos(wave), 0, std.sin(wave));      const out = now - jelly.position;      const radial = out - jelly.axis * std.dot(out, jelly.axis);      const outward = radial / std.max(std.length(radial), 0.0001);      const force =        d.vec3f(0, -sink, 0) +        across * (curl * down) +        outward * (spread * (1 - down)) +        drift(now * 2.5, time * 3) * 0.15 +        stirAt(now) * 1.4;      const free = now + (now - before) * keep + force * (dt * dt);      const straight = parent + std.normalize(parent - grand + 0.000001) * segment;      const moved = std.mix(free, straight, stiff);      const reach = moved - parent;      const next = parent + reach * (segment / std.max(std.length(reach), 1e-6));      if (p > 1) last.$[base + p - 1] = d.vec4f(held + (next - moved) * 0.9, 1);      held = d.vec3f(now);      points.$[base + p] = d.vec4f(next, 1);      grand = d.vec3f(parent);      parent = d.vec3f(next);    }    last.$[base + POINTS - 1] = d.vec4f(held, 1);  });  // The tapped jelly nearest the eye flashes, contracts hard and darts away from the tap  const hits = picker.create(root, camera, {    capacity: MAX,    size: options.size,    position: (i) => {      'use gpu';      return d.vec3f(jellies.$[i]!.position);    },    radius: (i) => {      'use gpu';      return jellies.$[i]!.size * 1.4;    },    alive: (i) => {      'use gpu';      return i < counted.$;    },    span: () => count,    rank: picker.byEye,  });  const startle = tgpu.computeFn({ workgroupSize: [1] })(() => {    'use gpu';    const i = hits.found();    if (i === picker.none) return;    const time = timeOf.$;    const jelly = jellies.$[i]!;    const ray = cameras.rayOf(cameras.current.$, tapped.$);    const toward = jelly.position - ray.origin;    const miss = toward - ray.direction * std.dot(toward, ray.direction);    const dart = std.normalize(d.vec3f(miss.x, 0, miss.z) + ray.direction * 0.3 + 0.0001);    if (canStartle(jelly.startled, time)) jelly.startled = time;    // Contract at once, unless already contracting    if (jelly.phase - std.floor(jelly.phase) > CLOSING_SECONDS / jelly.period) {      jelly.phase = std.ceil(jelly.phase);    }    jelly.velocity = std.mix(jelly.velocity, dart * (jelly.size * 1.6), 0.4);  });  const swimming = root.pipe(water).createComputePipeline({ compute: swim });  const trailing = root.pipe(water).createComputePipeline({ compute: trail });  const startling = root    .pipe(water)    .pipe(camera.inject())    .pipe(hits.inject())    .createComputePipeline({ compute: startle });  let count = options.count;  let tap: [x: number, y: number] | undefined;  const tissue = createTissue(root, {    jellies: jellyBuffer.as('readonly'),    strands,    points: pointBuffer.as('readonly'),    count: options.count,  });  return {    ...tissue,    /** Moves the jellies one fixed step, then their strands. */    step(pass: TgpuComputePass) {      pass.setPipeline(swimming);      pass.dispatchWorkgroups(1);      pass.setPipeline(trailing);      pass.dispatchWorkgroups(Math.ceil((count * STRANDS) / TRAIL_WORKGROUP));    },    /** Startles the jelly at `x`, `y`, from 0 to 1 across the view from its top left, in `startle`. */    tap(x: number, y: number) {      tapped.write(d.vec2f(x * 2 - 1, 1 - y * 2));      tap = [x, y];    },    /** Startles the jelly the latest tap found. Call once a frame, before `step`. */    startle(pass: TgpuComputePass) {      if (!tap) return;      hits.pick(pass, ...tap);      pass.setPipeline(startling);      pass.dispatchWorkgroups(1);      tap = undefined;    },    /** Sets how many jellies swim and draw. */    show(next: number) {      count = next;      tissue.show(next);      counted.write(next);    },    initAsync: () =>      Promise.all([        hits.initAsync(),        swimming.initAsync(),        trailing.initAsync(),        startling.initAsync(),      ]),    destroy() {      tissue.destroy();      hits.destroy();      jellyBuffer.destroy();      strandBuffer.destroy();      pointBuffer.destroy();      lastBuffer.destroy();    },  };}

jelly.ts

// A jelly's parts: its state, the strands under its bell and the bell's shapeimport { d, std } from 'typegpu';/** A moon jelly, a shallow saucer with a fine fringe and four gonads, or a taller sea nettle. */export const MOON = 0;export const NETTLE = 1;/** The most jellies the tank holds. */export const MAX = 32;/** Tentacles round each margin, and oral arms under each bell. */export const TENTACLES = 40;export const ARMS = 4;export const STRANDS = TENTACLES + ARMS;/** Points along each strand, its root on the bell. */export const POINTS = 24;export const Jelly = d.struct({  /** The bell's apex, in world space. */  position: d.vec3f,  /** Pulses so far; the fraction is where the bell is in its beat. */  phase: d.f32,  velocity: d.vec3f,  /** The bell's radius, relaxed. */  size: d.f32,  /** The way the bell faces, a unit vector out of its apex. */  axis: d.vec3f,  /** How far the bell has contracted, from 0 to 1. */  squeeze: d.f32,  tint: d.vec3f,  kind: d.u32,  /** The colour of the bell's own glow. */  glow: d.vec3f,  /** A number from 0 to 1 that sets the jelly's own rhythm. */  seed: d.f32,  /** Where the jelly keeps returning to, so the tank keeps its near, middle and far. */  home: d.vec3f,  /** Seconds between pulses. */  period: d.f32,  /** When the jelly was last disturbed, in scene seconds. It flashes and darts away. */  startled: d.f32,});export type Jelly = d.Infer<typeof Jelly>;/** A tentacle or an oral arm. Jelly `j` owns strands `j * STRANDS` on, its tentacles first. */export const Strand = d.struct({  /** Where round the bell it hangs from, in radians. */  around: d.f32,  length: d.f32,  /** Width at the root, in world units. */  width: d.f32,  /** 1 for an oral arm, 0 for a tentacle. */  arm: d.f32,  /** How far, and how quickly, waves curl down it. */  curl: d.f32,  pace: d.f32,});export type Strand = d.Infer<typeof Strand>;/** How far round each bell reaches from its apex, in radians, and its depth per unit radius. */const REACH = [1.05, 1.3] as const;const DEPTH = [0.3, 0.72] as const;/** Lobes round each margin. */const LOBES = 16;/** The share of a bell's width a full contraction takes from its margin, and the height it adds. */const PINCH = 0.28;const LIFT = 0.4;/** Depth of a relaxed bell per unit radius. */export const depthOf = (kind: number) => {  'use gpu';  return std.select(d.f32(DEPTH[0]), d.f32(DEPTH[1]), kind === NETTLE);};/** * A point of a jelly's unit bell around +y, its apex at the origin, at `time`: `v` runs from the * apex to the margin and `around` round the axis, in radians. A squeezed bell draws its margin in * and grows taller. No two bells, and no two lappets, are quite alike. */export const bellPoint = (  jelly: d.InferGPU<typeof Jelly>,  time: number,  v: number,  around: number,) => {  'use gpu';  const nettle = jelly.kind === NETTLE;  const squeeze = jelly.squeeze;  const seed = jelly.seed * 40;  const reach = std.select(d.f32(REACH[0]), d.f32(REACH[1]), nettle);  const theta = v * reach;  const v2 = v * v;  const v4 = v2 * v2;  // Lappets of uneven spacing and depth  const uneven = around * LOBES * 0.5 + std.sin(around * 5 + seed) * 0.35;  const deep = 0.6 + 0.4 * std.sin(around * 7 + seed * 1.3);  const lappets = std.abs(std.cos(uneven)) * deep;  const scallop = 1 + (lappets - 0.5) * v4 * v2 * 0.05;  // A slightly lopsided bell  const lopsided = 1 + std.sin(around + seed) * 0.035 + std.sin(around * 2 + seed * 0.7) * 0.02;  // The margin snaps in on a contraction and ripples as it does  const ripple = std.sin(around * 6) * v4 * squeeze * (1 - squeeze) * 0.12;  const pinch = 1 - squeeze * PINCH * v2 * std.sqrt(v) + ripple;  // A sea nettle's margin flares out a little when relaxed  const margin = std.smoothstep(0.72, 1, v);  const flare = std.select(0, margin * margin * 0.04 * (1 - squeeze), nettle);  const r = (std.sin(theta) / std.sin(reach)) * pinch * scallop * lopsided + flare;  // The margin undulates even at rest  const swell =    margin *    margin *    (std.sin(around * 3 + time * 1.1 + seed) * 0.035 + std.sin(around * 5 - time * 0.8) * 0.015);  // A moon jelly's margin turns down a little at its very edge  const lip = std.select(std.smoothstep(0.82, 1, v) * 0.07, -flare * 0.9, nettle);  const cupped = (1 - std.cos(theta)) / (1 - std.cos(reach));  const y = -depthOf(jelly.kind) * (1 + squeeze * LIFT) * cupped - lip + swell;  return d.vec3f(r * std.cos(around), y, r * std.sin(around));};/** The jelly's own frame: `right` and `forward` across its axis. */export const frameOf = (axis: d.v3f) => {  'use gpu';  const ref = std.select(d.vec3f(0, 0, 1), d.vec3f(1, 0, 0), std.abs(axis.z) > 0.9);  const right = std.normalize(std.cross(axis, ref));  return d.mat3x3f(right, axis, std.cross(right, axis));};/** Where a point of a jelly's unit bell is in the world. */export const placed = (jelly: d.InferGPU<typeof Jelly>, local: d.v3f) => {  'use gpu';  return jelly.position + frameOf(jelly.axis) * local * jelly.size;};/** * How far the bell contracts at `beat`, the fraction of a pulse: in over `closing` of the beat, * out over `opening`, then a glide. */export const contraction = (beat: number, closing: number, opening: number) => {  'use gpu';  const shut = std.smoothstep(0, closing, beat);  const open = 1 - std.smoothstep(closing, closing + opening, beat);  return std.select(open, shut, beat < closing);};

tank.ts

import { cameras, environment } from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { d, std, tgpu } from 'typegpu';import {  caustic,  timeOf,  SHALLOW,  SUNLIGHT,  SURFACE,  throughSurface,  waterAlong,} from './water.ts';/** Samples along each view ray for the light shafts, and how far they reach. */const STEPS = 8;const REACH = 11;/** The shafts' colour and brightness. */const SHAFTS = d.vec3f(0.011, 0.024, 0.036);/** Cosine of the half-angle of the window through which the sky shows from under the surface. */const WINDOW = 0.66;/** * Shafts of light coming down through the surface, summed along a ray from the eye. The samples * spread over the stretch of the ray below the surface, so the sum changes smoothly as the ray * tips up into it. */const shaftsAlong = (eye: d.v3f, direction: d.v3f, time: number) => {  'use gpu';  const toSurface = (SURFACE - eye.y) / std.max(direction.y, 0.0001);  const reach = std.min(d.f32(REACH), std.select(d.f32(REACH), toSurface, direction.y > 0));  let sum = d.f32(0);  for (const i of tgpu.unroll(std.range(STEPS))) {    const t = ((i + 0.5) / STEPS) * reach;    const at = eye + direction * t;    const below = std.max(SURFACE - at.y, 0);    const drifting = throughSurface(at) * 0.42 + d.vec2f(time * 0.012, time * 0.005);    const beam = perlin3d.sample(d.vec3f(drifting, time * 0.05));    const lit = std.smoothstep(0.1, 0.65, beam) * std.exp(-below * 0.55);    sum += lit * (1 - t / (REACH * 1.2));  }  return sum * (reach / STEPS);};/** The surface seen from below: the sky through Snell's window, the tank reflected around it. */const surfaceAlong = (eye: d.v3f, direction: d.v3f, time: number) => {  'use gpu';  const far = (SURFACE - eye.y) / std.max(direction.y, 0.01);  const at = eye.xz + direction.xz * far;  const ripples = caustic(at * 0.7, time * 1.3);  const reflected = SHALLOW * (0.5 + ripples * 1.6);  const sky = SUNLIGHT * (0.5 + ripples * 0.4);  const shown = std.mix(reflected, sky, std.smoothstep(WINDOW - 0.05, WINDOW + 0.06, direction.y));  return d.vec4f(shown, std.exp(-far * 0.3));};/** The water seen from the eye along `direction`: the surface above, and the shafts through it. */const waterFrom = (direction: d.v3f) => {  'use gpu';  const eye = cameras.current.$.position;  const time = timeOf.$;  let color = waterAlong(direction);  if (direction.y > 0) {    const surface = surfaceAlong(eye, direction, time);    color = std.mix(color, surface.xyz, surface.w * std.smoothstep(0, 0.08, direction.y));  }  return color + SHAFTS * shaftsAlong(eye, direction, time);};/** The water behind the jellies, which blend over it. */export const water = environment.background(waterFrom, { label: 'water' });

snow.ts

import { cameras, passes } from '@typegpu/kit';import { randf } from '@typegpu/noise';import { d, std } from 'typegpu';import { quadCorner } from './tissue.ts';import { hazed, lightAt, Seen, stirAt, SUNLIGHT, timeOf } from './water.ts';/** Motes of marine snow, and the box they drift down through. */const MOTES = 700;const LOW = d.vec3f(-4, -2.6, -7);const HIGH = d.vec3f(4, 2.4, 1.6);const Mote = d.struct({  position: d.vec4f,  world: d.vec3f,  /** The quad's corner, from -1 to 1 on each axis. */  corner: d.vec2f,  /** The share of the drawn size the mote fills, and how far through its fall it is. */  coverage: d.f32,  fall: d.f32,});const moteAt = (vertexIndex: number, instanceIndex: number) => {  'use gpu';  randf.seed(d.f32(instanceIndex) + 0.5);  const start = d.vec3f(randf.sample(), randf.sample(), randf.sample());  const size = 0.002 + randf.sample() * 0.003;  const time = timeOf.$;  // Each mote sinks slowly, the larger ones faster, and wraps round to the top of the box  const fall = std.fract(start.y - (time * (0.01 + size * 4)) / (HIGH.y - LOW.y));  const sway = std.sin(time * 0.25 + start.z * 40) * 0.04;  const resting = LOW + (HIGH - LOW) * d.vec3f(start.x, fall, start.z) + d.vec3f(sway, 0, 0);  // A stir carries the motes along while it lasts  const centre = resting + stirAt(resting) * 0.15;  const corner = d.vec2f(quadCorner(vertexIndex)) * 2 - 1;  const toEye = std.normalize(cameras.current.$.position - centre);  const right = std.normalize(std.cross(d.vec3f(0, 1, 0), toEye));  const up = std.cross(toEye, right);  const clip = cameras.current.$.viewProjection * d.vec4f(centre, 1);  const pixel = (2 * clip.w) / (cameras.lensOf(cameras.current.$).y * passes.pixels.$.size.y);  // Motes very near the eye are out of focus: larger and fainter  const blur = std.max(1.5 - std.length(cameras.current.$.position - centre), 0) * 0.008;  const drawn = std.max(size + blur, pixel * 1.5);  const world = centre + (right * corner.x + up * corner.y) * drawn;  const share = size / drawn;  return Mote({    position: cameras.current.$.viewProjection * d.vec4f(world, 1),    world,    corner,    coverage: share * share,    fall,  });};const moteOf = (mote: d.Infer<typeof Mote>) => {  'use gpu';  const r = std.length(mote.corner);  // Fade in at the top of the box and out at its floor, so the wrap never shows  const ends = std.smoothstep(0, 0.08, mote.fall) * (1 - std.smoothstep(0.9, 1, mote.fall));  const alpha = (1 - std.smoothstep(0.2, 1, r)) * 0.45 * mote.coverage * ends;  const lit = SUNLIGHT * (lightAt(mote.world, timeOf.$) * 0.9);  return Seen({ color: hazed(mote.world, lit * alpha, alpha) });};/** Marine snow drifting down through the tank, for scale and depth. */export const snow: passes.Drawing<typeof Mote, Seen> = {  label: 'marine snow',  Vertex: Mote,  at: moteAt,  surfaceOf: moteOf,  color: passes.shaded,  translucent: true,  draw: (pass) => pass.draw(6, MOTES),};

bubbles.ts

import { clock, Particle, particles, type CameraBase, type Inject } from '@typegpu/kit';import { mulberry32 } from 'math/random';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import { stirAt, SURFACE } from './water.ts';/** Where streams of bubbles rise from, on the tank's floor. */const VENTS: [number, number, number][] = [  [-1.9, -2.4, -1.4],  [1.4, -2.4, -0.4],];/** Wobble rate in radians a second, and its push. */const WOBBLE = { rate: 9, push: 1.4 };/** A bubble's thin rim, brightest on top where the light from the surface catches it. */const film = (bubble: Particle, local: d.v2f) => {  'use gpu';  const r = std.length(local);  const rim = std.smoothstep(0.55, 0.9, r) * (1 - std.smoothstep(0.9, 1, r));  const top = std.saturate(local.y * 0.7 + 0.45);  const spot = std.saturate(1 - std.length(local - d.vec2f(-0.2, 0.5)) / 0.28);  const alpha = (rim * (0.12 + top * 0.5) + spot * 0.5) * bubble.color.w;  return d.vec4f(bubble.color.xyz * (alpha * top + spot * 1.2), alpha);};/** Wobbles a bubble and lets the current carry it. It bursts at the surface. */const rise = (bubble: Particle, dt: number) => {  'use gpu';  const next = Particle(bubble);  const wobble = std.sin(bubble.age * WOBBLE.rate + bubble.seed * 40) * WOBBLE.push;  const push = d.vec3f(wobble, 0, wobble * 0.6) + stirAt(bubble.position);  next.velocity = bubble.velocity + push * dt;  if (bubble.position.y > SURFACE) next.life = 0;  return next;};export interface BubblesOptions {  /** The length of a step in seconds; see `particles.Options.step`. */  readonly step: number;  readonly camera: CameraBase;  /** Fills the water's accessors. */  readonly water: Inject;}/** Bubbles rising in thin streams from the floor, wobbling and stirred by the current. */export function createBubbles(root: TgpuRoot, options: BubblesOptions) {  const bubbles = particles.create(root, options.camera, {    step: options.step,    capacity: 128,    label: 'bubbles',    update: tgpu.fn(rise).pipe(options.water),    shade: film,    blend: 'translucent',    settings: { gravity: [0, 1.6, 0], drag: 2.2 },  });  const rng = mulberry32.create(3);  const puff = clock.cadence({ hz: 2.5 });  let vent = 0;  return {    ...bubbles,    /** Releases a bubble from the next vent now and then. Call once a frame before `spawn`. */    vent(dt: number) {      if (!puff.due(dt)) return;      vent = (vent + 1) % VENTS.length;      bubbles.burst({        count: 1,        position: VENTS[vent]!,        area: [0.03, 0, 0.03],        scatter: 0.05,        size: 0.012 + mulberry32.sample(rng) * 0.013,        life: 14,        color: [0.75, 0.95, 1, 1],      });    },  };}

water.ts

import { cameras } from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { d, std, tgpu } from 'typegpu';// Colours are in linear light/** The dark below. */export const ABYSS = d.vec3f(0.0006, 0.0018, 0.005);/** The water level with the eye, a deep navy. */export const NAVY = d.vec3f(0.003, 0.013, 0.029);/** The water just under the surface, lit through it. */export const SHALLOW = d.vec3f(0.009, 0.038, 0.065);/** The light coming through the surface. */export const SUNLIGHT = d.vec3f(0.78, 0.92, 1);/** The cold blue-green light a disturbed jelly gives off. */export const FLASH = d.vec3f(0.2, 0.85, 1);/** Height of the water's surface above the tank's middle. */export const SURFACE = 2.4;/** Direction the light travels through the water, down and a little aside. */export const LIGHT = std.normalize(d.vec3f(0.18, -1, 0.1));export const Look = d.struct({  /** Brightness of the jellies' own glow. */  glow: d.f32,  /** How strongly dragging stirs the water. */  current: d.f32,});export const Stir = d.struct({  /** Where the stir is, in world space. */  at: d.vec3f,  /** Its push, in world units a second squared. */  push: d.vec3f,});/** What the tank's drawings give their colour output, premultiplied. */export const Seen = d.struct({ color: d.vec4f });export type Seen = d.Infer<typeof Seen>;/** Seconds since the scene started. */export const timeOf = tgpu.accessor(d.f32);export const lookOf = tgpu.accessor(Look);export const stirOf = tgpu.accessor(Stir);/** Caustic web on a level plane at `xz`, bright where the waves above focus the light. */export const caustic = (xz: d.v2f, time: number) => {  'use gpu';  const a = 1 - std.abs(perlin3d.sample(d.vec3f(xz * 1.1, time * 0.22)));  const b = 1 - std.abs(perlin3d.sample(d.vec3f(xz * 1.9 + 7.3, time * 0.29 + 13)));  const a2 = a * a;  const b2 = b * b;  return a2 * a2 * a2 * 0.7 + b2 * b2 * b2 * b2 * 0.5;};/** Where light reaching `world` crossed the surface. */export const throughSurface = (world: d.v3f) => {  'use gpu';  return world.xz + LIGHT.xz * ((SURFACE - world.y) / -LIGHT.y);};/** Light reaching `world` from above, through the surface's caustics, dimming with depth. */export const lightAt = (world: d.v3f, time: number) => {  'use gpu';  const below = std.max(SURFACE - world.y, 0);  const web = caustic(throughSurface(world) * 0.8, time);  return std.exp(-below * 0.38) * (0.6 + web * 1.4);};/** The water's colour seen along `direction`, without shafts. */export const waterAlong = (direction: d.v3f) => {  'use gpu';  const up = direction.y;  const low = std.mix(NAVY, ABYSS, std.smoothstep(0.05, -0.45, up));  return std.mix(low, SHALLOW, std.smoothstep(0.1, 0.55, up));};/** How much of a surface `distance` away the water hides, from 0 to 1. */export const fogOf = (distance: number) => {  'use gpu';  return 1 - std.exp(-std.max(distance - 2.2, 0) * 0.42);};/** Premultiplied `lit` of opacity `alpha` at `world`, hazed toward the water behind it. */export const hazed = (world: d.v3f, lit: d.v3f, alpha: number) => {  'use gpu';  const toEye = cameras.current.$.position - world;  const distance = std.length(toEye);  const haze = fogOf(distance);  const water = waterAlong(toEye / -distance);  return d.vec4f(lit * (1 - haze) + water * (alpha * haze), alpha);};/** The push a drag gives the water at `at`. */export const stirAt = (at: d.v3f) => {  'use gpu';  const away = at - stirOf.$.at;  return stirOf.$.push * (std.exp(-std.dot(away, away) * 1.6) * lookOf.$.current);};

tissue.ts

import { cameras, lines, passes } from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { d, std, type TgpuReadonly, type TgpuRoot } from 'typegpu';import {  ARMS,  bellPoint,  frameOf,  Jelly,  MAX,  NETTLE,  placed,  POINTS,  Strand,  STRANDS,  TENTACLES,} from './jelly.ts';import { FLASH, hazed, lightAt, lookOf, Seen, SUNLIGHT, timeOf } from './water.ts';/** Rings from apex to margin, and segments round the axis, of each bell's mesh. */const RINGS = 28;const SEGMENTS = 96;/** Pieces each span between a strand's points is drawn in, along a smooth curve. */const SMOOTH = 5;const PIECES = (POINTS - 1) * SMOOTH;const Skin = d.struct({  position: d.vec4f,  world: d.vec3f,  normal: d.vec3f,  /** The bell flattened onto a unit disc, the apex at its middle. */  disc: d.vec2f,  jelly: d.u32,});const Ruffle = d.struct({  position: d.vec4f,  world: d.vec3f,  /** The ribbon's normal, where it folds. */  fold: d.vec3f,  across: d.f32,  along: d.f32,  strand: d.u32,});/** * The corner of a quad drawn as two triangles that `vertexIndex` is: x is 1 for the corners one * step along, y for those one step across. */export const quadCorner = (vertexIndex: number) => {  'use gpu';  const corner = vertexIndex % 6;  return d.vec2u(    std.select(d.u32(0), d.u32(1), corner === 1 || corner === 4 || corner === 5),    std.select(d.u32(0), d.u32(1), corner === 2 || corner === 3 || corner === 5),  );};/** * How brightly a jelly startled at `startled` glows at `u` along its body: 0 at its apex, 0.6 at * its margin and on down its tentacles to 2. The light runs down from the apex and fades. */const flashOf = (startled: number, u: number) => {  'use gpu';  const age = std.max(timeOf.$ - startled, 0);  const front = age * 0.9;  const ahead = (u - front) / 0.06;  const ring = std.exp(-ahead * ahead);  const behind = 1 - std.smoothstep(front - 0.04, front + 0.04, u);  return (ring + behind * 0.1) * std.exp(-age * 1.1);};/** * Light scattered through tissue at `world`, from the surface above, premultiplied by `alpha`, * plus what it glows with, then hazed toward the water behind it with distance. */const tissue = (world: d.v3f, tint: d.v3f, up: number, alpha: number, glow: d.v3f) => {  'use gpu';  const key = lightAt(world, timeOf.$) * (0.55 + 0.45 * std.saturate(up));  return hazed(world, tint * SUNLIGHT * (key * 1.15) * alpha + glow, alpha);};export interface TissueOptions {  readonly jellies: TgpuReadonly<d.WgslArray<typeof Jelly>>;  readonly strands: TgpuReadonly<d.WgslArray<typeof Strand>>;  /** Each strand's points, `POINTS` a strand. */  readonly points: TgpuReadonly<d.WgslArray<d.Vec4f>>;  /** How many jellies to draw at first; `show` changes it. */  readonly count: number;}/** Draws the jellies' bells, tentacles and oral arms as translucent tissue. */export function createTissue(root: TgpuRoot, options: TissueOptions) {  const { jellies, strands, points } = options;  let count = options.count;  const skinAt = (vertexIndex: number, instanceIndex: number) => {    'use gpu';    const quad = std.intdiv(vertexIndex, d.u32(6));    const corner = quadCorner(vertexIndex);    const ring = std.intdiv(quad, d.u32(SEGMENTS));    const segment = quad % SEGMENTS;    const v = std.max(d.f32(ring + corner.y) / RINGS, 0.01);    const around = (d.f32(segment + corner.x) / SEGMENTS) * Math.PI * 2;    const jelly = Jelly(jellies.$[instanceIndex]!);    const time = timeOf.$;    const here = bellPoint(jelly, time, v, around);    const down = bellPoint(jelly, time, v + 0.01, around) - here;    const aside = bellPoint(jelly, time, v, around + 0.01) - here;    const world = placed(jelly, here);    return Skin({      position: cameras.current.$.viewProjection * d.vec4f(world, 1),      world,      normal: frameOf(jelly.axis) * std.cross(aside, down),      disc: d.vec2f(std.cos(around), std.sin(around)) * v,      jelly: instanceIndex,    });  };  const skinOf = (skin: d.Infer<typeof Skin>) => {    'use gpu';    const jelly = jellies.$[skin.jelly]!;    const nettle = jelly.kind === NETTLE;    const n = std.normalize(skin.normal);    const toEye = std.normalize(cameras.current.$.position - skin.world);    // Positive where the eye sees the outside of the bell, negative where it looks into it    const facing = std.dot(n, toEye);    const square = std.saturate(std.abs(facing));    const v = std.length(skin.disc);    // Fade the last pixel of the silhouette, and of the hem where the mesh ends, instead of    // cutting them    const side = std.saturate(square / std.max(std.fwidth(facing) * 1.5, 0.0001));    const hem = std.saturate((1 - v) / std.max(std.fwidth(v) * 1.5, 0.0001));    const edge = side * hem;    const around = std.atan2(skin.disc.y, skin.disc.x);    // Seen face-on the tissue is thin and clear; edge-on, and at the margin, the light crosses    // more of it and it takes more colour    const graze = 1 - square;    const graze2 = graze * graze;    const margin = std.smoothstep(0.8, 1, v);    const rim = graze2 * graze2 * graze;    const thin = std.select(d.f32(0.035), d.f32(0.16), nettle);    const thick = std.select(d.f32(0.32), d.f32(0.45), nettle);    const body = thin + (thick - thin) * std.saturate(graze2 * graze + margin * 0.4);    // Four horseshoe gonads round a moon jelly's middle, open toward it, blurred by the tissue    // over them    const centre = d.vec2f(std.sign(skin.disc.x), std.sign(skin.disc.y)) * 0.2;    const offset = skin.disc - centre;    const wobble = perlin3d.sample(d.vec3f(skin.disc * 9, jelly.seed * 17)) * 0.025;    const fromRing = std.abs(std.length(offset) - 0.1 - wobble);    const open = std.smoothstep(      0.4,      0.9,      -std.dot(std.normalize(offset + 0.00001), std.normalize(centre + 0.00001)),    );    const blurred = 1 - std.smoothstep(0, 0.06, fromRing);    const gonads = std.select(blurred * (1 - open) * (0.4 + 0.6 * square), 0, nettle);    // Faint canals on a moon jelly; pigment banded through a nettle's tissue    const spokes = std.abs(std.sin(around * 8));    const canal = (1 - std.smoothstep(0, std.max(std.fwidth(spokes) * 2, 0.06), spokes)) * 0.7;    const stripe = std.smoothstep(0.7, 0.97, spokes) * std.smoothstep(0.1, 0.3, v);    const canals = std.select(canal * std.smoothstep(0.3, 0.6, v), 0, nettle);    const stripes = std.select(0, stripe, nettle);    const alpha = (body + gonads * 0.1 + canals * 0.02 + stripes * 0.2) * edge;    // A moon jelly is milky and lit from above; a nettle's tissue is amber and darker for its    // pigment, and paler where the eye looks into the bell    const inner = std.select(d.f32(0), d.f32(1), facing < 0);    const amber = std.mix(jelly.tint, d.vec3f(1, 0.78, 0.5), inner * 0.6);    const pigment = std.mix(amber, d.vec3f(0.42, 0.1, 0.03), stripes * 0.8);    const milky = std.mix(jelly.tint, d.vec3f(0.85, 0.62, 0.95), gonads);    // Thin tissue face-on lets most light through; it glows with colour where it is thick    const veil = 0.3 + 0.7 * std.saturate(graze2 + margin * 0.5);    const tint = std.select(      milky * 1.7,      pigment * (veil * std.select(0.9, 0.85, inner > 0)),      nettle,    );    const own = lookOf.$.glow;    const glow =      jelly.glow * (rim * std.select(0.3, 0.22, nettle) * own * edge) +      d.vec3f(0.8, 0.45, 0.95) * (gonads * 0.04 * own);    // A startled jelly's light runs out to its margin and round it    const shimmer = 0.6 + 0.4 * std.sin(around * 8 - timeOf.$ * 9);    const flash = flashOf(jelly.startled, v * 0.6) * shimmer;    const lit =      glow + FLASH * (flash * (alpha * 0.3 + margin * alpha * 4 + rim * 0.4 * edge) * own);    const up = n.y * std.sign(facing);    return Seen({ color: tissue(skin.world, tint, up * 0.6, alpha, lit) });  };  /** A point on the Catmull-Rom curve through a strand's points, `s` from 0 to `POINTS - 1`. */  const curveAt = (base: number, s: number) => {    'use gpu';    const i = std.min(d.u32(s), d.u32(POINTS - 2));    const f = s - d.f32(i);    const p0 = points.$[base + std.max(i, 1) - 1]!.xyz;    const p1 = points.$[base + i]!.xyz;    const p2 = points.$[base + i + 1]!.xyz;    const p3 = points.$[base + std.min(i + 2, d.u32(POINTS - 1))]!.xyz;    const a = p2 - p0;    const b = p0 * 2 - p1 * 5 + p2 * 4 - p3;    const c = p1 * 3 - p0 - p2 * 3 + p3;    return p1 + (a * f + b * (f * f) + c * (f * f * f)) * 0.5;  };  /** The unit direction a strand's curve runs at `s`, toward its tip. */  const tangentAt = (base: number, s: number) => {    'use gpu';    return std.normalize(      curveAt(base, std.min(s + 0.2, POINTS - 1)) - curveAt(base, std.max(s - 0.2, 0)) + 0.00001,    );  };  /** Point `i` of a tentacle, its `SMOOTH` pieces a span along the curve through its points. */  const tentacleAt = (line: number, i: number) => {    'use gpu';    const index = std.intdiv(line, d.u32(TENTACLES)) * STRANDS + (line % TENTACLES);    const s = d.f32(i) / SMOOTH;    const along = s / (POINTS - 1);    return lines.Point({      position: curveAt(index * POINTS, s),      width: strands.$[index]!.width * (1 - along * 0.75),      color: d.vec4f(1),      along,    });  };  const tentacleOf = (fragment: lines.Fragment) => {    'use gpu';    const jelly = jellies.$[std.intdiv(fragment.line, d.u32(TENTACLES))]!;    const nettle = jelly.kind === NETTLE;    const profile = 1 - fragment.across * fragment.across;    const fade = std.saturate(1 - fragment.along);    const alpha =      std.select(0.45, 0.5, nettle) * profile * fade * std.sqrt(fade) * fragment.coverage;    const tint = std.select(jelly.tint, d.vec3f(0.95, 0.42, 0.22), nettle);    const flash = flashOf(jelly.startled, 0.6 + fragment.along * 1.4);    const glow = (jelly.glow * (0.25 * fade) + FLASH * (flash * 7)) * (lookOf.$.glow * alpha);    return tissue(fragment.position, tint, 0.4, alpha, glow);  };  const tentacles = lines.create(root, {    capacity: MAX * TENTACLES,    length: PIECES + 1,    point: tentacleAt,    shade: tentacleOf,    blend: 'translucent',    minWidth: { pixels: 1.5 },    startCap: lines.caps.butt,    endCap: lines.caps.butt,    label: 'tentacles',  });  tentacles.shown = count * TENTACLES;  /** Each piece of an arm's ribbon is two quads across, from -1 through 0 to 1. */  const ruffleAt = (vertexIndex: number, instanceIndex: number) => {    'use gpu';    const segment = std.intdiv(vertexIndex, d.u32(12));    const half = std.intdiv(vertexIndex % 12, d.u32(6));    const corner = quadCorner(vertexIndex);    const across = d.f32(half + corner.y) - 1;    const owner = std.intdiv(instanceIndex, d.u32(ARMS));    const index = owner * STRANDS + TENTACLES + (instanceIndex % ARMS);    const base = index * POINTS;    const strand = strands.$[index]!;    const time = timeOf.$;    const s = d.f32(segment + corner.x) / SMOOTH;    const here = curveAt(base, s);    const tangent = tangentAt(base, s);    const toEye = std.normalize(cameras.current.$.position - here);    const facing = std.normalize(std.cross(tangent, toEye) + 0.00001);    const front = std.cross(facing, tangent);    // The ribbon spirals slowly about its length, showing its face in places and its edge in    // others, and swells and narrows as it goes    const along = s / (POINTS - 1);    const turn = along * 4.5 + strand.around + std.sin(time * 0.3 + strand.around) * 0.6;    const side = facing * std.cos(turn) + front * std.sin(turn);    const fold = front * std.cos(turn) - facing * std.sin(turn);    const swell = 0.75 + 0.25 * std.sin(along * 9 + strand.around * 3);    const width = strand.width * (1 - along * 0.75) * swell;    // The frill flattens where the ribbon turns edge-on, so its folds never stand out across the    // screen as a strip of slivers    const open = std.cos(turn);    const frill =      std.sin(along * 22 - time * 0.9 + strand.around) * across * std.abs(across) * open * open;    const world = here + side * (across * width * 0.5) + fold * (frill * width * 0.3);    return Ruffle({      position: cameras.current.$.viewProjection * d.vec4f(world, 1),      world,      fold,      across,      along,      strand: index,    });  };  const ruffleOf = (ruffle: d.Infer<typeof Ruffle>) => {    'use gpu';    const jelly = jellies.$[std.intdiv(ruffle.strand, d.u32(STRANDS))]!;    const strand = strands.$[ruffle.strand]!;    const nettle = jelly.kind === NETTLE;    const toEye = std.normalize(cameras.current.$.position - ruffle.world);    const edgeOn = 1 - std.abs(std.dot(std.normalize(ruffle.fold + 0.00001), toEye));    // A frilled edge: the ribbon's outline waves gently along its length    const reach = std.abs(ruffle.across);    const limit =      0.82 + 0.18 * std.sin(ruffle.along * 48 + std.sign(ruffle.across) * 1.7 + strand.around);    const soft = std.max(std.fwidth(reach) * 1.5, 0.04);    const inside = 1 - std.smoothstep(limit - soft, limit, reach);    const frill = std.smoothstep(limit - 0.3, limit, reach) * inside;    const fade =      std.smoothstep(0, 0.05, ruffle.along) * (1 - std.smoothstep(0.55, 1, ruffle.along));    const base = std.select(0.03, 0.05, nettle);    const alpha = (base + edgeOn * edgeOn * base + frill * base) * inside * fade;    const tint = std.select(jelly.tint, d.vec3f(1, 0.86, 0.64) * 1.5, nettle);    const flash = flashOf(jelly.startled, 0.4 + ruffle.along * 1.2);    const glow =      (jelly.glow * (0.15 + frill * 0.3) + FLASH * (flash * 3)) * (lookOf.$.glow * alpha);    return Seen({ color: tissue(ruffle.world, tint, 0.3, alpha, glow) });  };  return {    bells: {      label: 'bells',      Vertex: Skin,      at: skinAt,      surfaceOf: skinOf,      color: passes.shaded,      translucent: true,      draw: (pass) => pass.draw(RINGS * SEGMENTS * 6, count),    } satisfies passes.Drawing<typeof Skin, Seen>,    tentacles: tentacles.drawing,    arms: {      label: 'oral arms',      Vertex: Ruffle,      at: ruffleAt,      surfaceOf: ruffleOf,      color: passes.shaded,      translucent: true,      draw: (pass) => pass.draw(PIECES * 12, count * ARMS),    } satisfies passes.Drawing<typeof Ruffle, Seen>,    /** Sets how many jellies draw. */    show(next: number) {      count = next;      tentacles.shown = next * TENTACLES;    },    destroy: tentacles.destroy,  };}