Star map
20 000 stars are generated on the CPU. GPU picking finds the star under the pointer each frame. Its found() accessor lights that star through a 'use gpu' glow field in the same frame. Readback handles clicks, and the chosen star gets a callout pinned beside it, drawn over the finished frame: a ring and a leader out to a plate, shaded from signed distances, with its name over its distance from the core typing on in the kit's MSDF text. Bloom and the final pass add glow.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- label.tsThe chosen star's callout, pinned beside it at a constant size: a ring round the star, a leader out to a dark plate, and on the plate its name over its distance from the core.
- stars.tsDraws additive camera-facing star discs into the caller's pass.
- assets.ts
Kit modules bloom, cameras, finish, font, owner, passes, picker, pipelines, targets, text, tone. Also @typegpu/sdf, typegpu.
scene.ts
import { cameras, owner, passes, picker, pipelines, targets, bloom, font, finish, tone, type Baked, type Frame, type Surface,} from '@typegpu/kit';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import { createLabel } from './label.ts';import { createStars, galaxy, Star, type StarData } from './stars.ts';const HDR = 'rgba16float';/** The dark between the stars, linear. */const SPACE = [0.004, 0.005, 0.012, 1] as const;/** Radians a second the view turns round the galaxy on its own. */const DRIFT = 0.03;/** Brightness multipliers for hovered and selected stars. */const HOVERED = 1;const CHOSEN = 1.5;export interface SceneOptions { /** The label's font. */ readonly font: Baked; /** Default 20 000. */ readonly count?: number; /** Chooses the star nearest this point from the start. Default none. */ readonly choose?: readonly [x: number, y: number, z: number];}/** Star selected by a press, with its index. */export interface Choice { readonly index: number; readonly star: StarData;}/** Drops a pick the destroyed scene no longer waits for. */const ignore = () => undefined;/** Index of the star nearest `point`. */function nearestTo(data: readonly StarData[], [x, y, z]: readonly [number, number, number]) { let best = 0; let least = Infinity; for (const [index, { position }] of data.entries()) { const apart = Math.hypot(position[0] - x, position[1] - y, position[2] - z); if (apart < least) [best, least] = [index, apart]; } return best;}/** * Views stars with an orbit camera and picks the hovered star on the GPU. A press chooses it, and * a label pinned beside it names it and its distance from the core. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) { const { count = 20_000 } = options; const own = owner(); const screen = own(targets.create(root, surface, { hdr: { format: HDR, sampled: true } })); const view = cameras.orbit(root, { size: surface, target: [0, 0, 0], distance: 11, pitch: 0.7, distanceRange: [2, 30], damping: 6, }); const data = galaxy(count); const stars = own(root.createReadonly(d.arrayOf(Star, count), data)); const chosen = root.createUniform(d.u32, picker.none); const byEye = root.createUniform(d.u32, 0); let pointer: [x: number, y: number] | undefined; const values = { /** Picker margin beyond a star edge, in points. */ reach: picker.defaults.reach, /** Chooses the nearest candidate by screen distance or eye distance. */ nearest: 'pointer' as 'pointer' | 'eye', }; const picks = own( picker.create(root, view, { capacity: count, size: surface, position: (i) => { 'use gpu'; return d.vec3f(stars.$[i]!.position); }, radius: (i) => { 'use gpu'; return stars.$[i]!.radius; }, rank: (candidate) => { 'use gpu'; return std.select(picker.byPointer(candidate), picker.byEye(candidate), byEye.$ === 1); }, hz: 20, settings: { reach: values.reach }, }), ); // Highlight the hovered star in the picker frame and keep the selected star lit const field = createStars(root, view, { stars, format: HDR, glow: tgpu .fn((i: number) => { 'use gpu'; const hovered = std.select(d.f32(0), d.f32(HOVERED), i === picks.found()); return hovered + std.select(d.f32(0), d.f32(CHOSEN), i === chosen.$); }) .pipe(picks.inject()), }); const glow = own( bloom.create(root, { source: () => screen.textures.hdr, settings: { threshold: 0.9, spread: 1.2 }, }), ); const sans = own(font.create(root, options.font)); const label = own(createLabel(root, sans, view)); const overlay = passes.create(root, surface); const callout = overlay.add(label.shapes); const labelled = overlay.add(label.text); const final = own( finish.create(root, { format: surface.format, source: () => screen.textures.hdr, tone: tone.aces, settings: { glowIntensity: 0.35, vignette: 0.45 }, glow, }), ); await pipelines.compile([picks, field, glow, final, overlay]); let drawnFrame = -1; let time = 0; const settings: Settings<typeof values> = { values, knobs: { reach: { label: 'pick margin (points)', min: 0, max: 64, step: 1 }, nearest: { label: 'pick priority', options: ['pointer', 'eye'] }, }, write(key) { if (key === undefined || key === 'reach') picks.settings.patch({ reach: values.reach }); if (key === undefined || key === 'nearest') byEye.write(values.nearest === 'eye' ? 1 : 0); }, readouts: { /** The last pick read back: `none yet`, `null` for no star, or a star index. */ result: () => { const { latest, latestFrame } = picks.result; if (latestFrame < 0) return 'none yet'; return latest === null ? 'null' : `#${latest}`; }, /** Frame index when the readback result was picked. */ 'picked in frame': () => { const { latestFrame } = picks.result; return latestFrame < 0 ? 'pending' : latestFrame; }, 'drawn in frame': () => drawnFrame, }, }; let choice: Choice | null = null; const choose = (index: number | null) => { chosen.write(index ?? picker.none); choice = index === null ? null : { index, star: data[index]! }; label.show(choice && { index: choice.index, position: choice.star.position }, time); }; if (options.choose) choose(nearestTo(data, options.choose)); let presses = 0; let pressed: [x: number, y: number] | undefined; const pointing = { move(x: number, y: number) { pointer = [x, y]; }, leave() { pointer = undefined; picks.clear(); }, /** Selects the star at the pressed point, or clears selection, once its pick comes back. */ press(x: number, y: number) { const press = ++presses; pressed = [x, y]; picks.result.next().then((found) => { if (press !== presses || own.destroyed) return; pressed = undefined; choose(found); }, ignore); }, get reach() { return values.reach; }, } satisfies Pointing; return { view, settings, pointing, /** Selected star, or null. */ get choice() { return choice; }, frame(frame: Frame) { drawnFrame = frame.index; time = frame.time; label.tick(time); view.goal.yaw += frame.dt * DRIFT; view.update(frame.dt); screen.fit(); const compute = frame.encoder.beginComputePass(); const at = pressed ?? pointer; if (at) picks.pick(compute, at[0], at[1]); compute.end(); picks.result.capture(frame); const scene = frame.encoder.beginRenderPass({ colorAttachments: { view: screen.views.hdr, clearValue: SPACE }, }); field.draw(scene); scene.end(); glow.record(frame); // The label draws over the finished frame, so neither bloom nor tone mapping touches it const output = overlay.begin(frame); final.draw(output); callout.draw(output); labelled.draw(output); output.end(); }, destroy: own.destroy, };}label.ts
import { cameras, passes, pipelines, text, type CameraBase, type Font, type Inject,} from '@typegpu/kit';import { sdRoundedBox2d } from '@typegpu/sdf';import { d, std, type TgpuRoot } from 'typegpu';/** The labels: the star's name and its distance from the core. */const [NAME, DISTANCE] = [0, 1];/** Glyphs a label holds; spaces take none. */const LENGTH = 24;/** Points per em of the name and the distance. */const EM = [16, 12.5];/** Em between the name's characters. */const TRACK = 0.07;// The callout, in points from the star with y up, for a label on the star's upper right/** Radius of the ring round the star, and the width of its line and of the leader. */const RING = 9;const STROKE = 1.25;/** Where the leader turns from the ring's diagonal to run level into the plate. */const ELBOW = 21;/** Where the plate starts, and its padding, corner radius and border width. */const PLATE_X = 31;const PAD = d.vec2f(11, 9);const CORNER = 7;const BORDER = 1;/** Points from the name's baseline to the distance's capitals. */const LINE_GAP = 7;/** Angles in radians: where the leader leaves the ring, and a full turn; and cos 45°. */const QUARTER = Math.PI / 4;const TAU = Math.PI * 2;const DIAGONAL = Math.SQRT1_2;/** Points of slack round the callout's quad, for antialiasing. */const SLACK = 2;/** Clip-space x and y past which the callout flips to the star's left or below it. */const FLIP = d.vec2f(0.35, 0.55);/** Linear colours: name, distance, ring and leader, plate fill and plate border. */const NAME_COLOR = d.vec4f(0.95, 0.96, 1, 1);const DISTANCE_COLOR = d.vec4f(0.62, 0.7, 0.88, 1);const ACCENT = d.vec3f(0.5, 0.66, 1);const PLATE = d.vec4f(0.012, 0.016, 0.04, 0.86);const EDGE = d.vec4f(0.5, 0.66, 1, 0.38);/** Seconds the ring takes to draw round, the leader to run out and the plate to open. */const RING_TIME = 0.2;const LEAD_TIME = 0.12;const PLATE_TIME = 0.16;/** Glyphs a second that type on once the plate is open. */const TYPING = 48;/** Points a glyph rises through as it types on. */const RISE = 3;/** Seconds from a choice until every glyph has typed on. */const SETTLED = RING_TIME + LEAD_TIME + PLATE_TIME + (2 * LENGTH) / TYPING;/** * The label's state: the chosen star, when it was chosen, now in seconds, the widest line in * points, and 1 while a star is chosen. */const Callout = d.struct({ position: d.vec3f, since: d.f32, now: d.f32, width: d.f32, open: d.u32,});const Corner = d.struct({ position: d.vec4f, local: d.vec2f });const Seen = d.struct({ color: d.vec4f });const ease = (t: number) => { 'use gpu'; const s = std.saturate(t); return s * s * (3 - 2 * s);};/** * Distance from `p` to the segment from `a` to `b`. Stays finite for a zero-length segment, where * `sdLine` divides by zero. */const segment = (p: d.v2f, a: d.v2f, b: d.v2f) => { 'use gpu'; const along = b - a; const t = std.saturate(std.dot(p - a, along) / std.max(std.dot(along, along), 1e-6)); return std.length(p - a - along * t);};/** Coverage of a pixel at signed distance `distance` in points, at `pixels` pixels a point. */const cover = (distance: number, pixels: number) => { 'use gpu'; return std.saturate(0.5 - distance * pixels);};/** `color` over `under`, both premultiplied. */const over = (under: d.v4f, color: d.v4f) => { 'use gpu'; return color + under * (1 - color.w);};/** * The chosen star's callout, pinned beside it at a constant size: a ring round the star, a leader * out to a dark plate, and on the plate its name over its distance from the core. The ring draws * round first, the leader runs out, the plate opens, and the glyphs type on. */export function createLabel(root: TgpuRoot, font: Font, camera: CameraBase) { const callout = root.createUniform(Callout); // The plate holds the name's capitals and the distance's. Each line is anchored at the top of // its capitals, in points from the plate's middle: the name's `PAD.y` below the plate's top, // the distance's `LINE_GAP` below the name's baseline const capitals = font.data.capHeight; const plateHeight = PAD.y * 2 + capitals * (EM[NAME]! + EM[DISTANCE]!) + LINE_GAP; const nameTop = plateHeight / 2 - PAD.y; const distanceTop = nameTop - capitals * EM[NAME]! - LINE_GAP; /** Seconds since the star was chosen. */ const since = () => { 'use gpu'; return callout.$.now - callout.$.since; }; /** Glyphs typed on so far, once the plate has opened. */ const typedGlyphs = () => { 'use gpu'; return (since() - RING_TIME - LEAD_TIME - PLATE_TIME) * TYPING; }; /** The open plate's size in points. */ const plateSize = () => { 'use gpu'; return d.vec2f(callout.$.width + PAD.x * 2, plateHeight); }; /** -1 or 1 per axis: which way from the star the callout points, to stay on screen. */ const sides = () => { 'use gpu'; const clip = cameras.current.$.viewProjection * d.vec4f(callout.$.position, 1); const x = std.select(d.f32(1), d.f32(-1), clip.x > clip.w * FLIP.x); const y = std.select(d.f32(1), d.f32(-1), clip.y > clip.w * FLIP.y); return d.vec2f(x, y); }; /** Sets each glyph on the plate, which mirrors with the callout but reads left to right. */ const place = (position: d.v3f, offset: d.v2f, glyph: text.Placed) => { 'use gpu'; const shown = std.saturate(typedGlyphs() - d.f32(glyph.glyph)); const local = d.vec2f(offset.x, offset.y - (1 - shown) * RISE); const plate = plateSize(); const centre = d.vec2f(PLATE_X + plate.x / 2, ELBOW) * sides(); const start = d.vec2f( centre.x - plate.x / 2 + PAD.x, centre.y + std.select(nameTop, distanceTop, glyph.label !== NAME), ); return text.pinned(position, local + start); }; const shade = (fragment: text.Fragment) => { 'use gpu'; const shown = std.saturate((typedGlyphs() - d.f32(fragment.glyph)) * 2); const alpha = fragment.color.w * shown * std.saturate(fragment.distance + 0.5); return d.vec4f(fragment.color.xyz * alpha, alpha); }; const set = text.create(root, font, { capacity: 2, length: LENGTH, camera, place, shade, label: 'star label', }); set.labels.patch({ [NAME]: { color: NAME_COLOR }, [DISTANCE]: { color: DISTANCE_COLOR } }); const fill: Inject = (cfg) => cfg.pipe(camera.inject()); /** One quad round the callout, shaded with distances to its ring, leader and plate. */ const shapes: passes.Drawing<typeof Corner, d.Infer<typeof Seen>> = { label: 'star callout', Vertex: Corner, at: (vertexIndex: number, _instanceIndex: number) => { 'use gpu'; const plate = plateSize(); const low = d.vec2f(-RING - SLACK); const high = d.vec2f(PLATE_X + plate.x, ELBOW + plate.y / 2) + SLACK; const corner = d.vec2f(d.f32(vertexIndex & 1), d.f32(vertexIndex >>> 1)); const local = std.mix(low, high, corner); const position = text.pinned(callout.$.position, local * sides()); // A cleared label collapses beyond the far plane const hidden = d.vec4f(0, 0, 2, 1); return Corner({ position: std.select(hidden, position, callout.$.open === 1), local }); }, surfaceOf: (corner) => { 'use gpu'; const p = corner.local; const t = since(); const pixels = passes.pixels.$.pixelRatio; // The ring draws round from where the leader leaves it const turned = std.fract((std.atan2(p.y, p.x) - QUARTER) / TAU + 1); const ringDrawn = turned <= ease(t / RING_TIME); const ring = std.select(d.f32(1e3), std.abs(std.length(p) - RING) - STROKE / 2, ringDrawn); // The leader runs out along the diagonal, then level into the plate const leaving = d.vec2f(RING * DIAGONAL); const elbow = d.vec2f(ELBOW); const end = d.vec2f(PLATE_X, ELBOW); const first = std.length(elbow - leaving); const total = first + (PLATE_X - ELBOW); const run = ease((t - RING_TIME) / LEAD_TIME) * total; const diagonal = leaving + (elbow - leaving) * std.saturate(run / first); const level = elbow + (end - elbow) * std.saturate((run - first) / (total - first)); const lead = std.min(segment(p, leaving, diagonal), segment(p, elbow, level)); const leader = std.select(d.f32(1e3), lead - STROKE / 2, run > 0); // The plate opens from the leader's end const opening = ease((t - RING_TIME - LEAD_TIME) / PLATE_TIME); const plate = plateSize() * d.vec2f(opening, 1); const centre = d.vec2f(PLATE_X + plate.x / 2, ELBOW); const box = sdRoundedBox2d(p - centre, plate / 2, std.min(CORNER, plate.x / 2)); let color = PLATE * cover(box, pixels) * opening; color = over(color, EDGE * cover(std.abs(box + BORDER / 2) - BORDER / 2, pixels) * opening); const line = std.max(cover(ring, pixels), cover(leader, pixels)); color = over(color, d.vec4f(ACCENT, 1) * line); return Seen({ color }); }, color: passes.shaded, blend: pipelines.blend.over, depth: { depthWriteEnabled: false, depthCompare: 'always' }, primitive: { topology: 'triangle-strip' }, inject: () => fill, draw: (pass) => pass.draw(4), }; /** What the callout uniform holds. */ const state = { position: d.vec3f(), since: 0, now: 0, width: 0, open: 0 }; let animating = false; const write = (now: number) => { state.now = now; callout.write(state); }; return { /** Draws the ring, leader and plate; draw it before `text`. */ shapes, /** Draws the name and distance over the plate. */ text: set.drawing, /** Labels the star at `position` from `time`, or clears the label for null. */ show( star: { readonly index: number; readonly position: readonly number[] } | null, time: number, ) { if (!star) { for (const label of [NAME, DISTANCE]) set.write(label, '', { em: EM[label]! }); state.open = 0; animating = false; write(time); return; } const [x = 0, y = 0, z = 0] = star.position; const position = d.vec3f(x, y, z); const lines = [`STAR ${star.index}`, `${Math.hypot(x, y, z).toFixed(2)} from the core`]; state.width = 0; for (const [label, line] of lines.entries()) { const tracking = label === NAME ? TRACK : 0; const style = { em: EM[label]!, align: 'left', baseline: 'cap', tracking } as const; state.width = Math.max(state.width, set.write(label, line, style).width); } set.labels.patch({ [NAME]: { position }, [DISTANCE]: { position } }); state.position = position; state.since = time; state.open = 1; animating = true; write(time); }, /** Advances the animation; writes nothing once it has finished. */ tick(time: number) { if (!animating) return; write(time); animating = time - state.since < SETTLED; }, destroy() { set.destroy(); }, };}stars.ts
import { cameras, pipelines, type CameraBase } from '@typegpu/kit';import { d, std, tgpu, type TgpuReadonly, type TgpuRenderCommands, type TgpuRoot } from 'typegpu';export const Star = d.struct({ position: d.vec3f, /** World units from its middle to its edge. */ radius: d.f32, /** Linear HDR colour. */ color: d.vec3f,});type Vec3 = [number, number, number];export interface StarData { readonly position: Vec3; readonly radius: number; readonly color: Vec3;}export interface StarsOptions { readonly stars: TgpuReadonly<d.WgslArray<typeof Star>>; /** The target's format, such as an HDR one. */ readonly format: GPUTextureFormat; /** GPU function returning brightness gain by star index. Zero leaves brightness unchanged. */ readonly glow: (i: number) => number;}const ARMS = 3;/** * Galaxy shape in world units and radians. Stars span radii `core` to `core + disc`. Arms wind * by `winding` radians per unit radius. Stars deviate by `spread` radians and up to `height` * above or below the disc, tapering toward the rim. */const SHAPE = { core: 0.3, disc: 6, winding: 0.75, spread: 0.3, height: 0.18 };/** Minimum and maximum star radius in world units, biased toward small stars. */const RADIUS = [0.02, 0.1] as const;/** Minimum and maximum brightness, biased toward dim stars. */const SHINE = [1.5, 7.5] as const;/** How sharply a star's light falls off from its middle. */const FALLOFF = 9;/** Radius growth per unit of `glow`. */const SWELL = 3;/** Highlight-ring radius, width and brightness. Radius and width use star-radius units. */const RING = { radius: 0.8, width: 0.08, brightness: 0.6 };/** A standard normal sample, by the Box–Muller transform. */const normal = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(Math.PI * 2 * Math.random());/** * Generates `count` stars in a spiral around the origin on the CPU. Outer arms favour hot blue * stars, and the core favours cooler yellow and orange stars. */export function galaxy(count: number): StarData[] { const blue: Vec3 = [0.62, 0.74, 1]; const yellow: Vec3 = [1, 0.86, 0.66]; const orange: Vec3 = [1, 0.5, 0.28]; return Array.from({ length: count }, (_, i) => { const out = Math.random() ** 0.7; const radius = SHAPE.core + out * SHAPE.disc; const arm = ((i % ARMS) / ARMS) * Math.PI * 2; const angle = arm + radius * SHAPE.winding + normal() * SHAPE.spread; const height = normal() * SHAPE.height * (1.1 - out); // Bias outer stars toward blue while retaining all colours at every radius const heat = Math.min(1, Math.max(0, out + normal() * 0.25)); const [from, to, t] = heat < 0.5 ? [orange, yellow, heat * 2] : [yellow, blue, heat * 2 - 1]; const shine = SHINE[0] + Math.random() ** 3 * (SHINE[1] - SHINE[0]); return { position: [Math.cos(angle) * radius, height, Math.sin(angle) * radius], radius: RADIUS[0] + Math.random() ** 6 * (RADIUS[1] - RADIUS[0]), color: from.map((c, k) => (c + (to[k]! - c) * t) * shine) as Vec3, }; });}/** * Draws additive camera-facing star discs into the caller's pass. `glow` increases size and * brightness and adds a ring. */export function createStars(root: TgpuRoot, view: CameraBase, options: StarsOptions) { const { stars, glow } = options; const count = stars.buffer.dataType.elementCount; const billboard = tgpu.vertexFn({ in: { vertex: d.builtin.vertexIndex, instance: d.builtin.instanceIndex }, out: { position: d.builtin.position, at: d.vec2f, color: d.vec3f, lift: d.f32 }, })(({ vertex, instance }) => { 'use gpu'; const star = stars.$[instance]!; const lift = glow(instance); const corner = d.vec2f(std.select(-1, 1, vertex % 2 === 1), std.select(-1, 1, vertex > 1)); const reach = star.radius * (1 + lift * SWELL); const world = star.position + (cameras.current.$.right * corner.x + cameras.current.$.up * corner.y) * reach; return { position: cameras.current.$.viewProjection * d.vec4f(world, 1), at: corner, color: star.color, lift, }; }); const disc = tgpu.fragmentFn({ in: { at: d.vec2f, color: d.vec3f, lift: d.f32 }, out: d.vec4f, })(({ at, color, lift }) => { 'use gpu'; const r = std.length(at); const core = std.exp(-r * r * FALLOFF); const ring = std.smoothstep(RING.width, 0, std.abs(r - RING.radius)) * std.min(lift, 1); return d.vec4f(color * (core * (1 + lift) + ring * RING.brightness), 0); }); const pipeline = root.pipe(view.inject()).createRenderPipeline({ vertex: billboard, fragment: disc, targets: { format: options.format, blend: pipelines.blend.add }, primitive: { topology: 'triangle-strip' }, }); return { initAsync: () => pipeline.initAsync(), draw(pass: TgpuRenderCommands) { pass.setPipeline(pipeline); pass.draw(4, count); }, };}assets.ts
import { font } from '../assets.ts';/** Inter, baked and shipped with `@typegpu/kit`. */export const assets = { font: font('inter') };