Solar system

A low-poly sun, eight planets, a belt of rocks between Mars and Jupiter, and two comets. Each planet's name arches over it and a band of facts turns around it, written in world space. A slow tour visits one planet at a time; hover or tap one to choose it. Its band unravels and its details type on, on whichever side of it the sun and the other planets leave clear. Each rock's orbit and tumble follow from its seed and the time alone, so the belt stays exact at any orbit speed. Uses instances, drawings, text, lines, trails and picker from @typegpu/kit.

Files, in reading order

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. belt.tsDraws a main belt of count tumbling rocks between Mars and Jupiter.
  3. planets.tsDraws the sun, the planets, Saturn's ring, the Moon and each planet's orbit.
  4. labels.tsWrites each planet's name in the world, arched over it at one size on screen.
  5. comets.tsMoves two comets round the sun a fixed step at a time, pushing a point to each tail.
  6. data.ts
  7. sky.ts
  8. assets.ts

Kit modules bloom, cameras, clock, environment, finish, font, instances, lines, meshPool, owner, passes, picker, pipelines, shading, targets, text, tone, trails, typeset. Also @typegpu/color, @typegpu/noise, math, typegpu.

scene.ts

import {  type Baked,  bloom,  cameras,  clock,  finish,  font,  type Frame,  owner,  passes,  pipelines,  type Surface,  targets,  tone,} from '@typegpu/kit';import type { TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createBelt } from './belt.ts';import { createPlanets } from './planets.ts';import { createLabels } from './labels.ts';import { createComets, STEP } from './comets.ts';import { EARTH, PLANETS } from './data.ts';import { space } from './sky.ts';const SAMPLES = 4;const HDR = 'rgba16float';/** World units from the target to the eye on a wide view. Narrower views back away. */const DISTANCE = 37;/** Pitch above the ecliptic on wide screens, and on narrow ones, which look further down. */const PITCH = { wide: 0.62, narrow: 1.1 };/** Radians a second the view turns round the sun. */const DRIFT = 0.025;/** * Views narrower than `aspect` back away by the ratio to the power `power`, cropping the outer * orbits at their sides, and draw text larger by it to the power `growth`. */const FIT = { aspect: 1.45, power: 0.75, growth: 0.4 };/** * Until a planet is picked, a tour chooses Earth at `start` seconds, then each planet outwards in * turn for `stay` seconds. */const TOUR = { start: 1.8, stay: 6 };const ROCKS = 4096;export interface SceneOptions {  readonly font: Baked;  /** Rocks in the belt between Mars and Jupiter. Default 4096. */  readonly rocks?: number;}/** * A sun, eight planets wrapped in their facts, a belt of rocks and two comets, on kit instances, * drawings, lines, text and trails. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) {  const fit = () => Math.max(1, (FIT.aspect * surface.height) / surface.width);  const narrow = () => surface.width < surface.height;  const pitch = () => (narrow() ? PITCH.narrow : PITCH.wide);  let fitted = fit();  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, -1.6, 0],    yaw: 0.35,    fov: 0.5,    distance: DISTANCE * fitted ** FIT.power,    pitch: pitch(),    near: 1,    far: 1200,    pitchRange: [0.08, 1.5],    distanceRange: [6, 140],  });  const sim = clock.fixedStep({ step: STEP });  const scene = passes    .create(      root,      screen,      { scene: passes.color, depth: passes.depth },      { resolve: { scene: 'hdr' } },    )    .pipe(view.inject());  const values = { speed: 1, tailWidth: 0.16, paused: false };  const sans = own(font.create(root, options.font));  const planets = own(createPlanets(root, view, surface));  const labels = own(createLabels(root, sans, planets));  const belt = createBelt(root, options.rocks ?? ROCKS);  const comets = own(createComets(root, values));  const drawn = [    scene.add(planets.renderer),    scene.add(space),    scene.add(belt.drawing),    scene.add(planets.paths),    scene.add(comets.tails.drawing),    scene.add(labels.drawing),  ];  const glow = own(    bloom.create(root, {      source: () => screen.textures.hdr,      settings: { threshold: 2, spread: 1.1 },    }),  );  const final = own(    finish.create(root, {      format: surface.format,      source: () => screen.textures.hdr,      tone: tone.aces,      glow,      settings: { exposure: 1.05, vignette: 0.35, glowIntensity: 0.2 },    }),  );  await pipelines.compile([scene, planets, comets, glow, final]);  let picked: number | undefined;  let pointer: [x: number, y: number] | undefined;  const pointing: Pointing = {    move(x, y) {      pointer = [x, y];    },  };  const settings: Settings<typeof values> = {    values,    knobs: {      speed: { label: 'orbit speed', min: 0, max: 4, step: 0.05 },      tailWidth: { label: 'tail width', min: 0.02, max: 0.5, step: 0.01 },      paused: {},    },    write(key) {      if (key === undefined || key === 'tailWidth') {        comets.tails.settings.patch({ width: values.tailWidth });      }      if (key === undefined || key === 'paused') sim.paused = values.paused;    },  };  return {    view,    settings,    pointing,    frame(frame: Frame) {      const fitting = fit();      if (fitting !== fitted) {        view.goal.distance *= (fitting / fitted) ** FIT.power;        view.goal.pitch = pitch();        fitted = fitting;      }      picked = planets.picks.result.take() ?? picked;      const stop = Math.floor((frame.time - TOUR.start) / TOUR.stay);      const touring = stop >= 0 ? (EARTH + stop) % PLANETS.length : undefined;      const chosen = picked ?? touring;      labels.choose(chosen, frame.time);      view.goal.yaw += DRIFT * frame.dt;      view.update(frame.dt);      screen.fit();      const steps = sim.advance(frame.dt * values.speed);      const seconds = sim.time + sim.ahead;      const grow = fitted ** FIT.growth;      labels.tick(seconds, frame.time, grow);      const width = chosen === undefined ? 0 : labels.widths[chosen]!;      planets.focusOn(chosen, { width, grow });      belt.move(seconds);      const compute = frame.encoder.beginComputePass();      for (let step = steps; step > 0; step--) comets.step(compute);      planets.move(compute, seconds);      if (pointer) planets.picks.pick(compute, ...pointer);      compute.end();      if (pointer) planets.picks.result.capture(frame);      pointer = undefined;      scene.record(frame, drawn);      glow.record(frame);      final.record(frame, surface.view(frame));    },    destroy: own.destroy,  };}

belt.ts

import { cameras, passes } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { perlin3d, randf } from '@typegpu/noise';import { d, std, type TgpuRoot } from 'typegpu';import { rateOf } from './data.ts';const TAU = Math.PI * 2;/** Radians a second round the sun of an orbit one world unit out. Wider orbits are slower. */const RATE = rateOf(1);/** Semi-major axes in world units, between Mars's orbit and Jupiter's. */const INNER = 7;const OUTER = 7.6;const MOST_ECCENTRIC = 0.05;/** Spread of inclinations in radians, which sets how thick the belt is. */const INCLINATION = 0.035;/** Radii in world units of the smallest rock and the largest, which few come near. */const SMALLEST = 0.014;const LARGEST = 0.05;/** How far facets stand out from or sink into a rock, as a fraction of its radius. */const BUMPS = 0.3;/** Radians a second the fastest rock tumbles. */const TUMBLE = 1.6;/** Newton steps solving Kepler's equation, from a guess already close at low eccentricity. */const NEWTON_STEPS = 3;/** Linear colours of the darkest rock and the palest. */const DARK = d.vec3f(0.04, 0.037, 0.034);const PALE = d.vec3f(0.11, 0.1, 0.09);const SUNLIGHT = d.vec3f(1.5, 1.4, 1.22);const SHADE = d.vec3f(0.05, 0.05, 0.08);/** The share of rocks with an icy facet that glints when it turns to the sun, and how brightly. */const GLINTING = 0.015;const GLINT = d.vec3f(4, 3.8, 3.5);const GLINT_SHARPNESS = 40;const rock = meshes.unindexed(meshes.icosphere({ radius: 1, segments: 1 }));const Corner = d.struct({  position: d.vec4f,  world: d.vec3f,  color: d.vec3f,  glints: d.f32,});const Seen = d.struct({ color: d.vec4f });/** Where a rock is round the sun at `time`, from orbital elements seeded by its index. */const orbitAt = (time: number) => {  'use gpu';  // Rocks bunch towards the middle of the belt  const axis = std.mix(INNER, OUTER, (randf.sample() + randf.sample()) / 2);  const eccentricity = randf.sample() * MOST_ECCENTRIC;  const inclination = randf.normal(0, INCLINATION);  const node = randf.sample() * TAU;  const perihelion = randf.sample() * TAU;  // Kepler's equation, M = E - e sin E, from the mean anomaly round to the eccentric one  let mean = randf.sample() * TAU + (RATE / (axis * std.sqrt(axis))) * time;  mean -= TAU * std.floor(mean / TAU + 0.5);  let eccentric = mean + eccentricity * std.sin(mean);  for (let step = 0; step < NEWTON_STEPS; step++) {    const miss = eccentric - eccentricity * std.sin(eccentric) - mean;    eccentric -= miss / (1 - eccentricity * std.cos(eccentric));  }  const along = axis * (std.cos(eccentric) - eccentricity);  const beside = axis * std.sqrt(1 - eccentricity * eccentricity) * std.sin(eccentric);  const u = along * std.cos(perihelion) - beside * std.sin(perihelion);  const v = along * std.sin(perihelion) + beside * std.cos(perihelion);  const tilted = v * std.cos(inclination);  return d.vec3f(    u * std.cos(node) - tilted * std.sin(node),    v * std.sin(inclination),    -(u * std.sin(node) + tilted * std.cos(node)),  );};/** Turns `point` about the unit `axis` by `angle`. */const turn = (point: d.v3f, axis: d.v3f, angle: number) => {  'use gpu';  const cos = std.cos(angle);  return (    point * cos +    std.cross(axis, point) * std.sin(angle) +    axis * (std.dot(axis, point) * (1 - cos))  );};/** * Draws a main belt of `count` tumbling rocks between Mars and Jupiter. Each rock's orbit, shape * and tumble follow from its index and the time, and nothing is stored between frames, so any * moment draws the same. It writes no depth, so add it after the sky. */export function createBelt(root: TgpuRoot, count: number) {  const time = root.createUniform(d.f32);  const drawing: passes.Drawing<typeof Corner, d.Infer<typeof Seen>> = {    label: 'belt',    Vertex: Corner,    flat: ['color', 'glints'],    at: (vertexIndex: number, instanceIndex: number) => {      'use gpu';      randf.seed(d.f32(instanceIndex) + 1);      const center = orbitAt(time.$);      const radius = std.mix(SMALLEST, LARGEST, std.pow(randf.sample(), 4));      const stretch = d.vec3f(randf.sample(), randf.sample(), randf.sample()) * 0.5 + 0.6;      const lumps = randf.inUnitCube() * 50;      const axis = randf.onUnitSphere();      const tumble = randf.sample() * TAU + (randf.sample() * 2 - 1) * TUMBLE * time.$;      const color = std.mix(DARK, PALE, randf.sample());      const glints = std.select(d.f32(0), d.f32(1), randf.sample() < GLINTING);      const unit = rock.vertexAt(vertexIndex).position;      const bump = 1 + perlin3d.sample(unit * 1.4 + lumps) * BUMPS;      const world = center + turn(unit * stretch * (radius * bump), axis, tumble);      return Corner({        position: cameras.current.$.viewProjection * d.vec4f(world, 1),        world,        color,        glints,      });    },    surfaceOf: (corner) => {      'use gpu';      const view = cameras.towards(cameras.current.$, corner.world);      const facet = std.normalize(std.cross(std.dpdx(corner.world), std.dpdy(corner.world)));      const normal = std.select(facet, facet * -1, std.dot(facet, view) < 0);      const sun = std.normalize(corner.world * -1);      const day = std.saturate(std.dot(normal, sun));      const glint = std.pow(        std.saturate(std.dot(normal, std.normalize(sun + view))),        GLINT_SHARPNESS,      );      const lit = corner.color * (SUNLIGHT * day + SHADE) + GLINT * (glint * day * corner.glints);      return Seen({ color: d.vec4f(lit, 1) });    },    color: passes.shaded,    // Hidden by the bodies, the belt leaves depth alone so orbit paths and labels read over it    depth: { depthWriteEnabled: false, depthCompare: 'less' },    primitive: { cullMode: 'back' },    draw: (pass) => pass.draw(rock.vertexCount, count),  };  return {    drawing,    /** Moves every rock to `seconds` of simulated time. */    move(seconds: number) {      time.write(seconds);    },  };}

planets.ts

import {  byStage,  cameras,  type CameraBase,  type Inject,  instances,  lines,  meshPool,  picker,  shading,  type SizeSource,  unfilled,} from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { perlin3d } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import {  BANDED,  BURNING,  EARTH,  EARTHLIKE,  PLAIN,  PLANETS,  SATURN,  MOON,  SATURN_RINGS,  SUN,  rateOf,} from './data.ts';/** `Circuit.around` of a body that circles only the sun. */const NONE = 0xffffffff;const WORKGROUP = 64;/** The sun, the planets in order, then the Moon. */const BODIES = PLANETS.length + 2;/** Points round each orbit's closed path. */const SEGMENTS = 160;const PATH_COLOR = d.vec3f(0.34, 0.38, 0.6);/** Path width in points, and its opacity at its middle. */const PATH_WIDTH = 1.25;const PATH_OPACITY = 0.16;/** Linear light a facet facing the sun receives, and light on the night side and at the rim. */const SUNLIGHT = d.vec3f(1.5, 1.4, 1.22);const NIGHT = d.vec3f(0.13, 0.13, 0.2);const RIM = d.vec3f(0.12, 0.16, 0.32);const Body = d.struct({ position: d.vec3f, spin: d.f32 });const Focus = d.struct({ planet: d.u32, width: d.f32, grow: d.f32 });/** * World units from the eye at which labels take their size. Nearer or further, they keep that size * on screen. */export const SIZE_DISTANCE = 37;/** A world point in picture heights from the middle of the picture, with y up. */export const onPicture = (world: d.v3f) => {  'use gpu';  const eye = cameras.current.$;  const clip = eye.viewProjection * d.vec4f(world, 1);  const lens = cameras.lensOf(eye);  return (clip.xy / clip.w) * d.vec2f(lens.y / lens.x, 1) * 0.5;};/** World units at `SIZE_DISTANCE` from the top of a planet's details to their bottom. */const DETAILS_HEIGHT = 1.5;/** World units from a planet's surface to its name's baseline. */export const NAME_LIFT = 0.12;/** * World units at `SIZE_DISTANCE` from a name's baseline to the bottom of details above it: the * name's height and a gap. */export const DETAILS_ABOVE = 0.6;/** * World units at `SIZE_DISTANCE` the box round a planet's details reaches past their words, and * the room below them for a name that moves to their head. */const [FACTS_PAD, NAME_ROOM] = [0.25, 0.62];/** Picture heights of clear space details need from other bodies before they keep to one side. */const FACTS_CLEARANCE = 0.01;/** Each planet's band radius and radius, in world units. */const MEASURES = tgpu.const(  d.arrayOf(d.vec2f, PLANETS.length),  PLANETS.map(({ bandRadius, radius }) => d.vec2f(bandRadius, radius)),);/** The sun's radius with its glow, then each planet's extent, rings included, in world units. */const REACHES = tgpu.const(d.arrayOf(d.f32, PLANETS.length + 1), [  SUN.radius * 1.2,  ...PLANETS.map(({ extent }) => extent),]);/** How far a point on the picture stands outside a box, its middle then half its size. */const gapOf = (point: d.v2f, box: d.v4f) => {  'use gpu';  const outside = std.abs(point - box.xy) - box.zw;  return std.length(std.max(outside, d.vec2f())) + std.min(std.max(outside.x, outside.y), 0);};/** * Planet `planet`'s details and the name beside them as a box on the picture, below its band or * `above` it: its middle, then half its width and height. The details are `width` world units * wide at `SIZE_DISTANCE`, drawn `grow` times their size. */export const detailsBox = (planet: number, width: number, grow: number, above: boolean) => {  'use gpu';  const at = bodies.$[planet + 1]!.position;  const eye = cameras.current.$;  const distance = std.distance(eye.position, at);  const lens = cameras.lensOf(eye).y * grow;  // Facts keep their world size up to `SIZE_DISTANCE` and their picture size beyond  const near = (std.max(distance / SIZE_DISTANCE, 1) * lens) / (2 * distance);  const far = lens / (2 * SIZE_DISTANCE);  const measures = MEASURES.$[planet]!;  // Upright text looks shorter by the cosine of the eye's height above it, and names lift by  // world units. Below, the details leave room for the name at their head.  const rise = std.clamp((eye.position.y - at.y) / distance, -0.9, 0.9);  const cosine = std.sqrt(1 - rise * rise);  const upright = cosine * far;  const named = ((measures.y + NAME_LIFT) * lens) / (2 * distance);  // The details face the eye from a circle round the planet, nearer the eye and so larger  const size = distance / SIZE_DISTANCE;  const radius = std.max(measures.x, (width / 1.8) * size);  const lift = std.select(-(measures.x + 0.15) * std.max(size, 1), measures.y + NAME_LIFT, above);  const closer = distance / (distance - (radius + lift * rise) / cosine);  const under = -(measures.x + 0.15) * near * closer;  const below = under - (NAME_ROOM + DETAILS_HEIGHT) * upright * closer;  const bottom = std.select(below, named, above);  const over = (named + DETAILS_ABOVE * upright) * closer + DETAILS_HEIGHT * upright * closer;  const top = std.select(under, over, above);  const half = d.vec2f(width * far, top - bottom) * 0.5 + FACTS_PAD * far;  const centre = onPicture(at);  return d.vec4f(centre.x, centre.y + (top + bottom) * 0.5, half);};/** * How far planet `planet`'s details have moved above its name, from 0 to 1. They keep below its * band unless the sun or another planet stands in their way there and less so above. */export const aboveness = (planet: number, width: number, grow: number) => {  'use gpu';  const eye = cameras.current.$;  const lens = cameras.lensOf(eye).y;  const below = detailsBox(planet, width, grow, false);  const above = detailsBox(planet, width, grow, true);  let under = d.f32(1);  let over = d.f32(1);  for (let body = d.u32(0); body <= PLANETS.length; body++) {    if (body !== planet + 1) {      const at = bodies.$[body]!.position;      const disc = (REACHES.$[body]! * lens) / (2 * std.distance(eye.position, at));      under = std.min(under, gapOf(onPicture(at), below) - disc);      over = std.min(over, gapOf(onPicture(at), above) - disc);    }  }  // Crowded on both sides, they take the clearer  const clearer = std.smoothstep(-FACTS_CLEARANCE, FACTS_CLEARANCE, (over - under) * 10);  return clearer * (1 - std.smoothstep(0, FACTS_CLEARANCE, under));};/** * A circle round the sun, or round body `around` too, at `rate` radians a second from `phase`, a * turn about the body's axis at `spin` radians a second, and the world units a pick reaches. */const Circuit = d.struct({  radius: d.f32,  rate: d.f32,  phase: d.f32,  spin: d.f32,  around: d.u32,  reach: d.f32,});/** Each body's position and turn about its axis, after `move`. */export const bodies = tgpu.accessor(  d.arrayOf(Body, BODIES),  unfilled('the planets', 'planets', 'createPlanets'),);/** Turns about +y by `spin`, then tilts that axis about +z by `tilt`. */const turnOf = (tilt: number, spin: number) => {  'use gpu';  const tiltSin = std.sin(tilt / 2);  const spinSin = std.sin(spin / 2);  const tiltCos = std.cos(tilt / 2);  const spinCos = std.cos(spin / 2);  return d.vec4f(-tiltSin * spinSin, tiltCos * spinSin, spinCos * tiltSin, tiltCos * spinCos);};/** The rotation that undoes the unit quaternion `turn`. */export const inverse = (turn: d.v4f) => {  'use gpu';  return d.vec4f(turn.xyz * -1, turn.w);};/** A unit normal constant over each triangle, facing the eye. */const facetOf = (point: shading.Point) => {  'use gpu';  const normal = std.normalize(std.cross(std.dpdx(point.position), std.dpdy(point.position)));  return std.select(normal, normal * -1, std.dot(normal, point.view) < 0);};/** A low-poly body whose colour patterns stay constant over each facet. */const Faceted = instances.define('Faceted', {  host: { body: d.u32, radius: d.f32, tilt: d.f32, accent: d.vec3f, pattern: d.u32 },  place: (row) => {    'use gpu';    const body = bodies.$[row.body]!;    return instances.Placement({      rotation: turnOf(row.tilt, body.spin),      position: body.position,      scale: d.vec3f(row.radius),    });  },  material: (point, row, base) => {    'use gpu';    const turn = turnOf(row.tilt, bodies.$[row.body]!.spin);    const local = instances.rotate(inverse(turn), facetOf(point));    const grain = perlin3d.sample(local * 2.5);    let color = std.mix(base.color, row.accent, std.saturate(grain * 1.5 + 0.3));    let emission = d.vec3f(base.emission);    if (row.pattern === BANDED) {      const band = std.sin(local.y * 9 + grain * 1.5);      color = std.mix(base.color, row.accent, std.smoothstep(-0.2, 0.2, band));    } else if (row.pattern === EARTHLIKE) {      const land = perlin3d.sample(local * 1.6 + d.vec3f(3, 1, 2)) > 0.08;      color = std.select(base.color, row.accent, land);      color = std.select(color, d.vec3f(0.92, 0.95, 1), std.abs(local.y) > 0.8);    } else if (row.pattern === BURNING) {      emission = base.emission * (0.75 + grain * 0.5);    }    return shading.Material({ color, roughness: 1, emission, metallic: 0, occlusion: 1 });  },});/** Lights each facet from the sun at the origin. Emissive facets dim towards their edges. */const sunlit = (material: shading.Material, point: shading.Point) => {  'use gpu';  const normal = facetOf(point);  const day = std.saturate(std.dot(normal, std.normalize(point.position * -1)));  const facing = std.saturate(std.dot(normal, point.view));  const rim = (1 - facing) * (1 - facing) * (1 - facing);  const lit = material.color * (SUNLIGHT * day + NIGHT + RIM * rim);  return lit + material.emission * (0.65 + 0.35 * facing);};/** * Draws the sun, the planets, Saturn's ring, the Moon and each planet's orbit. One kernel a frame * moves the bodies, which other modules read through `inject()`. */export function createPlanets(root: TgpuRoot, view: CameraBase, size: SizeSource) {  const placed = root.createBuffer(d.arrayOf(Body, BODIES)).$usage('storage');  const placement = byStage(placed);  const inject: Inject = (cfg) => cfg.with(bodies, placement);  const positions = placed.as('readonly');  /**   * The chosen planet, whose band and details the orbit paths leave clear, or `NONE`, its details'   * width at `SIZE_DISTANCE` and how many times their size labels are drawn.   */  const focus = root.createUniform(Focus, { planet: NONE, width: 0, grow: 1 });  /** How far a point is clear on the picture of the chosen planet's band and details, 0 to 1. */  const clearOfFacts = (world: d.v3f) => {    'use gpu';    const chosen = focus.$;    if (chosen.planet === NONE) return d.f32(1);    const at = bodies.$[chosen.planet + 1]!.position;    const eye = cameras.current.$;    const distance = std.distance(eye.position, at);    const lens = cameras.lensOf(eye).y * chosen.grow;    const band =      (MEASURES.$[chosen.planet]!.x * std.max(distance / SIZE_DISTANCE, 1) * lens) / (2 * distance);    const picture = onPicture(world);    const margin = band * 0.3;    let clear = std.smoothstep(0, margin, std.distance(picture, onPicture(at)) - band);    const above = aboveness(chosen.planet, chosen.width, chosen.grow);    const under = detailsBox(chosen.planet, chosen.width, chosen.grow, false);    const over = detailsBox(chosen.planet, chosen.width, chosen.grow, true);    clear = std.min(clear, std.mix(1, std.smoothstep(0, margin, gapOf(picture, under)), 1 - above));    return std.min(clear, std.mix(1, std.smoothstep(0, margin, gapOf(picture, over)), above));  };  const pool = meshPool.create(root, { capacity: 2 });  const encoder = root.createCommandEncoder();  const ball = pool.add(meshes.icosphere({ radius: 1, segments: 2 }), encoder);  const torus = meshes.torus({ radius: 1, tube: 0.12, ringSegments: 48, tubeSegments: 4 });  const ring = pool.add(meshes.transform(torus, std.scaling4(d.vec3f(1, 0.25, 1))), encoder);  encoder.submit();  const capacity = BODIES + SATURN_RINGS.length;  const faceted = instances.create(root, pool, { kind: Faceted, capacity });  const renderer = faceted.renderer({ shade: sunlit }).pipe(inject);  const time = root.createUniform(d.f32);  const orbits = root.createReadonly(d.arrayOf(Circuit, BODIES), [    { radius: 0, rate: 0, phase: 0, spin: SUN.spin, around: NONE, reach: 0 },    ...PLANETS.map((planet) => ({      radius: planet.orbit,      rate: rateOf(planet.orbit),      phase: planet.phase,      spin: planet.spin,      around: NONE,      reach: planet.extent,    })),    { radius: MOON.orbit, rate: MOON.rate, phase: 0, spin: 0.3, around: EARTH + 1, reach: 0 },  ]);  const circle = (orbit: d.Infer<typeof Circuit>, t: number) => {    'use gpu';    const angle = orbit.phase + orbit.rate * t;    return d.vec3f(std.cos(angle), 0, -std.sin(angle)) * orbit.radius;  };  const move = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (i >= BODIES) return;    const orbit = Circuit(orbits.$[i]!);    let position = circle(orbit, time.$);    if (orbit.around !== NONE) position += circle(Circuit(orbits.$[orbit.around]!), time.$);    bodies.$[i] = Body({ position, spin: orbit.spin * time.$ });  });  const moving = root.pipe(inject).createComputePipeline({ compute: move });  const picks = picker.create(root, view, {    capacity: PLANETS.length,    size,    position: (i) => {      'use gpu';      return d.vec3f(positions.$[i + 1]!.position);    },    radius: (i) => {      'use gpu';      return orbits.$[i + 1]!.reach;    },  });  const paths = lines.create(root, {    capacity: PLANETS.length,    length: SEGMENTS,    closed: true,    units: 'points',    label: 'orbits',    shade: tgpu      .fn((fragment: lines.Fragment) => {        'use gpu';        const alpha =          (1 - fragment.across * fragment.across) *          PATH_OPACITY *          fragment.coverage *          clearOfFacts(fragment.position);        return d.vec4f(PATH_COLOR * alpha, alpha);      })      .pipe(inject),  });  const loop = new Float32Array(SEGMENTS * 3);  for (const [i, planet] of PLANETS.entries()) {    for (let k = 0; k < SEGMENTS; k++) {      const angle = (k / SEGMENTS) * Math.PI * 2;      loop[k * 3] = Math.cos(angle) * planet.orbit;      loop[k * 3 + 2] = -Math.sin(angle) * planet.orbit;    }    paths.write(i, loop, { width: PATH_WIDTH });  }  const sun = { radius: SUN.radius, tilt: 0.1, color: d.vec3f(), emission: SUN.emission };  faceted.add(ball, { body: 0, ...sun, accent: d.vec3f(), pattern: BURNING });  for (const [i, { radius, tilt, color, accent, pattern }] of PLANETS.entries()) {    faceted.add(ball, { body: i + 1, radius, tilt, color, accent, pattern });  }  const saturn = PLANETS[SATURN]!;  for (const { radius, color } of SATURN_RINGS) {    const band = { radius: saturn.radius * radius, tilt: saturn.tilt, color, accent: color };    faceted.add(ring, { body: SATURN + 1, ...band, pattern: PLAIN });  }  const { radius, color, accent } = MOON;  faceted.add(ball, { body: BODIES - 1, radius, tilt: 0, color, accent, pattern: PLAIN });  faceted.flush();  let chosen = { planet: NONE, width: 0, grow: 1 };  return {    renderer,    /** Each planet's orbit path. Add after `renderer`, which hides the paths behind the bodies. */    paths: paths.drawing,    /** Picks the planet nearest a point in points, which reaches the CPU a frame or more later. */    picks,    inject: () => inject,    initAsync: () => Promise.all([moving.initAsync(), picks.initAsync()]),    /**     * Clears the orbit paths from round `planet`'s band and its details, `width` world units wide     * at `SIZE_DISTANCE` and drawn `grow` times their size. No planet clears nothing.     */    focusOn(planet: number | undefined, facts: { width: number; grow: number }) {      const next = { planet: planet ?? NONE, ...facts };      const { width, grow } = next;      if (next.planet === chosen.planet && width === chosen.width && grow === chosen.grow) return;      chosen = next;      focus.write(next);    },    /** Moves every body to `seconds` of simulated time. */    move(pass: TgpuComputePass, seconds: number) {      time.write(seconds);      pass.setPipeline(moving);      pass.dispatchWorkgroups(Math.ceil(BODIES / WORKGROUP));    },    destroy() {      paths.destroy();      picks.destroy();      faceted.destroy();      pool.destroy();    },  };}

labels.ts

import { cameras, type Font, type Inject, instances, text, typeset } from '@typegpu/kit';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import { PLANETS, SUN } from './data.ts';import {  aboveness,  bodies,  DETAILS_ABOVE,  inverse,  NAME_LIFT,  onPicture,  SIZE_DISTANCE,} from './planets.ts';/** * The kinds of a planet's labels, and `PER`, the labels each planet has. Its details are written * twice, below its band and above its name, and show where the bodies leave them clear. A chosen * planet's name is written again at the head of the details below, for when the sun is behind it. */const [NAME, BAND, DETAILS, ABOVE, UNDER, PER] = [0, 1, 2, 3, 4, 5];/** Glyphs a label holds; spaces take none. */const LENGTH = 100;/** World units per em of names, bands and details. */const EM = [0.5, 0.3, 0.3, 0.3, 0.5];const BAND_EM = EM[BAND]!;/** The colour of bands and details. Names take their planet's colour, lightened. */const FACTS = d.vec4f(0.8, 0.84, 0.95, 1);const COLORS = [FACTS, FACTS, FACTS];const ANCHORS = [  { baseline: 'alphabetic' },  { align: 'left' },  { baseline: 'top' },  { baseline: 'bottom' },  { baseline: 'top' },] as const;/** World units at `SIZE_DISTANCE` the details below move down to make room for a name above them. */const NAME_LINE = 0.62;/** Glyphs a second that type on. */const TYPING = 28;/** Seconds before the first planet's labels type on. */const INTRO = 0.3;/** Seconds between one planet's labels typing on and the next's. */const STAGGER = 0.12;/** Radians a second a band turns. */const BAND_TURN = 0.25;/** Radians a band leans about `LEAN_AXIS`, one way on odd planets and the other on even ones. */const BAND_LEAN = 0.35;const LEAN_AXIS = d.vec3f(0.6, 0, 0.8);/** World units from the eye over which a planet's labels shrink away as it nears them. */const NEAR = d.vec2f(4, 8);/** * Boxes round a name and round the details, in world units at `SIZE_DISTANCE`: half the height and * how far the middle sits above the baseline. A name's covers the arch's rise at its ends, and the * details' their four lines below their top, or above their bottom when above the name. */const NAME_BOX = d.vec2f(0.34, 0.3);const [DETAILS_BOX, ABOVE_BOX] = [d.vec2f(0.75, -0.75), d.vec2f(0.75, 0.75)];/** Picture heights of clear space a name needs from other names and bodies before it shows fully. */const CLEARANCE = 0.012;/** Seconds of simulated and of real time, and how many times their size labels are drawn. */const Clock = d.struct({ sim: d.f32, real: d.f32, grow: d.f32 });/** When a planet was last chosen or let go, and 1 while it is chosen. */const Choice = d.struct({ since: d.f32, open: d.u32 });/** A circle a label wraps round, its axis tilted, its baseline lifted up the axis. */const Arc = d.struct({  centre: d.vec3f,  tilt: d.vec4f,  radius: d.f32,  /** How many times its `EM` the label's glyphs are drawn. */  size: d.f32,  lift: d.f32,  start: d.f32,  /** Radians round the circle to its point nearest the eye. */  front: d.f32,});/** How far two boxes on the picture stand apart, negative where they overlap. */const apart = (a: d.v4f, b: d.v4f) => {  'use gpu';  const gap = std.abs(a.xy - b.xy) - (a.zw + b.zw);  return std.max(gap.x, gap.y);};/** The planet a label belongs to. */const planetOf = (label: number) => {  'use gpu';  return std.intdiv(label, d.u32(PER));};/** * Writes each planet's name in the world, arched over it at one size on screen. While a planet is * chosen, a band of its facts types on round it on a tilted ring that turns, and its details type * on below the band. */export function createLabels(root: TgpuRoot, font: Font, planets: { inject: () => Inject }) {  const clock = root.createUniform(Clock);  const choices = root.createReadonly(    d.arrayOf(Choice, PLANETS.length),    PLANETS.map(() => ({ since: -1e3, open: 0 })),  );  const measure = typeset.create(LENGTH);  const widthOf = (line: string, kind: number) => measure.set(font, line, { em: EM[kind] }).width;  // A band repeats its facts as often as they fit round it  const lines = PLANETS.map((planet) => {    const fits = Math.floor((Math.PI * 2 * planet.bandRadius) / widthOf(planet.band, BAND));    const { name, band, details } = planet;    const named = name.toUpperCase();    return [named, band.repeat(Math.max(1, fits)), details, details, named];  });  /** The radius, at least `least`, at which a line spans about 1.8 radians facing the eye. */  const arc = (line: string, kind: number, least: number) =>    Math.max(least, widthOf(line, kind) / 1.8);  /** Each label's radius and lift at `SIZE_DISTANCE`, and the least radius it keeps nearer. */  const wraps = root.createReadonly(    d.arrayOf(d.vec3f, PLANETS.length * PER),    PLANETS.flatMap(({ radius, bandRadius: band }, i) => {      const [name, , details] = lines[i]! as [string, string, string];      return [        d.vec3f(arc(name, NAME, 0), radius + NAME_LIFT, radius + 0.3),        d.vec3f(band, -BAND_EM * 0.35, band),        d.vec3f(arc(details, DETAILS, band), -band - 0.15, band),        d.vec3f(arc(details, DETAILS, band), radius + NAME_LIFT, band),        d.vec3f(arc(name, UNDER, band), -band - 0.15, band),      ];    }),  );  /** The sun's radius, then each planet's extent, rings included, in world units. */  const extents = root.createReadonly(d.arrayOf(d.f32, PLANETS.length + 1), [    SUN.radius,    ...PLANETS.map(({ extent }) => extent),  ]);  const arcOf = (label: number) => {    'use gpu';    const planet = planetOf(label);    const band = label % PER === BAND;    const name = label % PER === NAME;    const wrap = wraps.$[label]!;    const centre = d.vec3f(bodies.$[planet + 1]!.position);    // Firefox on Windows fails to compile `sin` of a `select` between literals    const lean = std.sin((d.f32(planet % 2) - 0.5) * BAND_LEAN);    const leaning = d.vec4f(LEAN_AXIS * lean, std.sqrt(1 - lean * lean));    const tilt = std.select(d.vec4f(0, 0, 0, 1), leaning, band);    const eye = instances.rotate(inverse(tilt), cameras.current.$.position - centre);    const front = std.atan2(-eye.z, eye.x);    // Names keep clear of the planet as they shrink, and facts of the name as they grow    const size = std.length(eye) / SIZE_DISTANCE;    const radius = std.max(wrap.z, wrap.x * size);    // Text facing the eye lifts so its nearest point keeps above or below the planet on screen    const rise = std.clamp(eye.y / std.length(eye), -0.9, 0.9);    // Details above a name lift as far as it does, then over its upright text, which the eye    // sees foreshortened    const above = label % PER === ABOVE;    let lift = wrap.y * std.select(std.max(size, 1), d.f32(1), name || above);    if (above) lift += DETAILS_ABOVE * size * std.sqrt(1 - rise * rise);    const facing = (lift + radius * rise) / std.sqrt(1 - rise * rise);    const turned = clock.$.sim * BAND_TURN * (1.2 - d.f32(planet) * 0.05);    return Arc({      centre,      tilt,      radius,      size,      lift: std.select(facing, lift, band),      start: std.select(front, turned, band),      front,    });  };  /**   * A name or details label as a box on the picture, from its `box`: its middle, then its half   * width and height.   */  const boxOf = (label: number, box: d.v2f) => {    'use gpu';    const circle = arcOf(label);    const grow = clock.$.grow;    const front = d.vec3f(std.cos(circle.front), 0, -std.sin(circle.front)) * circle.radius;    const baseline = onPicture(circle.centre + (front + d.vec3f(0, circle.lift, 0)) * grow);    // Labels keep one size on the picture, so their boxes scale with the lens alone    const scale = (cameras.lensOf(cameras.current.$).y / (2 * SIZE_DISTANCE)) * grow;    const half = d.vec2f(wraps.$[label]!.x * 0.9, box.x) * scale;    return d.vec4f(baseline + d.vec2f(0, box.y * scale), half);  };  /** How far planet `planet`'s details have moved above its name, from 0 to 1. */  const abovenessOf = (planet: number) => {    'use gpu';    return aboveness(planet, wraps.$[planet * PER + DETAILS]!.x * 1.8, clock.$.grow);  };  /**   * How far a chosen planet's name has moved to the head of its details below, from 0 to 1. It   * moves while the sun stands behind it and the details keep below.   */  const nameUnder = (planet: number) => {    'use gpu';    if (choices.$[planet]!.open === 0) return d.f32(0);    const eye = cameras.current.$;    const sun = bodies.$[0]!.position;    const disc = (SUN.radius * 1.2 * cameras.lensOf(eye).y) / (2 * std.distance(eye.position, sun));    const box = boxOf(planet * PER + NAME, NAME_BOX);    const outside = std.abs(onPicture(sun) - box.xy) - box.zw;    const gap = std.length(std.max(outside, d.vec2f())) + std.min(std.max(outside.x, outside.y), 0);    return (1 - std.smoothstep(0, CLEARANCE * 2, gap - disc)) * (1 - abovenessOf(planet));  };  /**   * Fades a name in as it clears every other body, the chosen planet's band and details, and every   * name that wins over it: the chosen planet's, or a nearer one. A chosen planet's name always   * shows.   */  const clearance = (planet: number) => {    'use gpu';    if (choices.$[planet]!.open === 1) return d.f32(1);    const eye = cameras.current.$;    const lens = cameras.lensOf(eye);    const box = boxOf(planet * PER + NAME, NAME_BOX);    const away = std.distance(eye.position, bodies.$[planet + 1]!.position);    let clear = d.f32(1);    for (let body = d.u32(0); body <= PLANETS.length; body++) {      if (body !== planet + 1) {        const at = bodies.$[body]!.position;        const distance = std.distance(eye.position, at);        const other = body - 1;        const chosen = body > 0 && choices.$[other]!.open === 1;        let reach = extents.$[body]!;        if (chosen) reach = wraps.$[other * PER + BAND]!.x * std.max(distance / SIZE_DISTANCE, 1);        const disc = (reach * lens.y) / (2 * distance);        const outside = std.abs(onPicture(at) - box.xy) - box.zw;        const gap =          std.length(std.max(outside, d.vec2f())) + std.min(std.max(outside.x, outside.y), 0);        clear = std.min(clear, std.smoothstep(0, CLEARANCE, gap - disc));        if (chosen || (body > 0 && distance < away)) {          const rival = boxOf(other * PER + NAME, NAME_BOX);          clear = std.min(clear, std.smoothstep(0, CLEARANCE, apart(rival, box)));        }        if (chosen) {          const above = abovenessOf(other);          const under = std.smoothstep(            0,            CLEARANCE,            apart(boxOf(other * PER + DETAILS, DETAILS_BOX), box),          );          const over = std.smoothstep(            0,            CLEARANCE,            apart(boxOf(other * PER + ABOVE, ABOVE_BOX), box),          );          clear = std.min(clear, std.min(std.mix(1, under, 1 - above), std.mix(1, over, above)));        }      }    }    return clear;  };  const place = (_position: d.v3f, offset: d.v2f, glyph: text.Placed) => {    'use gpu';    const kind = glyph.label % PER;    const planet = planetOf(glyph.label);    const choice = choices.$[planet]!;    // Names always show; a planet's facts, and its name below, only while it is chosen    const on = kind === NAME || choice.open === 1;    // Glyphs grow in one after another, rising, and shrink back all at once. Names type on once.    const real = clock.$.real;    const intro = INTRO + d.f32(planet) * STAGGER;    const since = std.select(std.max(choice.since, intro), intro, kind === NAME);    const typed = (real - since) * TYPING;    const opening = std.saturate(typed - d.f32(glyph.glyph));    const closing = std.saturate(1 - (real - choice.since) * TYPING * 0.2);    let shown = std.select(closing, opening, on);    const middle = glyph.pen + d.vec2f(0.3, 0.35) * glyph.em;    const circle = arcOf(glyph.label);    // A band's glyphs grow in as they turn towards the eye and shrink away as they turn past    if (kind === BAND) {      const facing = std.cos(        circle.start + (middle.x * circle.size) / circle.radius - circle.front,      );      shown *= std.smoothstep(0.45, 0.6, facing);    }    const local = middle + (offset - middle) * shown - d.vec2f(0, (1 - shown) * 0.5 * glyph.em);    const eye = cameras.current.$;    const grow = clock.$.grow;    const near = std.smoothstep(NEAR.x, NEAR.y, std.distance(eye.position, circle.centre));    const shrunk = (middle + (local - middle) * near) * circle.size;    const angle = circle.start + shrunk.x / circle.radius;    const around = d.vec3f(std.cos(angle), 0, -std.sin(angle)) * circle.radius;    let lift = circle.lift;    if (kind === DETAILS) lift -= nameUnder(planet) * NAME_LINE * circle.size;    const point = (around + d.vec3f(0, lift + shrunk.y, 0)) * grow;    const world = circle.centre + instances.rotate(circle.tilt, point);    const clip = eye.viewProjection * d.vec4f(world, 1);    // The chosen planet's labels draw over every body, so nothing the viewer chose to read hides    return std.select(clip, d.vec4f(clip.xy, 0, clip.w), kind !== NAME || choice.open === 1);  };  /**   * A soft dark halo keeps text readable over orbit paths and stars, and a darker, wider one keeps   * facts readable over the bodies they cross. Names fade where they would crowd another, and   * details to whichever side of the planet the bodies leave clear.   */  const shade = (fragment: text.Fragment) => {    'use gpu';    let alpha = fragment.color.w;    const planet = planetOf(fragment.label);    if (fragment.label % PER === NAME) alpha *= clearance(planet) * (1 - nameUnder(planet));    if (fragment.label % PER === UNDER) alpha *= nameUnder(planet);    if (fragment.label % PER === DETAILS) alpha *= 1 - abovenessOf(planet);    if (fragment.label % PER === ABOVE) alpha *= abovenessOf(planet);    const fill = std.saturate(fragment.distance + 0.5) * alpha;    const facts = fragment.label % PER !== NAME || choices.$[planet]!.open === 1;    const width = std.select(d.f32(2), fragment.reach, facts);    const dark = std.select(d.f32(0.5), d.f32(0.85), facts);    const halo = std.saturate((fragment.round + width) / (width + 0.5)) * dark * alpha;    return d.vec4f(fragment.color.xyz * fill, fill + (1 - fill) * halo);  };  const texts = text.create(root, font, {    capacity: PLANETS.length * PER,    length: LENGTH,    place: tgpu.fn(place).pipe(planets.inject()),    shade: tgpu.fn(shade).pipe(planets.inject()),    label: 'labels',  });  for (const [i, planet] of PLANETS.entries()) {    for (const [kind, line] of lines[i]!.entries()) {      texts.write(i * PER + kind, line, { em: EM[kind]!, ...ANCHORS[kind] });    }    const name = d.vec4f(std.mix(planet.color, d.vec3f(1), 0.4), 1);    const colors = [name, ...COLORS, name];    texts.labels.patch(      Object.fromEntries(colors.map((color, kind) => [i * PER + kind, { color }])),    );  }  let chosen: number | undefined;  const now = { sim: 0, real: 0, grow: 1 };  return {    drawing: texts.drawing,    /** Each planet's details' width in world units at `SIZE_DISTANCE`. */    widths: PLANETS.map((planet, i) => arc(lines[i]![DETAILS]!, DETAILS, planet.bandRadius) * 1.8),    /**     * Turns the bands and types labels on to these seconds of simulated and real time, and draws     * labels `grow` times their size.     */    tick(sim: number, real: number, grow: number) {      now.sim = sim;      now.real = real;      now.grow = grow;      clock.write(now);    },    /** Unravels planet `next`'s band and types its details on, and the reverse for the last. */    choose(next: number | undefined, real: number) {      if (next === chosen) return;      const changes: Record<number, d.Infer<typeof Choice>> = {};      if (chosen !== undefined) changes[chosen] = { since: real, open: 0 };      if (next !== undefined) changes[next] = { since: real, open: 1 };      choices.buffer.patch(changes);      chosen = next;    },    destroy: texts.destroy,  };}

comets.ts

import { instances, trails } from '@typegpu/kit';import { quat } from 'math';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { rateOf } from './data.ts';/** Seconds a step moves each comet and adds a point to its tail. */export const STEP = 1 / 60;/** Gravitational parameter in world units cubed per second squared, as the planets orbit. */const GM = rateOf(1) ** 2;const WORKGROUP = 64;const COMETS = 2;/** Points along each comet's tail, one a step. */const TAIL_POINTS = 160;/** Linear colours of the first comet's tail and the second's. */const COOL = d.vec3f(0.55, 0.85, 1);const WARM = d.vec3f(1, 0.7, 0.45);/** How much brighter than its tail a comet's head glows. */const HEAD = 7;/** Turns about +x by `tilt`, then about +y by `turn`. */const orientation = (tilt: number, turn: number) =>  quat.fromEuler(quat.create(), [tilt, turn, 0, 'yxz']);const Ellipse = d.struct({  /** Semi-major axis in world units, and eccentricity from 0 for a circle towards 1. */  axis: d.f32,  eccentricity: d.f32,  /** Radians round the sun from perihelion, which the kernel advances. */  angle: d.f32,  orientation: d.vec4f,});/** Moves two comets round the sun a fixed step at a time, pushing a point to each tail. */export function createComets(root: TgpuRoot, options: { tailWidth: number }) {  const orbits = root.createMutable(d.arrayOf(Ellipse, COMETS), [    { axis: 8.6, eccentricity: 0.8, angle: -1.3, orientation: orientation(0.3, 0.4) },    { axis: 5.2, eccentricity: 0.72, angle: 2.2, orientation: orientation(-0.22, 2.6) },  ]);  const tails = trails.create(root, {    capacity: COMETS,    length: TAIL_POINTS,    settings: { width: options.tailWidth },    shade: (point, across) => {      'use gpu';      const alpha = (1 - across * across) * (1 - point.along);      const glow = 1 + HEAD * std.exp(-point.along * 60);      return d.vec4f(std.mix(COOL, WARM, d.f32(point.trail)) * alpha * glow, alpha);    },  });  const move = tgpu.computeFn({    workgroupSize: [WORKGROUP],    in: { gid: d.builtin.globalInvocationId },  })(({ gid }) => {    'use gpu';    const i = gid.x;    if (i >= COMETS) return;    // Equal areas in equal times: a comet moves faster near the sun    const orbit = Ellipse(orbits.$[i]!);    const span = orbit.axis * (1 - orbit.eccentricity * orbit.eccentricity);    const before = span / (1 + orbit.eccentricity * std.cos(orbit.angle));    const angle = (orbit.angle + (std.sqrt(GM * span) / (before * before)) * STEP) % (Math.PI * 2);    orbits.$[i]!.angle = angle;    const distance = span / (1 + orbit.eccentricity * std.cos(angle));    const flat = d.vec3f(std.cos(angle), 0, std.sin(angle)) * distance;    tails.push(i, instances.rotate(orbit.orientation, flat));  });  const moving = root.pipe(tails.inject()).createComputePipeline({ compute: move });  return {    tails,    initAsync: () => moving.initAsync(),    /** Records one step in the caller's compute pass. */    step(pass: TgpuComputePass) {      pass.setPipeline(moving);      pass.dispatchWorkgroups(Math.ceil(COMETS / WORKGROUP));    },    destroy: tails.destroy,  };}

data.ts

import { hexToRgb, srgbToLinear } from '@typegpu/color';import { d } from 'typegpu';export const [PLAIN, BANDED, EARTHLIKE, BURNING] = [0, 1, 2, 3];// name | kind | day | year | diameter in km | gravity in g | distance in AUconst FACTS = `Mercury | Rocky planet | 58.6 days  | 88 days    | 4,879   | 0.38 | 0.39Venus   | Rocky planet | 243 days   | 225 days   | 12,104  | 0.91 | 0.72Earth   | Rocky planet | 23.9 hours | 365 days   | 12,742  | 1.00 | 1.00Mars    | Rocky planet | 24.6 hours | 687 days   | 6,779   | 0.38 | 1.52Jupiter | Gas giant    | 9.9 hours  | 11.9 years | 139,820 | 2.53 | 5.20Saturn  | Gas giant    | 10.7 hours | 29.5 years | 116,460 | 1.07 | 9.54Uranus  | Ice giant    | 17.2 hours | 84 years   | 50,724  | 0.89 | 19.2Neptune | Ice giant    | 16.1 hours | 165 years  | 49,244  | 1.14 | 30.1`;// orbit, radius in world units | phase, tilt in radians | spin in radians a second | colours | patternconst LOOKS = `2.6  | 0.3  | 4.92 | 0.02 | 0.3   | 9e9a95 | 67635f | 03.9  | 0.42 | 3.0  | 0.05 | -0.15 | f5dc9c | e0b871 | 15.2  | 0.45 | 4.44 | 0.41 | 0.8   | 3a77e6 | 5fb352 | 26.5  | 0.36 | 2.17 | 0.44 | 0.75  | d66b38 | 9a4526 | 08.3  | 1.0  | 3.7  | 0.05 | 1.2   | e0b385 | faedd1 | 110.1 | 0.82 | 5.6  | 0.47 | 1.1   | f2d99e | dbb780 | 111.8 | 0.6  | 0.9  | 1.71 | 0.9   | a8e8ed | 8ad1de | 113.4 | 0.58 | 2.3  | 0.49 | 0.95  | 3861e6 | 6b94ff | 1`;const rows = (table: string) =>  table    .trim()    .split('\n')    .map((row) => row.split('|').map((cell) => cell.trim()));/** Converts an sRGB hex colour to linear light. */export const hex = (rgb: string) => srgbToLinear(hexToRgb(rgb));export const EARTH = 2;export const SATURN = 5;/** Saturn's two ring bands: radius in planet radii, and colour. */export const SATURN_RINGS = [  { radius: 1.55, color: hex('efe2c0') },  { radius: 2.05, color: hex('b8a27c') },];const looks = rows(LOOKS);/** Each planet's facts, and how it looks and moves, stylised and not to scale. */export const PLANETS = rows(FACTS).map((fact, i) => {  const [name, kind, day, year, diameter, gravity, au] = fact as [string, ...string[]];  const [orbit, radius, phase, tilt, spin, color, accent, pattern] = looks[i]!;  const extent = Number(radius) * (i === SATURN ? SATURN_RINGS[1]!.radius + 0.25 : 1);  return {    name,    band: `orbit ${year}  /  day ${day}  /  `.toUpperCase(),    details:      `${kind}\n${diameter} km across\nGravity ${gravity} g\n${au} AU from the sun`.toUpperCase(),    orbit: Number(orbit),    radius: Number(radius),    /** World units from its centre to its outermost edge, Saturn's rings included. */    extent,    /** World units from its centre to the band of facts round it, outside Saturn's ring. */    bandRadius: Math.max(Number(radius) * 1.5, extent + Number(radius) * 0.15) + 0.35,    phase: Number(phase),    tilt: Number(tilt),    spin: Number(spin),    color: hex(color!),    accent: hex(accent!),    pattern: Number(pattern),  };});export const SUN = { radius: 1.3, spin: 0.08, emission: d.vec3f(3.6, 1.25, 0.22) };export const MOON = {  orbit: 0.85,  radius: 0.12,  rate: 2,  color: hex('bebec2'),  accent: hex('76767a'),};/** Radians a second round the sun for an orbit `radius` world units out, as in Kepler's third law. */export const rateOf = (radius: number) => 2.2 / radius ** 1.5;

sky.ts

import { environment } from '@typegpu/kit';import { perlin3d } from '@typegpu/noise';import { d, std } from 'typegpu';/** Linear colours of space overhead, at the ecliptic and below it. */const ABOVE = d.vec3f(0.006, 0.005, 0.02);const LEVEL = d.vec3f(0.014, 0.009, 0.03);const BELOW = d.vec3f(0.003, 0.005, 0.016);/** A faint band of haze, tilted across the ecliptic. */const BAND = d.vec3f(0.018, 0.012, 0.034);const BAND_AXIS = d.vec3f(0.35, 0.9, -0.25);/** A star in a fifth of the cells, 0.9 pixels across so none falls between pixels. */const starAt = environment.stars({ cells: 72, chance: 0.2, size: 0.9 });const STAR_BRIGHTNESS = 0.55;/** A power distribution gives many faint stars and a few bright ones. */const STAR_SPREAD = 6;const STAR_WARM = d.vec3f(1, 0.85, 0.7);const STAR_COOL = d.vec3f(0.7, 0.8, 1);const starlight = (direction: d.v3f) => {  'use gpu';  const star = starAt(direction);  const brightness = std.pow(star.seed.x, STAR_SPREAD) * STAR_BRIGHTNESS + 0.02;  return std.mix(STAR_WARM, STAR_COOL, star.seed.y) * (brightness * star.disc);};const spaceAt = (direction: d.v3f) => {  'use gpu';  const up = std.mix(LEVEL, ABOVE, std.sqrt(std.saturate(direction.y)));  const down = std.mix(LEVEL, BELOW, std.sqrt(std.saturate(-direction.y)));  const base = std.select(down, up, direction.y > 0);  const across = std.abs(std.dot(direction, std.normalize(BAND_AXIS)));  const wisps = perlin3d.sample(direction * 3) * 0.5 + 0.5;  const haze = std.exp(-across * across * 18) * wisps;  return base + BAND * haze + starlight(direction);};/** Deep space: a blue-violet gradient, a faint band of haze and sparse stars. */export const space = environment.background(spaceAt);

assets.ts

import { font } from '../assets.ts';/** Inter, baked and shipped with `@typegpu/kit`. */export const assets = { font: font('inter') };