Platformer
Pixel art at 16 texels a tile is printed on paper cards under a desk lamp. Each card casts a soft shadow on the cards behind it, and its cut edges catch the light. Every layer and sprite moves by whole texels, and the print scales the art up with square texels. Hero physics runs on the CPU at 120 Hz. The GPU moves critters, counts their contacts as events and reads them back. A coroutine removes collected coins from a table. Particles add dust and sparkles.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- game.tsRuns toward the flag, collecting coins and stomping critters.
- level.tsWhether tile
x, yblocks the hero. - avatar.tsWrites hero state to GPU buffers only when it changes and draws its sprite.
- coins.tsStores coins in a CPU-written table and lists them with live lists.
- critters.tsMoves walkers and bats on the GPU and detects contacts with the hero.
- effects.tsDust kicked up by landings and stomps, and sparkles from taken coins.
- paper.tsPrints the low-resolution art on paper cards under a desk lamp: each card casts a soft shadow on the cards behind, its cut edges catch the light, and the lamp leaves a warm pool.
- scenery.tsShades sky, clouds, hills, tiles and flag per texel in one full-screen pass, each on its card.
- sprites.tsReturns eye colour at a point relative to the pair midpoint, using the sprite's x facing.
- hero.tsRuns CPU physics and tile collisions at a fixed step.
- ui.ts
Kit modules cameras, clock, coroutines, events, finish, liveLists, memo, owner, particles, passes, pipelines, table, targets. Also @typegpu/noise, @typegpu/sdf, math, typegpu.
scene.ts
import { cameras, clock, coroutines, finish, owner, passes, pipelines, targets, type Frame, type Surface,} from '@typegpu/kit';import { clamp } from 'math';import { d, type TgpuRoot } from 'typegpu';import { createPlatformer, STEP, type Score } from './game.ts';import { MAP, parseLevel } from './level.ts';import { createAvatar } from './avatar.ts';import { createCoins } from './coins.ts';import { createCritters } from './critters.ts';import { createEffects } from './effects.ts';import { createPaper } from './paper.ts';import { createScenery } from './scenery.ts';import { TEXELS } from './sprites.ts';/** View height in tiles. */const VIEW = 12;/** Vertical camera offset above the hero, in tiles. */const ABOVE = 1;/** Fixed steps a frame may catch up on, 0.1 s at `STEP`. */const MAX_STEPS = 12;export interface SceneOptions { /** The level, one string per row, as `parseLevel` reads it. Default `MAP`. */ readonly map?: readonly string[]; /** Receives the score at startup and whenever it changes. */ readonly onScore?: (score: Score) => void; /** Seconds at which a demo run, holding right, jumps. Default none, no demo. */ readonly demo?: readonly number[];}/** * Draws a platformer's pixel art into a low-resolution target, then prints it on paper cards under * a desk lamp at full resolution. Coins and critters are listed on the GPU, and dust and sparkles * are particles. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions = {}) { const { map = MAP, onScore = () => {} } = options; const level = parseLevel(map); const own = owner(); // Match the surface aspect ratio at a fixed texel height. An even width keeps each pixel's centre // inside a texel, since the camera snaps its centre to the texel grid const pixels = { get width() { return 2 * Math.round((surface.width / surface.height) * VIEW * TEXELS * 0.5); }, height: VIEW * TEXELS, }; const view = cameras.flat(root, { size: pixels, height: VIEW, snap: 1 / TEXELS, center: level.start, }); const art = own(targets.create(root, pixels, { color: { format: 'rgba8unorm', sampled: true } })); const scene = passes.create(root, art, { color: passes.color }).pipe(view.inject()); const time = root.createUniform(d.f32); const sim = clock.fixedStep({ step: STEP, maxSteps: MAX_STEPS }); const scheduler = own(coroutines.create()); const scenery = own(createScenery(root, { time, level })); const avatar = createAvatar(root, { time }); const coins = own(createCoins(root, { time, capacity: level.coins.length, scheduler })); const effects = own(createEffects(root, { camera: view, step: sim.step })); const critters = own( createCritters(root, { time, capacity: level.critters.length, step: STEP, avatar, dust: effects.dust, }), ); const drawn = [ scene.add(scenery.drawing), scene.add(coins.drawing), scene.add(critters.drawing), scene.add(effects.dust.drawing), scene.add(effects.sparks.drawing), scene.add(avatar.drawing), ]; // The paper is already in display colour space, so skip tone mapping and encoding const sheet = own( targets.create(root, surface, { color: { format: 'rgba8unorm', sampled: true } }), ); const print = passes.create(root, sheet, { color: passes.color }); const paper = own(createPaper(root, { art: () => art.textures.color, size: pixels })); const printed = [print.add(paper.drawing)]; const final = own( finish.create(root, { format: surface.format, source: () => sheet.textures.color, tone: null, encode: false, settings: { vignette: 0.18, dither: 0 }, }), ); await pipelines.compile([coins, critters, effects, scene, paper, print, final]); let score = ''; const game = createPlatformer(level, { coins, critters, effects, sim }, (state) => { const record = state.best === undefined ? '' : `\nbest ${state.best.toFixed(1)} s`; score = `coins ${state.coins} · stomps ${state.stomps}${record}`; onScore(state); }); const { hero, input } = game; const hold = (key: keyof typeof input) => ({ press: () => (input[key] = true), release: () => (input[key] = false), }); return { ui: { score: () => score, controls: { left: hold('left'), right: hold('right'), jump: { press() { hero.jump(); input.jump = true; }, release: () => (input.jump = false), }, }, }, /** Held inputs, set by the buttons `ui` binds. */ input, hero, coins, critters, frame(frame: Frame) { if (options.demo) { input.right = true; const t = frame.time; const held = options.demo.some((at) => t >= at && t < at + 0.3); if (held && !input.jump) hero.jump(); input.jump = held; } time.write(frame.time); scheduler.run(frame); art.fit(); sheet.fit(); coins.table.flush(); critters.table.flush(); const compute = frame.encoder.beginComputePass(); game.update(frame, compute); effects.spawn(compute); coins.compact(compute); critters.compact(compute); effects.compact(compute, sim.ahead); compute.end(); critters.contacts.capture(frame); // Queue writes precede frame commands, so every critter step reads the hero's whole path avatar.update(hero, sim.alpha, game.safe); const halfWidth = pixels.width / TEXELS / 2; view.goal.x = clamp(hero.position[0], halfWidth, level.width - halfWidth); view.goal.y = clamp(hero.position[1] + ABOVE, VIEW / 2, level.height - VIEW / 2); view.update(frame.dt); scene.record(frame, drawn); paper.record(frame); print.record(frame, printed); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}game.ts
import type { FixedStep, Frame } from '@typegpu/kit';import type { TgpuComputePass } from 'typegpu';import type { Coins } from './coins.ts';import type { Critters } from './critters.ts';import type { Effects } from './effects.ts';import { createHero, HALF, type Input } from './hero.ts';import type { Level } from './level.ts';/** Seconds per step. The hero and the critters move at 120 Hz. */export const STEP = 1 / 120;/** Invulnerability after restart, in seconds. Exceeds contact-readback latency. */const SAFE = 1.5;/** Fall distance below the level before restart, in tiles. */const FELL = 1;export interface Score { coins: number; stomps: number; /** Fastest time to the flag in seconds. */ best: number | undefined;}export interface PlatformerParts { readonly coins: Coins; readonly critters: Critters; readonly effects: Effects; /** Fixed step shared by the hero, critters and effects, with duration `STEP`. */ readonly sim: FixedStep;}/** * Runs toward the flag, collecting coins and stomping critters. Falls and other critter * contacts restart the run. */export function createPlatformer( level: Level, parts: PlatformerParts, onScore: (score: Score) => void,) { const { coins, critters, effects, sim } = parts; const hero = createHero(level); const input: Input = { left: false, right: false, jump: false }; const score: Score = { coins: 0, stomps: 0, best: undefined }; let now = 0; let startedAt = 0; let safeUntil = 0; const restart = () => { hero.place(...level.start); coins.reset(level); critters.reset(level); score.coins = 0; score.stomps = 0; startedAt = now; safeUntil = now + SAFE; onScore(score); }; restart(); return { hero, input, /** Whether restart invulnerability is active. */ get safe() { return now < safeUntil; }, /** * Steps the hero, critters and effects. Record after table flushes and before effect * spawning so dust and sparkles appear in the same frame. */ update(frame: Frame, pass: TgpuComputePass) { now = frame.time; // GPU contacts arrive one or two frames after detection const contact = critters.contacts.take(); if (contact?.stomps) { hero.bounce(); score.stomps += contact.stomps; onScore(score); } if (contact?.hurts && now >= safeUntil) restart(); let taken = 0; hero.start[0] = hero.position[0]; hero.start[1] = hero.position[1]; for (let steps = sim.advance(frame.dt); steps > 0; steps--) { const airborne = !hero.grounded; hero.step(input, STEP); if (airborne && hero.grounded) effects.puff(hero.position[0], hero.position[1] - HALF[1]); taken += coins.collect(hero.position, now, effects.sparkle); critters.step(pass); effects.step(pass); } if (taken > 0) { score.coins += taken; onScore(score); } if (hero.position[0] > level.flag[0]) { const run = now - startedAt; score.best = Math.min(score.best ?? run, run); restart(); } if (hero.position[1] < -FELL) restart(); }, };}level.ts
/** * One character per tile, top row first: `#` ground, `=` bricks, `o` a coin, `w` a walker, * `b` a bat, `@` the start and `F` the flag. */export const MAP = [ '', '', '', '', '', ' ooo ooooo', ' w b o o o b', ' o o o === b ===== b o o o', ' o o ===== o', ' ===== o o o o ==== ===== o o', ' ## ##', ' @ w w #### w w w ## w w ## F', '######################### ############# ################# ############################ ##########', '######################### ############# ################# ############################ ##########',];export const EMPTY = 0;export const GROUND = 1;export const BRICKS = 2;export const WALKER = 0;export const BAT = 1;type Point = readonly [x: number, y: number];export interface Spawn { readonly kind: typeof WALKER | typeof BAT; /** Centre of its tile. */ readonly at: Point; /** Walker travel limits between walls and floor edges. */ readonly patrol: Point;}export interface Tiles { readonly width: number; readonly height: number; /** Tile kinds, row by row from the bottom. */ readonly tiles: Uint32Array;}export interface Level extends Tiles { /** Object positions at tile centres. */ readonly start: Point; readonly flag: Point; readonly coins: readonly Point[]; readonly critters: readonly Spawn[];}const TILE_KINDS: Record<string, number> = { '#': GROUND, '=': BRICKS };/** Whether tile `x, y` blocks the hero. Level sides are walls, and the bottom is open. */export function solidAt(level: Tiles, x: number, y: number) { if (x < 0 || x >= level.width) return true; if (y < 0 || y >= level.height) return false; return level.tiles[y * level.width + x] !== EMPTY;}/** Reads tiles, marks and walker patrols from a map such as `MAP`. */export function parseLevel(map: readonly string[]): Level { const width = Math.max(...map.map((row) => row.length)); const height = map.length; const tiles = new Uint32Array(width * height); const marks: Record<string, Point[]> = {}; map.forEach((row, line) => { const y = height - 1 - line; for (let x = 0; x < row.length; x++) { const char = row[x]!; const kind = TILE_KINDS[char]; if (kind !== undefined) tiles[y * width + x] = kind; else if (char !== ' ') (marks[char] ??= []).push([x + 0.5, y + 0.5]); } }); const level: Tiles = { width, height, tiles }; /** Centre of the last tile from `x` in direction `step` that is open with floor below. */ const patrolEnd = (x: number, y: number, step: number) => { let end = Math.floor(x); while (!solidAt(level, end + step, y) && solidAt(level, end + step, y - 1)) end += step; return end + 0.5; }; const spawn = (kind: Spawn['kind']) => ([x, y]: Point): Spawn => ({ kind, at: [x, y], patrol: [patrolEnd(x, Math.floor(y), -1), patrolEnd(x, Math.floor(y), 1)], }); return { ...level, start: marks['@']?.[0] ?? [0.5, 0.5], flag: marks.F?.[0] ?? [width - 0.5, 0.5], coins: marks.o ?? [], critters: [...(marks.w ?? []).map(spawn(WALKER)), ...(marks.b ?? []).map(spawn(BAT))], };}avatar.ts
import { cameras, passes, unfilled, type Inject } from '@typegpu/kit';import { sdRoundedBox2d } from '@typegpu/sdf';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import { HALF, type Hero } from './hero.ts';import { eyes, quad, snapped, sprite as front, TEXELS, type ArtOptions, type Seen,} from './sprites.ts';const Avatar = d.struct({ /** Interpolated draw position between fixed steps. */ position: d.vec2f, velocity: d.vec2f, /** Hero positions before and after this frame's steps, used for swept contact tests. */ before: d.vec2f, after: d.vec2f,});/** The hero's state for shaders, such as the critters' contact test. */export const avatar = tgpu.accessor(Avatar, unfilled("the hero's state", 'avatar', 'createAvatar'));/** Changes less often than `Avatar`, so it has a uniform of its own. */const Sprite = d.struct({ facing: d.f32, /** Invulnerability flag, 1 while the hero cannot be hurt and 0 otherwise. */ blink: d.f32,});const BODY = d.vec3f(0.93, 0.36, 0.24);const LIGHT = d.vec3f(1, 0.62, 0.45);const OUTLINE = d.vec3f(0.42, 0.12, 0.12);/** Quad half-size in tiles, including room for stretching. */const REACH = 0.6;/** * Stretch per tile per second of upward velocity, and maximum stretch as a fraction of height. * Downward velocity squashes the body. */const STRETCH = { rate: 0.012, most: 0.15 };/** Blinks a second while the hero can't be hurt. */const BLINKS = 8;/** Eye size and centre offset forward and above the body centre. */const eyesAt = eyes({ apart: 0.09, radius: 0.07, pupil: 0.035, look: 0.025 });const EYES = { ahead: 0.1, up: 0.12 };/** How much the body stretches as it rises, or squashes as it falls. */const stretchAt = () => { 'use gpu'; return std.clamp(avatar.$.velocity.y * STRETCH.rate, -STRETCH.most, STRETCH.most);};/** The body's distance at `local`, its feet kept grounded while it stretches or squashes. */const bodyAt = (local: d.v2f) => { 'use gpu'; const stretch = stretchAt(); const half = d.vec2f(HALF[0] * (1 - stretch), HALF[1] * (1 + stretch)); return sdRoundedBox2d(local - d.vec2f(0, half.y - HALF[1]), half, 0.14);};const Corner = d.struct({ position: d.vec4f, local: d.vec2f });/** Sets `pair` to `x, y`. Returns whether it changed. */const set = (pair: [number, number], x: number, y: number) => { if (pair[0] === x && pair[1] === y) return false; pair[0] = x; pair[1] = y; return true;};/** Writes hero state to GPU buffers only when it changes and draws its sprite. */export function createAvatar(root: TgpuRoot, options: ArtOptions) { const { time } = options; const state = root.createUniform(Avatar); const written: Record<keyof d.Infer<typeof Avatar>, [number, number]> = { position: [0, 0], velocity: [0, 0], before: [0, 0], after: [0, 0], }; const sprite = root.createUniform(Sprite, { facing: 1, blink: 0 }); const shown = { facing: 1, blink: 0 }; const inject = (): Inject => (cfg) => cfg.with(avatar, state); const drawing: passes.Drawing<typeof Corner, Seen> = { label: 'hero', Vertex: Corner, at: (vertexIndex) => { 'use gpu'; const local = quad.vertexAt(vertexIndex).position.xz * REACH; const world = snapped(avatar.$.position) + local; return Corner({ position: cameras.current.$.viewProjection * d.vec4f(world, 0, 1), local }); }, cutout: (corner) => { 'use gpu'; if (sprite.$.blink > 0 && std.fract(time.$ * BLINKS) < 0.5) return true; return bodyAt(corner.local) > 0; }, surfaceOf: (corner) => { 'use gpu'; const local = corner.local; const facing = sprite.$.facing; const stretch = stretchAt(); const eye = eyesAt(local - d.vec2f(facing * EYES.ahead, EYES.up + stretch * 0.3), facing); if (eye.w > 0) return front(eye.xyz); // A texel of outline, light along the top left and a darker band at the feet const inside = -bodyAt(local) * TEXELS; if (inside < 1) return front(OUTLINE); if (inside < 2.2 && local.y - local.x > 0.05) return front(LIGHT); return front(BODY * (0.8 + 0.2 * std.step(-0.2, local.y))); }, color: passes.shaded, inject, draw: (pass) => pass.draw(quad.vertexCount), }; return { inject, drawing, /** * Interpolates the hero by `alpha` between fixed steps and records its swept path. Writes * position, facing and blink state only when they change. */ update(hero: Hero, alpha: number, blink: boolean) { const { position, previous, start, velocity } = hero; const x = previous[0] + (position[0] - previous[0]) * alpha; const y = previous[1] + (position[1] - previous[1]) * alpha; let moved = set(written.position, x, y); moved = set(written.velocity, velocity[0], velocity[1]) || moved; moved = set(written.before, start[0], start[1]) || moved; moved = set(written.after, position[0], position[1]) || moved; if (moved) state.write(written); const flicker = blink ? 1 : 0; if (hero.facing !== shown.facing || flicker !== shown.blink) { shown.facing = hero.facing; shown.blink = flicker; sprite.write(shown); } }, };}coins.ts
import { cameras, coroutines, liveLists, passes, table, type Coroutines, type RowId,} from '@typegpu/kit';import { d, std, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { HALF } from './hero.ts';import type { Level } from './level.ts';import { quad, snapped, sprite, type ArtOptions, type Seen } from './sprites.ts';const Coin = table.define('Coin', { host: { position: d.vec2f, /** Collection time in seconds, or -1 before collection. */ takenAt: d.f32, },});/** Seconds a taken coin flies up before it goes. */export const VANISH = 0.4;/** Tiles a second a taken coin flies up. */const RISE = 4;/** A coin's radius, in tiles. */const RADIUS = 0.3;/** How far a coin's centre may be from the hero's for the hero to take it, in tiles. */const REACH = [HALF[0] + RADIUS, HALF[1] + RADIUS] as const;/** * Spin rate in radians per second, phase change per tile in radians, and minimum width as a * fraction of face-on width. */const SPIN = { rate: 3, stagger: 0.7, edge: 0.25 };const GOLD = d.vec3f(1, 0.8, 0.22);const RIM = d.vec3f(0.78, 0.47, 0.1);const SHINE = d.vec3f(1, 0.97, 0.8);const Corner = d.struct({ position: d.vec4f, local: d.vec2f });export interface CoinsOptions extends ArtOptions { readonly capacity: number; /** Runs taken coins' flights. */ readonly scheduler: Coroutines;}/** Stores coins in a CPU-written table and lists them with live lists. */export function createCoins(root: TgpuRoot, options: CoinsOptions) { const { capacity, time, scheduler } = options; const coins = table.create(root, Coin, { capacity, defaults: { takenAt: -1 } }); const live = liveLists.create(root, { ...coins.live(), draws: [quad] }); const taken = coins.fields.takenAt; /** Coins not yet taken, bottom row first and left to right in a row. */ let waiting: { id: RowId<'Coin'>; x: number; y: number }[] = []; const drawing: passes.Drawing<typeof Corner, Seen> = { label: 'coins', Vertex: Corner, at: (vertexIndex, instanceIndex) => { 'use gpu'; const slot = live.slot(0, instanceIndex); const corner = quad.vertexAt(vertexIndex).position.xz; const center = Coin.position(slot); const takenAt = Coin.takenAt(slot); const age = std.select(0, time.$ - takenAt, takenAt >= 0); const size = RADIUS * std.saturate(1 - age / VANISH); // Retain some width so the coin remains visible edge-on const turn = std.abs(std.cos(time.$ * SPIN.rate + center.x * SPIN.stagger)); const across = std.max(turn, SPIN.edge); const world = snapped(center + d.vec2f(0, age * RISE)) + corner * d.vec2f(across, 1) * size; return Corner({ position: cameras.current.$.viewProjection * d.vec4f(world, 0, 1), local: corner, }); }, cutout: (corner) => { 'use gpu'; return std.length(corner.local) > 1; }, surfaceOf: (corner) => { 'use gpu'; const local = corner.local; if (std.length(local) > 0.72) return sprite(RIM); if (std.abs(local.x + local.y * 0.5 + 0.2) < 0.18) return sprite(SHINE); return sprite(GOLD); }, color: passes.shaded, inject: () => (cfg) => cfg.pipe(coins.inject()).pipe(live.inject()), draw: (pass) => live.draw(pass, 0), }; function* vanish(id: RowId<'Coin'>) { yield* coroutines.seconds(VANISH); // A reset can remove the coin before its animation ends if (coins.has(id)) coins.remove(id); } const destroy = () => { coins.destroy(); live.destroy(); }; return { table: coins, drawing, initAsync: () => live.initAsync(), reset(level: Level) { coins.clear(); waiting = level.coins.map(([x, y]) => ({ id: coins.add({ position: [x, y] }), x, y })); waiting.sort((a, b) => a.y - b.y || a.x - b.x); }, /** * Collects coins touching the hero at `position`. Calls `onTake` with each coin position * and returns the count. */ collect( position: readonly [number, number], now: number, onTake?: (x: number, y: number) => void, ) { const [x, y] = position; let count = 0; for (let i = 0; i < waiting.length; i++) { const coin = waiting[i]!; if (Math.abs(coin.x - x) >= REACH[0] || Math.abs(coin.y - y) >= REACH[1]) continue; waiting.splice(i--, 1); taken.set(coin.id, now); scheduler.start(vanish(coin.id)); onTake?.(coin.x, coin.y); count++; } return count; }, /** Lists coins for drawing. Record after `table.flush()`. */ compact(pass: TgpuComputePass) { live.compact(pass); }, destroy, [Symbol.dispose]: destroy, };}export type Coins = ReturnType<typeof createCoins>;critters.ts
import { cameras, events, liveLists, Particle, passes, table, type Injectable, type Particles,} from '@typegpu/kit';import { randf } from '@typegpu/noise';import { sdDisk, sdRoundedBox2d } from '@typegpu/sdf';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';import { avatar } from './avatar.ts';import { DUST } from './effects.ts';import { HALF } from './hero.ts';import { BAT, WALKER, type Level } from './level.ts';import { eyes, quad, snapped, sprite, TEXELS, type ArtOptions, type Seen } from './sprites.ts';const Critter = table.define('Critter', { host: { kind: d.u32, home: d.vec2f, /** Horizontal turning bounds in tiles. */ patrol: d.vec2f, /** Loop phase offset in seconds. */ phase: d.f32, }, device: { position: d.vec2f, heading: d.f32, /** Stomp time in seconds, or -1 before a stomp. */ squashedAt: d.f32, },});const WORKGROUP = 64;/** Seconds a squashed critter stays flat before it goes. */const VANISH = 0.5;/** A squashed critter's width and height, as shares of its own. */const FLAT = d.vec2f(1.3, 0.3);/** Tiles a second a walker walks. */const WALK = 1.6;/** Bat loop frequencies in radians per second and horizontal and vertical amplitudes in tiles. */const FLIGHT = { pace: 0.9, reach: 2.5, bob: 2.3, rise: 0.6 };/** Radians a second a bat's wings beat. */const WINGBEAT = 18;/** Loop phase offset per critter in seconds. Keeps bats from flying in step. */const STAGGER = 1.7;/** Half a critter's size, in tiles. */const SIZE = 0.4;/** Overlap margin in tiles. A graze within this margin does not count as contact. */const GRAZE = 0.08;/** Contact distance between hero and critter centres. */const TOUCH = d.vec2f(HALF[0] + SIZE - GRAZE, HALF[1] + SIZE - GRAZE);/** * Stomp dust count, horizontal and vertical velocity spread and lift in tiles per second, * minimum and maximum lifetime in seconds, and size in tiles. */const KICK = { motes: 8, spread: d.vec2f(5, 2), lift: 1.5, life: [0.3, 0.5], size: 0.3 } as const;const MOTE = d.vec4f(...DUST);const SHELL = d.vec3f(0.42, 0.25, 0.6);const WING = d.vec3f(0.22, 0.16, 0.32);/** Eye size and centre offset forward and above the critter centre. */const eyesAt = eyes({ apart: 0.2, radius: 0.16, pupil: 0.08, look: 0.06 });const EYES = { ahead: 0.2, up: 0.15 };const OUTLINE = d.vec3f(0.16, 0.09, 0.22);/** A walker's shell distance at `local`. */const shellAt = (local: d.v2f) => { 'use gpu'; return sdRoundedBox2d(local + d.vec2f(0, 0.15), d.vec2f(0.85, 0.7), 0.55);};/** What a critter's pixels read from its quad: the corner in quad units, its kind and facing. */const Corner = d.struct({ position: d.vec4f, local: d.vec2f, kind: d.u32, facing: d.f32 });/** The part of [0, 1] where `origin + path * t` lies strictly between `low` and `high`. */const slab = (origin: number, path: number, low: number, high: number) => { 'use gpu'; if (std.abs(path) < 1e-6) { if (origin > low && origin < high) return d.vec2f(0, 1); return d.vec2f(1, 0); } const a = (low - origin) / path; const b = (high - origin) / path; return d.vec2f(std.max(std.min(a, b), 0), std.min(std.max(a, b), 1));};/** * First contact along the hero's frame path, from 0 to 1, or -1 for no contact. Every step * reads the whole path to prevent fast motion skipping critters. */const touchAt = (centre: d.v2f) => { 'use gpu'; const origin = avatar.$.before; const path = avatar.$.after - origin; const x = slab(origin.x, path.x, centre.x - TOUCH.x, centre.x + TOUCH.x); const y = slab(origin.y, path.y, centre.y - TOUCH.y, centre.y + TOUCH.y); const enter = std.max(x.x, y.x); if (enter >= std.min(x.y, y.y)) return d.f32(-1); return enter;};export interface CrittersOptions extends ArtOptions { readonly capacity: number; /** Seconds per `step`. */ readonly step: number; /** Fills `avatar` for contact detection. */ readonly avatar: Injectable; /** Takes the dust a stomp kicks up. */ readonly dust: Pick<Particles, 'emit' | 'inject'>;}/** Moves walkers and bats on the GPU and detects contacts with the hero. */export function createCritters(root: TgpuRoot, options: CrittersOptions) { const { capacity, step, time, dust } = options; const critters = table.create(root, Critter, { capacity, defaults: { heading: 1, squashedAt: -1 }, }); const live = liveLists.create(root, { ...critters.live(), draws: [quad] }); // Read contacts every frame to minimise stomp-bounce latency const contacts = events.create(root, ['stomps', 'hurts'], { hz: 60 }); /** Emits stomp dust at `at`, seeded by the critter's index. */ const kickUp = (at: d.v2f, index: number) => { 'use gpu'; randf.seed2(d.vec2f(d.f32(index), time.$)); for (let k = 0; k < KICK.motes; k++) { const kick = randf.inUnitCircle() * KICK.spread + d.vec2f(0, KICK.lift); dust.emit( Particle({ position: d.vec3f(at, 0), age: 0, velocity: d.vec3f(kick, 0), life: std.mix(KICK.life[0], KICK.life[1], randf.sample()), color: MOTE, size: KICK.size, seed: randf.sample(), }), ); } }; const stepKernel = tgpu.computeFn({ workgroupSize: [WORKGROUP], in: { gid: d.builtin.globalInvocationId }, })(({ gid }) => { 'use gpu'; const i = gid.x; if (!Critter.alive(i)) return; const critter = Critter.device.$[i]!; if (critter.squashedAt >= 0) return; if (Critter.kind(i) === WALKER) { const patrol = Critter.patrol(i); const x = critter.position.x + critter.heading * WALK * step; if (x < patrol.x || x > patrol.y) critter.heading = -critter.heading; critter.position.x = std.clamp(x, patrol.x, patrol.y); } else { const t = time.$ + Critter.phase(i); const across = std.sin(t * FLIGHT.pace) * FLIGHT.reach; critter.position = Critter.home(i) + d.vec2f(across, std.sin(t * FLIGHT.bob) * FLIGHT.rise); critter.heading = std.sign(std.cos(t * FLIGHT.pace)); } const enter = touchAt(critter.position); if (enter < 0) return; const offset = std.mix(avatar.$.before, avatar.$.after, enter) - critter.position; if (avatar.$.velocity.y < 0 && offset.y > SIZE) { critter.squashedAt = time.$; contacts.add.stomps(1); kickUp(critter.position, i); } else { contacts.add.hurts(1); } }); /** A bat's wing distance at `local`, beating by the clock. */ const wingAt = (local: d.v2f) => { 'use gpu'; const beat = std.sin(time.$ * WINGBEAT) * 0.3; const wing = d.vec2f(std.abs(local.x) - 0.6, local.y - beat * (std.abs(local.x) - 0.3)); return sdRoundedBox2d(wing, d.vec2f(0.38, 0.12), 0.1); }; const drawing: passes.Drawing<typeof Corner, Seen> = { label: 'critters', Vertex: Corner, flat: ['facing'], at: (vertexIndex, instanceIndex) => { 'use gpu'; const slot = live.slot(0, instanceIndex); const corner = quad.vertexAt(vertexIndex).position.xz; // Keep squashed critters on their feet while they shrink const squashedAt = Critter.squashedAt(slot); const flat = squashedAt >= 0; const fade = std.saturate(1 - (time.$ - squashedAt) / VANISH); const scale = std.select(d.vec2f(1), FLAT * fade, flat); const local = corner * scale * SIZE + d.vec2f(0, (scale.y - 1) * SIZE); const world = snapped(Critter.position(slot)) + local; return Corner({ position: cameras.current.$.viewProjection * d.vec4f(world, 0, 1), local: corner, kind: Critter.kind(slot), facing: Critter.heading(slot), }); }, cutout: (corner) => { 'use gpu'; if (corner.kind === BAT) return wingAt(corner.local) > 0 && sdDisk(corner.local, 0.5) > 0; return shellAt(corner.local) > 0; }, surfaceOf: (corner) => { 'use gpu'; const local = corner.local; if (corner.kind === BAT && wingAt(local) < 0) return sprite(WING); const facing = corner.facing; const eye = eyesAt(local - d.vec2f(facing * EYES.ahead, EYES.up), facing); if (eye.w > 0) return sprite(eye.xyz); // Walkers wear a texel of outline, which bats' wings already give them if (corner.kind !== BAT && shellAt(local) > -1 / (TEXELS * SIZE)) return sprite(OUTLINE); return sprite(SHELL * (0.75 + 0.25 * std.step(-0.3, local.y))); }, color: passes.shaded, inject: () => (cfg) => cfg.pipe(critters.inject()).pipe(live.inject()), draw: (pass) => live.draw(pass, 0), }; const stepPipeline = root .pipe(critters.inject()) .pipe(options.avatar.inject()) .pipe(dust.inject()) .pipe(contacts.inject()) .createComputePipeline({ compute: stepKernel }); const destroy = () => { critters.destroy(); live.destroy(); contacts.destroy(); }; return { table: critters, /** Stomps and hurts the steps count. Capture them after the steps each frame. */ contacts, drawing, initAsync: () => Promise.all([live.initAsync(), stepPipeline.initAsync()]), reset(level: Level) { critters.clear(); level.critters.forEach(({ kind, at, patrol }, i) => { critters.add({ kind, home: at, patrol, phase: i * STAGGER, position: at }); }); }, /** Records one step after `table.flush()`. */ step(pass: TgpuComputePass) { const workgroups = Math.ceil(critters.span / WORKGROUP); if (workgroups > 0) { pass.setPipeline(stepPipeline); pass.dispatchWorkgroups(workgroups); } }, /** Lists critters after simulation steps. */ compact(pass: TgpuComputePass) { live.compact(pass); }, destroy, [Symbol.dispose]: destroy, };}export type Critters = ReturnType<typeof createCritters>;effects.ts
import { particles, type CameraBase, type Particle } from '@typegpu/kit';import { d, std, type TgpuComputePass, type TgpuRoot } from 'typegpu';/** Dust colour and opacity. Opaque dust lies on the front card, beside the sprites. */export const DUST = [0.82, 0.76, 0.66, 1] as const;const GOLD = [1, 0.85, 0.35, 1] as const;/** Hard-edged shrinking dot matching the pixel art. */const dot = (mote: Particle, local: d.v2f) => { 'use gpu'; const left = 1 - mote.age / mote.life; if (std.length(local) > left) return d.vec4f(); return d.vec4f(mote.color.xyz * mote.color.w, mote.color.w);};/** Additive four-pointed sparkle. */const twinkle = (spark: Particle, local: d.v2f) => { 'use gpu'; const across = std.abs(local); const star = std.max(1 - across.x * 4 - across.y, 1 - across.y * 4 - across.x); const light = std.saturate(star * 2) * (1 - spark.age / spark.life); return d.vec4f(spark.color.xyz * light, 0);};export interface EffectsOptions { /** The length of a step in seconds; see `particles.Options.step`. */ readonly step: number; readonly camera: CameraBase;}/** Dust kicked up by landings and stomps, and sparkles from taken coins. */export function createEffects(root: TgpuRoot, options: EffectsOptions) { const { camera, step } = options; const shared = { step, dimensions: 2 } as const; const dust = particles.create(root, camera, { ...shared, capacity: 512, blend: 'over', shade: dot, settings: { gravity: [0, 2, 0], drag: 6 }, }); const sparks = particles.create(root, camera, { ...shared, capacity: 256, blend: 'add', shade: twinkle, settings: { gravity: [0, -18, 0], drag: 1.5 }, }); const destroy = () => { dust.destroy(); sparks.destroy(); }; return { /** Kernels piped with `dust.inject()` can `dust.emit` motes of their own. */ dust, /** Add its drawing after the dust's. */ sparks, initAsync: () => Promise.all([dust.initAsync(), sparks.initAsync()]), /** Emits dust at the hero's feet. */ puff(x: number, y: number) { dust.burst({ count: 6, position: [x, y, 0], area: [0.25, 0, 0], scatter: 2.5, size: 0.22, life: 0.35, color: DUST, }); }, sparkle(x: number, y: number) { sparks.burst({ count: 10, position: [x, y, 0], velocity: [0, 4, 0], scatter: 7, size: 0.5, life: 0.5, color: GOLD, }); }, /** Spawns queued bursts. Record once per frame before `compact`. */ spawn(pass: TgpuComputePass) { dust.spawn(pass); sparks.spawn(pass); }, step(pass: TgpuComputePass) { dust.step(pass); sparks.step(pass); }, compact(pass: TgpuComputePass, ahead: number) { dust.compact(pass, ahead); sparks.compact(pass, ahead); }, destroy, [Symbol.dispose]: destroy, };}export type Effects = ReturnType<typeof createEffects>;paper.ts
import { memo, passes, targets, type Frame, type SizeSource } from '@typegpu/kit';import { d, std, tgpu, type TgpuRoot, type TgpuTexture } from 'typegpu';import { CARDS, cardOf, Seen } from './sprites.ts';type Picture = TgpuTexture & { usableAsSampled: true };const Art = tgpu.bindGroupLayout({ art: { texture: d.texture2d(d.f32) }, shadows: { texture: d.texture2d(d.f32) }, smooth: { sampler: 'filtering' },});const Source = tgpu.bindGroupLayout({ source: { texture: d.texture2d(d.f32) } });/** Where the lamp's shadows fall, in texels per texel of height, right and down the picture. */const FALL = d.vec2f(0.75, 0.9);/** * Texels of height between the cards that cast shadows and the cards that take them, in the * channels of the shadow masks: on the sky, on the far hills, on the near hills and on the level. * Sprites stand closest to the level, and their feet closer still. */const GAPS = { sky: 4.5, far: 4, near: 4, level: 3, feet: 1 };/** The penumbra of each mask channel, as a Gaussian's standard deviation in texels. */const SOFTNESS = [2.4, 2, 2, 1.2];/** Texels the blur reaches to each side, three of the widest deviations. */const REACH = 7;/** The blur's taps: an offset in texels and each mask channel's Gaussian weight, summing to 1. */const TAPS = (() => { const offsets = Array.from({ length: 2 * REACH + 1 }, (_, k) => k - REACH); const curves = offsets.map((i) => SOFTNESS.map((s) => Math.exp((i * i * -0.5) / (s * s)))); const totals = SOFTNESS.map((_, c) => curves.reduce((total, curve) => total + curve[c]!, 0)); return offsets.map((offset, k) => ({ offset, weights: d.vec4f( ...(curves[k]!.map((weight, c) => weight / totals[c]!) as [number, number, number, number]), ), }));})();/** How dark and how cool a full shadow makes the card. */const SHADE = d.vec3f(0.52, 0.56, 0.7);/** Width of a lit cut edge in texels, and its strength on top and left edges. */const EDGE = { width: 0.33, top: 0.5, left: 0.28 };/** Paper is white inside, so lit cut edges show pale. */const CORE = d.vec3f(1, 0.98, 0.93);/** The lamp's warm pool: centre in UV coordinates, falloff, and light at the rim and centre. */const LAMP = { at: d.vec2f(0.38, 0.1), falloff: 1.1, rim: 0.93, centre: 1.04 };const WARM = d.vec3f(1.02, 1, 0.96);/** * Which cards cast shadows on each mask channel's cards: everything in front of the sky, of the * far hills, of the near hills and of the level. Clouds take the far hills' channel, since the * hills below them cast nothing up onto them. */const casters = (card: number) => { 'use gpu'; return std.step(d.vec4f(0.5, 2.5, 3.5, 4.5), d.vec4f(card));};/** Blurs `read` along `direction` with each channel's Gaussian. */const blurred = (screen: d.Infer<typeof passes.ScreenVertex>, direction: d.v2i, art: boolean) => { 'use gpu'; const size = d.vec2i(std.textureDimensions(Source.$.source)); const texel = d.vec2i(std.floor(screen.uv * d.vec2f(size))); let sum = d.vec4f(); for (const tap of tgpu.unroll(TAPS)) { const at = std.clamp(texel + direction * tap.offset, d.vec2i(), size - 1); const read = std.textureLoad(Source.$.source, at, 0); let value = d.vec4f(read); if (art) value = casters(std.round(cardOf(read.w))); sum += tap.weights * value; } return Seen({ color: sum });};/** Blurs the casters of each channel across the art. */const castersAcross = (screen: d.Infer<typeof passes.ScreenVertex>) => { 'use gpu'; return blurred(screen, d.vec2i(1, 0), true);};/** Blurs the masks blurred across down the art, which makes their blur round. */const castersDown = (screen: d.Infer<typeof passes.ScreenVertex>) => { 'use gpu'; return blurred(screen, d.vec2i(0, 1), false);};/** The card at texel `at` of the art. */const cardAt = (at: d.v2f) => { 'use gpu'; const texel = d.vec2i(std.floor(at)); return std.round(cardOf(std.textureLoad(Art.$.art, texel, 0).w));};/** The blurred shadow masks around texel position `at`. */const masksAt = (at: d.v2f) => { 'use gpu'; const size = d.vec2f(std.textureDimensions(Art.$.shadows)); return std.textureSampleLevel(Art.$.shadows, Art.$.smooth, at / size, 0);};/** The shadow that the cards in front of `card` cast on it at texel position `at`. */const shadowOn = (at: d.v2f, card: number) => { 'use gpu'; if (card === CARDS.sky) return masksAt(at - FALL * GAPS.sky).x; if (card <= CARDS.far) return masksAt(at - FALL * GAPS.far).y; if (card === CARDS.near) return masksAt(at - FALL * GAPS.near).z; // Where sprites stand on the level, a tighter shadow grounds their feet const cast = masksAt(at - FALL * GAPS.level).w; return std.max(cast, masksAt(at - FALL * GAPS.feet).w);};/** * Samples the art with square texels at any scale, blending one-pixel seams, so a fractional * upscale keeps every texel the same width. */const crisp = (at: d.v2f) => { 'use gpu'; const size = d.vec2f(std.textureDimensions(Art.$.art)); const seam = std.floor(at + 0.5); const blend = std.clamp((at - seam) / std.fwidth(at), d.vec2f(-0.5), d.vec2f(0.5)); return std.textureSampleLevel(Art.$.art, Art.$.smooth, (seam + blend) / size, 0).xyz;};/** The art printed on its card, shaded by the cards in front and lit by the lamp. */const surfaceOf = (screen: d.Infer<typeof passes.ScreenVertex>) => { 'use gpu'; const size = d.vec2f(std.textureDimensions(Art.$.art)); const at = screen.uv * size; const card = cardAt(at); let color = crisp(at); if (card < CARDS.sprites) color *= std.mix(d.vec3f(1), SHADE, shadowOn(at, card)); // A cut edge catches the lamp where a card lower down lies just above or to the left if (card > 0) { let lit = d.f32(0); if (cardAt(at - d.vec2f(0, EDGE.width)) < card) lit = EDGE.top; else if (cardAt(at - d.vec2f(EDGE.width, 0)) < card) lit = EDGE.left; color = std.mix(color, CORE, lit); } const aspect = size.x / size.y; const off = (screen.uv - LAMP.at) * d.vec2f(aspect, 1); const pool = std.exp(-std.dot(off, off) * LAMP.falloff); color *= WARM * std.mix(LAMP.rim, LAMP.centre, pool); return Seen({ color: d.vec4f(color, 1) });};export interface PaperOptions { /** The low-resolution art, which stores each pixel's card in alpha. */ readonly art: () => Picture; /** The art's size, which the shadow masks follow. */ readonly size: SizeSource;}/** * Prints the low-resolution art on paper cards under a desk lamp: each card casts a soft shadow * on the cards behind, its cut edges catch the light, and the lamp leaves a warm pool. The * shadows blur at the art's resolution, so the print reads two texels a pixel. */export function createPaper(root: TgpuRoot, options: PaperOptions) { const { art, size } = options; const mask = { format: 'rgba16float', sampled: true } as const; const across = targets.create(root, size, { color: mask }); const down = targets.create(root, size, { color: mask }); const smooth = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); const sourceOf = memo((texture: Picture) => root.createBindGroup(Source, { source: texture })); const blur = ( label: string, shade: (screen: d.Infer<typeof passes.ScreenVertex>) => Seen, read: () => Picture, ): passes.Drawing<typeof passes.ScreenVertex, Seen> => ({ label, ...passes.fullScreen, surfaceOf: shade, color: passes.shaded, draw(pass) { pass.setBindGroup(sourceOf(read())); pass.draw(3); }, }); const blurs = [ passes.create(root, across, { color: passes.color }), passes.create(root, down, { color: passes.color }), ] as const; const drawn = [ [blurs[0].add(blur('shadows across', castersAcross, art))], [blurs[1].add(blur('shadows down', castersDown, () => across.textures.color))], ] as const; const printOf = memo((picture: Picture, shadows: Picture) => root.createBindGroup(Art, { art: picture, shadows, smooth }), ); const drawing: passes.Drawing<typeof passes.ScreenVertex, Seen> = { label: 'paper', ...passes.fullScreen, surfaceOf, color: passes.shaded, draw(pass) { pass.setBindGroup(printOf(art(), down.textures.color)); pass.draw(3); }, }; const destroy = () => { across.destroy(); down.destroy(); }; return { /** Add it to the pass that prints the paper, after `record` in each frame. */ drawing, initAsync: () => Promise.all(blurs.map((pass) => pass.initAsync())), /** Blurs the shadow masks from the art drawn this frame. */ record(frame: Frame) { across.fit(); down.fit(); blurs[0].record(frame, drawn[0]); blurs[1].record(frame, drawn[1]); }, destroy, [Symbol.dispose]: destroy, };}scenery.ts
import { cameras, passes } from '@typegpu/kit';import { perlin2d, randf } from '@typegpu/noise';import { d, std, type TgpuRoot } from 'typegpu';import { BRICKS, EMPTY, GROUND, type Level } from './level.ts';import { CARDS, onCard, Seen, TEXELS, type ArtOptions } from './sprites.ts';const SKY = d.vec3f(0.36, 0.6, 0.88);const HAZE = d.vec3f(0.8, 0.88, 0.92);const CLOUD = d.vec3f(0.98, 0.97, 0.94);const CLOUD_BELLY = d.vec3f(0.83, 0.87, 0.93);const FAR = d.vec3f(0.56, 0.71, 0.8);const NEAR = d.vec3f(0.36, 0.62, 0.47);const NEAR_CREST = d.vec3f(0.5, 0.74, 0.5);const FAR_CREST = d.vec3f(0.68, 0.8, 0.86);const GRASS = d.vec3f(0.38, 0.76, 0.3);const GRASS_TIP = d.vec3f(0.62, 0.88, 0.4);const GRASS_ROOT = d.vec3f(0.24, 0.5, 0.2);const DIRT = d.vec3f(0.58, 0.38, 0.23);const PEBBLE = d.vec3f(0.44, 0.27, 0.16);const BRICK = d.vec3f(0.8, 0.42, 0.28);const BRICK_EDGE = d.vec3f(0.93, 0.6, 0.42);const MORTAR = d.vec3f(0.45, 0.23, 0.18);const POLE = d.vec3f(0.9, 0.9, 0.85);const PENNANT = d.vec3f(0.95, 0.3, 0.35);/** Cloud, far-hill and near-hill parallax factors relative to camera movement. */const PARALLAX = { clouds: 0.9, far: 0.7, near: 0.45 };/** * Cumulus: the range of their flat bases above the horizon, the tiles between banks, the share of * clear sky between banks, and the smallest and largest puff in tiles. */const CLOUDS = { base: [3.2, 5.2], spacing: 8, clear: 0.2, size: [0.8, 1.6] } as const;/** Tiles a second the clouds drift. */const DRIFT = 0.3;/** Radians a second the pennant waves. */const FLUTTER = 6;const TEXEL = 1 / TEXELS;/** Moves a layer by whole texels, so its art keeps its pixels as the camera pans. */const shift = (offset: d.v2f) => { 'use gpu'; return std.round(offset * TEXELS) / TEXELS;};/** Dirt and pebbles, under a lumpy grass edge where `open` says the tile above is empty. */const ground = (world: d.v2f, open: boolean) => { 'use gpu'; const texel = std.floor(world * TEXELS); const depth = std.floor((std.ceil(world.y) - world.y) * TEXELS); const lumps = perlin2d.sample(texel * 0.21); if (open) { const grass = 3 + std.step(0, lumps) * 2; if (depth < 1) return GRASS_TIP; if (depth < grass - 1) return GRASS; if (depth < grass) return GRASS_ROOT; } return std.mix(DIRT, PEBBLE, std.step(0.35, lumps));};/** Bricks in a running bond, two courses a tile, mortar along their bottoms and left ends. */const bricks = (world: d.v2f) => { 'use gpu'; const row = std.floor(world.y * 2); const run = world.x + (row % 2) * 0.5; const local = std.fract(d.vec2f(run, world.y * 2)); if (local.x < TEXEL || local.y < 2 * TEXEL) return MORTAR; // A lit texel along each brick's top and left, as if each one were bevelled if (local.y > 1 - 2 * TEXEL || local.x < 2 * TEXEL) return BRICK_EDGE; return BRICK * (0.9 + 0.1 * perlin2d.sample(d.vec2f(std.floor(run), row) * 0.71));};/** Whether `at` lies in a cumulus: puffs of a size each bank picks, on a flat base. */const cloudAt = (at: d.v2f) => { 'use gpu'; if (at.y < CLOUDS.base[0]) return false; const cell = std.floor(at.x / CLOUDS.spacing); // A bank may reach into the next cell, so test this cell's and the one before for (let k = -1; k <= 0; k++) { const bank = cell + d.f32(k); randf.seed(bank * 0.37 + 11); if (randf.sample() < CLOUDS.clear) continue; const size = std.mix(CLOUDS.size[0], CLOUDS.size[1], randf.sample()); const middle = (bank + 0.3 + randf.sample() * 0.3) * CLOUDS.spacing; const base = std.mix(CLOUDS.base[0], CLOUDS.base[1], randf.sample()); if (at.y < base) continue; for (let puff = 0; puff < 4; puff++) { const across = (d.f32(puff) - 1.3 + randf.sample() * 0.4) * size * 0.9; const radius = size * std.mix(0.45, 0.85, randf.sample()) * (1 - std.abs(across) / (size * 3)); const centre = d.vec2f(middle + across, base + radius * 0.25); const offset = (at - centre) * d.vec2f(0.8, 1); if (std.dot(offset, offset) < radius * radius) return true; } } return false;};export interface SceneryOptions extends ArtOptions { readonly level: Level;}/** * Shades sky, clouds, hills, tiles and flag per texel in one full-screen pass, each on its card. */export function createScenery(root: TgpuRoot, options: SceneryOptions) { const { level, time } = options; const { width, height } = level; const flag = d.vec2f(level.flag[0] - 0.5, level.flag[1] - 0.5); const tiles = root.createReadonly(d.arrayOf(d.u32, width * height), [...level.tiles]); const tileAt = (cell: d.v2i) => { 'use gpu'; if (cell.x < 0 || cell.x >= width) return d.u32(GROUND); if (cell.y < 0 || cell.y >= height) return d.u32(EMPTY); return tiles.$[cell.y * width + cell.x]!; }; /** The cards behind the level, which scroll slower than it. */ const backdrop = (world: d.v2f, eye: d.v2f) => { 'use gpu'; const near = world - shift(eye * PARALLAX.near); const rise = 1 + perlin2d.sample(d.vec2f(near.x * 0.2, 7.5)) * 1.5; if (near.y < rise) { // Darker toward the foot of the hills, in whole texel rows const below = std.floor((rise - near.y) * TEXELS) * TEXEL; if (below < 2 * TEXEL) return d.vec4f(NEAR_CREST, onCard(CARDS.near)); return d.vec4f(NEAR * (1 - std.min(below, 3) * 0.05), onCard(CARDS.near)); } const far = world - shift(eye * PARALLAX.far); const crest = 2.5 + perlin2d.sample(d.vec2f(far.x * 0.12, 1.5)) * 2; if (far.y < crest) { const below = std.floor((crest - far.y) * TEXELS) * TEXEL; if (below < 2 * TEXEL) return d.vec4f(FAR_CREST, onCard(CARDS.far)); return d.vec4f(std.mix(FAR, HAZE, std.min(below, 4) * 0.06), onCard(CARDS.far)); } const drift = world - shift(eye * PARALLAX.clouds - d.vec2f(time.$ * DRIFT, 0)); if (cloudAt(drift)) { if (!cloudAt(drift - d.vec2f(0, 2 * TEXEL))) return d.vec4f(CLOUD_BELLY, onCard(CARDS.clouds)); return d.vec4f(CLOUD, onCard(CARDS.clouds)); } const sky = std.mix(HAZE, SKY, std.saturate((world.y - eye.y) / 6 + 0.5)); return d.vec4f(sky, onCard(CARDS.sky)); }; /** The flag's pole and waving pennant, or zero alpha beside them. */ const flagAt = (world: d.v2f) => { 'use gpu'; const p = world - flag; if (std.abs(p.x - 0.5) < TEXEL && p.y < 3.2 && p.y > 0) return d.vec4f(POLE, 1); const along = p.x - 0.56; const wave = std.sin(time.$ * FLUTTER - along * 5) * 0.06 * along; if (along > 0 && std.abs(p.y - 2.75 - wave) < 0.32 * (1 - along / 0.9)) { return d.vec4f(PENNANT, 1); } return d.vec4f(); }; /** The tile, or the cards behind, under each texel the camera sees. */ const surfaceOf = (screen: d.Infer<typeof passes.ScreenVertex>) => { 'use gpu'; const ndc = d.vec2f(screen.uv.x * 2 - 1, 1 - screen.uv.y * 2); const world = cameras.rayOf(cameras.current.$, ndc).origin.xy; const eye = cameras.current.$.position.xy; const front = onCard(CARDS.level); const cell = d.vec2i(std.floor(world)); const kind = tileAt(cell); if (kind === GROUND) { const open = tileAt(cell + d.vec2i(0, 1)) === EMPTY; return Seen({ color: d.vec4f(ground(world, open), front) }); } if (kind === BRICKS) return Seen({ color: d.vec4f(bricks(world), front) }); const pole = flagAt(world); if (pole.w > 0) return Seen({ color: d.vec4f(pole.xyz, front) }); return Seen({ color: backdrop(world, eye) }); }; const drawing: passes.Drawing<typeof passes.ScreenVertex, Seen> = { label: 'scenery', ...passes.fullScreen, surfaceOf, color: passes.shaded, draw: (pass) => pass.draw(3), }; const destroy = () => tiles.buffer.destroy(); return { drawing, destroy, [Symbol.dispose]: destroy, };}sprites.ts
import { meshes } from '@typegpu/kit/geometry';import { sdDisk } from '@typegpu/sdf';import { d, std, type TgpuUniform } from 'typegpu';/** Sprite quad spans -1 to 1 on xz, with two triangles. */export const quad = meshes.unindexed(meshes.plane({ width: 2, depth: 2 }));/** Texels per tile. The camera and every layer snap to them, so the art stays on the grid. */export const TEXELS = 16;/** * The paper cards, back to front. Each pixel of the art stores its card in alpha, so the paper * pass knows which card it lies on and which cards stand in front of it. */export const CARDS = { sky: 0, clouds: 1, far: 2, near: 3, level: 4, sprites: 5 } as const;const TOP = CARDS.sprites;/** The alpha that marks a pixel of the art as lying on `card`. */export const onCard = (card: number) => { 'use gpu'; return card / TOP;};/** The card a pixel's alpha marks, fractional between filtered texels. */export const cardOf = (alpha: number) => { 'use gpu'; return alpha * TOP;};/** What every drawing of the game reads besides the camera, which the scene's description fills. */export interface ArtOptions { /** Frame-clock time in seconds. */ readonly time: TgpuUniform<d.F32>;}/** A pixel's colour and card, which every drawing of the game writes unchanged. */export const Seen = d.struct({ color: d.vec4f });export type Seen = d.Infer<typeof Seen>;/** A sprite's colour on the front card. */export const sprite = (color: d.v3f) => { 'use gpu'; return Seen({ color: d.vec4f(color, onCard(CARDS.sprites)) });};/** Rounds a world position to the texel grid, so a moving sprite keeps its exact pixels. */export const snapped = (world: d.v2f) => { 'use gpu'; return std.round(world * TEXELS) / TEXELS;};const WHITE = d.vec3f(0.98, 0.96, 0.9);const PUPIL = d.vec3f(0.08, 0.06, 0.1);/** Eye dimensions in quad units. */export interface Eyes { /** Offset from the pair midpoint to each eye centre. */ readonly apart: number; readonly radius: number; readonly pupil: number; /** Forward pupil offset from each eye centre. */ readonly look: number;}/** * Returns eye colour at a point relative to the pair midpoint, using the sprite's x facing. * Returns zero alpha outside the eyes. */export function eyes({ apart, radius, pupil, look }: Eyes) { const eyesAt = (at: d.v2f, facing: number) => { 'use gpu'; const pupils = at - d.vec2f(facing * look, 0); if (sdDisk(d.vec2f(std.abs(pupils.x) - apart, pupils.y), pupil) < 0) return d.vec4f(PUPIL, 1); if (sdDisk(d.vec2f(std.abs(at.x) - apart, at.y), radius) < 0) return d.vec4f(WHITE, 1); return d.vec4f(); }; return eyesAt;}hero.ts
import { solidAt, type Level } from './level.ts';/** Half the hero's size, in tiles. */export const HALF = [0.32, 0.42] as const;/** Top running speed, jump, stomp bounce and top falling speed, in tiles per second. */const SPEED = { run: 7.5, jump: 22, bounce: 15, fall: 22 };/** Top upward speed once jump is released, in tiles per second. */const CUT = SPEED.jump / 2;/** Ground and air acceleration, and gravity, in tiles per second squared. */const ACCELERATION = { ground: 70, air: 40, gravity: 70 };/** Seconds a jump still works after leaving a ledge, and a press waits for the ground. */const GRACE = { coyote: 0.08, buffer: 0.12 };/** Collision gap in tiles. Prevents contact counting as overlap. */const SKIN = 1e-4;/** Held inputs. A jump press also calls the hero's `jump`. */export interface Input { left: boolean; right: boolean; jump: boolean;}/** Moves `value` toward `target` by at most `amount`. */const approach = (value: number, target: number, amount: number) => value < target ? Math.min(value + amount, target) : Math.max(value - amount, target);/** Runs CPU physics and tile collisions at a fixed step. */export function createHero(level: Level) { const position: [number, number] = [0, 0]; const previous: [number, number] = [0, 0]; const start: [number, number] = [0, 0]; const velocity: [number, number] = [0, 0]; let sinceGround = Infinity; let sincePress = Infinity; /** Moves along one axis until the first solid tile. Returns whether a collision occurred. */ const move = (axis: 0 | 1, delta: number) => { if (delta === 0) return false; position[axis] += delta; const across = 1 - axis; const low = Math.floor(position[across] - HALF[across] + SKIN); const high = Math.floor(position[across] + HALF[across] - SKIN); const edge = Math.floor(position[axis] + Math.sign(delta) * HALF[axis]); for (let t = low; t <= high; t++) { if (!(axis === 0 ? solidAt(level, edge, t) : solidAt(level, t, edge))) continue; position[axis] = delta > 0 ? edge - HALF[axis] - SKIN : edge + 1 + HALF[axis] + SKIN; velocity[axis] = 0; return true; } return false; }; const hero = { /** Centre of the body, in tiles. */ position, /** Previous fixed-step position for interpolation. */ previous, /** Position before this frame's steps. The game sets it for swept critter contacts. */ start, velocity, facing: 1, grounded: false, place(x: number, y: number) { position[0] = previous[0] = start[0] = x; position[1] = previous[1] = start[1] = y - 0.5 + HALF[1] + SKIN; velocity[0] = velocity[1] = 0; hero.facing = 1; }, jump() { sincePress = 0; }, bounce() { velocity[1] = SPEED.bounce; sinceGround = Infinity; }, step(input: Input, dt: number) { previous[0] = position[0]; previous[1] = position[1]; const want = Number(input.right) - Number(input.left); if (want !== 0) hero.facing = want; const acceleration = hero.grounded ? ACCELERATION.ground : ACCELERATION.air; velocity[0] = approach(velocity[0], want * SPEED.run, acceleration * dt); sinceGround += dt; sincePress += dt; if (sincePress < GRACE.buffer && sinceGround < GRACE.coyote) { velocity[1] = SPEED.jump; sincePress = sinceGround = Infinity; } if (!input.jump && velocity[1] > CUT) velocity[1] = CUT; velocity[1] = Math.max(velocity[1] - ACCELERATION.gravity * dt, -SPEED.fall); move(0, velocity[0] * dt); const falling = velocity[1] < 0; hero.grounded = move(1, velocity[1] * dt) && falling; if (hero.grounded) sinceGround = 0; }, }; hero.place(...level.start); return hero;}export type Hero = ReturnType<typeof createHero>;ui.ts
import { buttons, text } from '../ui.ts';export const ui = { score: text({ corner: 'top-right' }), controls: buttons( { left: { label: '◀', name: 'left', keys: ['ArrowLeft', 'KeyA'], held: true }, right: { label: '▶', name: 'right', keys: ['ArrowRight', 'KeyD'], held: true }, jump: { label: 'jump', keys: ['ArrowUp', 'KeyW', 'Space'], held: true }, }, { left: [['left', 'right']], right: [['jump']] }, ),};