Blockfall
Painted stone blocks fall into a gold-edged glass well floating in the dark. The CPU lists blocks as render instances, and the GPU eases them between cells, brightest on the falling piece. A ghost shows where the piece will land. A fixed-step clock controls drops, and coroutines clear rows. A warm spot light out of view and a cool sun shadow the blocks, and ambient occlusion darkens their creases. Cleared blocks flash and break into stone chips that tumble, land flat and shadow the floor, with a little dust. Embers drift up past the well. MSAA smooths the HDR render, with bloom.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- game.tsRuns game rules on the CPU.
- blocks.tsDraws carved solid blocks, their plinth and landing ghosts as rounded-box instances.
- shards.tsBreaks cleared blocks into painted stone chips and a little sandstone dust.
- controls.tsThe game's buttons, as
ui.tsdeclares them. - stage.tsThe well's light, a warm spot out of view, and the embers drifting past it.
- well.tsDraws the well's far glass walls with a faint grid of its cells and gold edges, so its limits read from any side without standing between the camera and the play.
- ui.ts
Kit modules ao, bindings, bloom, cameras, clock, coroutines, environment, finish, instances, lighting, materials, meshPool, owner, particles, passes, pipelines, shading, table, targets. Also @typegpu/color, typegpu.
scene.ts
import { ao, bindings, bloom, cameras, clock, coroutines, finish, lighting, owner, passes, pipelines, shading, targets, type Frame, type Surface,} from '@typegpu/kit';import { d, tgpu, type TgpuRoot } from 'typegpu';import { CELLS, createBlockfall, DEPTH, HEIGHT, PIECE, WIDTH, type Score } from './game.ts';import { createBlocks } from './blocks.ts';import { createShards } from './shards.ts';import { createControls } from './controls.ts';import { backdrop, createStage, cube, STAGE } from './stage.ts';import { createWell } from './well.ts';const SAMPLES = 4;const HDR = 'rgba16float';/** Well centre and shadow centre. */const MIDDLE = [WIDTH / 2, HEIGHT / 2, DEPTH / 2] as const;/** Seconds the glow takes to settle after blocks break, and its added intensity. */const SWELL = { seconds: 0.7, intensity: 0.3 };/** Camera target a little below the middle, toward the block pile. */const TARGET = [WIDTH / 2, HEIGHT / 2 - 0.4, DEPTH / 2] as const;/** How far occluders darken sky light, in cells: about a block, for the creases between them. */const CREASE = 0.8;/** Darkens the sky light in creases, between blocks and where they meet the floor. */const occludedSky = (material: shading.Material, point: shading.Point) => { 'use gpu'; return shading.hemisphere(material, point) * ao.at(point.position);};export interface SceneOptions { /** Receives the score at startup and after each clear. */ readonly onScore?: (score: Score) => void;}/** * Draws 3D Blockfall in a glass well floating in the dark, under a warm key and a cool fill, with * MSAA, ambient occlusion, stone chips, bloom and a final pass. Its `ui` binds the buttons `ui.ts` * declares to `game` and `view`. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions = {}) { const own = owner(); const screen = own( targets.create(root, surface, { color: { format: HDR, samples: SAMPLES }, depth: { format: 'depth24plus', samples: SAMPLES }, hdr: { format: HDR, sampled: true }, }), ); const view = cameras.orbit(root, { size: surface, target: TARGET, distance: 21, pitch: 0.4, yaw: 0, pitchRange: [0.15, 1.45], distanceRange: [12, 32], far: 200, }); const time = root.createUniform(d.f32); const effects = clock.fixedStep({ step: 1 / 60, maxSteps: 4 }); const blocks = own( createBlocks(root, { time, capacity: CELLS + PIECE, ghosts: PIECE, plinth: { at: [WIDTH / 2, -0.32, DEPTH / 2], size: [WIDTH + 1, 0.6, DEPTH + 1], color: STAGE.plinth, }, grid: [WIDTH, DEPTH], scenery: [cube], }), ); const stage = own(createStage(root, { camera: view, step: effects.step })); /** Half-resolution depth the opaque drawings draw first, for the occlusion to read. */ const early = own( targets.create(root, surface, { depth: { format: 'depth32float', scale: 0.5, sampled: true }, }), ); const prepass = passes .create(root, early, { depth: passes.depth }, { label: 'prepass' }) .pipe(view.inject()); const occlusion = own( ao.create(root, view, { depth: () => early.textures.depth, settings: { radius: CREASE } }), ); const light = own( lighting.create(root, { settings: STAGE.settings, shading: { ambient: tgpu.fn(occludedSky).pipe(occlusion.inject()) }, shadow: { size: 2048, extent: 16, center: MIDDLE, filter: 9 }, points: { table: stage.lamps }, pointShadow: { count: 1, size: 1024, filter: 9 }, }), ); light.pointShadow.cast([stage.key]); const shared = bindings.share(root, { ao: occlusion.part }); const scene = passes .create( root, screen, { color: passes.color, depth: passes.depth }, { resolve: { color: 'hdr' }, shared }, ) .pipe(view.inject()) .beneath(light.inject()); const shards = own( createShards(root, { camera: view, step: effects.step, time, pool: blocks.pool, chip: blocks.sceneryMeshes[0]!, }), ); // The backdrop fills what the blocks leave. The well, ghosts, embers and dust blend over them const drawn = [ scene.add(blocks.solid), scene.add(shards.chips), scene.add(backdrop), scene.add(createWell([WIDTH, HEIGHT, DEPTH])), scene.add(blocks.ghosts), scene.add(stage.embers.drawing), ...shards.drawings.map((drawing) => scene.add(drawing)), ]; const shapes = [prepass.add(blocks.solid), prepass.add(shards.chips)]; const casters = [light.shadow.add(blocks.solid), light.shadow.add(shards.chips)]; const lit = [light.pointShadow.add(blocks.solid), light.pointShadow.add(shards.chips)]; const stirred = () => !blocks.still(now) || shards.flying(now); const glow = own( bloom.create(root, { source: () => screen.textures.hdr, settings: STAGE.bloom }), ); const final = own( finish.create(root, { format: surface.format, source: () => screen.textures.hdr, glow, settings: STAGE.finish, }), ); await pipelines.compile([ scene, prepass, occlusion, light.shadow, light.pointShadow, glow, final, ]); let now = 0; let brokeAt = -Infinity; let swelling = false; let score = ''; const scheduler = own(coroutines.create()); const game = createBlockfall({ blocks, scheduler, onScore(state) { score = `${state.score}\nlines ${state.lines} · level ${state.level}`; options.onScore?.(state); }, onBreak(x, y, z, kind) { shards.break(x, y, z, kind, now); brokeAt = now; }, }); /** Swells the glow as blocks break, settling over `SWELL.seconds`. */ const swell = () => { const left = Math.max(0, 1 - (now - brokeAt) / SWELL.seconds); if (left === 0 && !swelling) return; final.settings.patch({ glowIntensity: finish.defaults.glowIntensity + SWELL.intensity * left * left, }); swelling = left > 0; }; return { view, ui: { score: () => score, controls: createControls(game, view) }, game, frame(frame: Frame) { time.write(frame.time); game.update(frame); now = frame.time; scheduler.run(frame); swell(); view.update(frame.dt); screen.fit(); early.fit(); blocks.update(frame); shards.update(); const compute = frame.encoder.beginComputePass(); for (let steps = effects.advance(frame.dt); steps > 0; steps--) { shards.step(compute); stage.embers.step(compute); } shards.spawn(compute); stage.embers.spawn(compute); shards.compact(compute, effects.ahead); stage.embers.compact(compute, effects.ahead); compute.end(); prepass.record(frame, shapes); occlusion.record(frame); // Shadows hold while the blocks do, and the scenery never moves light.shadow.record(frame, casters, { redraw: stirred }); light.pointShadow.record(frame, lit, { redraw: stirred }); scene.record(frame, drawn); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}game.ts
import { clock, coroutines, type Frame, type Coroutines } from '@typegpu/kit';import { CLEAR_TIME, type BlockId, type Blocks } from './blocks.ts';export const WIDTH = 6;export const DEPTH = 6;export const HEIGHT = 12;/** Cells per well layer and total cells. */export const LAYER = WIDTH * DEPTH;export const CELLS = LAYER * HEIGHT;export const PIECE = 4;type Cell = [x: number, y: number, z: number];/** Spawn origin at the well centre, two cells below the top. */const SPAWN: Cell = [WIDTH / 2 - 1, HEIGHT - 2, DEPTH / 2 - 1];/** Each piece has `PIECE` cubes. The last two require three dimensions. */// oxfmt-ignoreconst SHAPES: readonly (readonly Cell[])[] = [ [[-1, 0, 0], [0, 0, 0], [1, 0, 0], [2, 0, 0]], [[0, 0, 0], [1, 0, 0], [0, 0, 1], [1, 0, 1]], [[-1, 0, 0], [0, 0, 0], [1, 0, 0], [0, 0, 1]], [[-1, 0, 0], [0, 0, 0], [1, 0, 0], [1, 0, 1]], [[-1, 0, 1], [0, 0, 1], [0, 0, 0], [1, 0, 0]], [[0, 0, 0], [1, 0, 0], [0, 0, 1], [0, 1, 0]], [[0, 0, 0], [1, 0, 0], [1, 0, 1], [1, 1, 1]],];/** Offsets tried, in order, when a rotation collides. */// oxfmt-ignoreconst KICKS: readonly Cell[] = [[0, 0, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0]];/** Score by cleared-layer count, from zero to four, multiplied by level. */const POINTS = [0, 100, 300, 600, 1000];/** Layers to clear for each level. */const LINES_PER_LEVEL = 6;/** Initial drop interval in seconds, multiplier per level and minimum interval. */const DROP = { first: 0.8, keep: 0.85, least: 0.1 };/** Delay in seconds between game-over layer flashes, ordered top down. */const CASCADE = 0.04;/** Seconds between drops at `level`. */const dropStep = (level: number) => Math.max(DROP.least, DROP.first * DROP.keep ** (level - 1));/** Linear grid index for `x, y, z`, ordered by layer from the bottom. */const at = (x: number, y: number, z: number) => y * LAYER + z * WIDTH + x;/** Whether column `x, z` lies inside the well's walls. */const inside = (x: number, z: number) => x >= 0 && x < WIDTH && z >= 0 && z < DEPTH;/** Rotates a cell a quarter turn around `axis` (0 x, 1 y, 2 z), counter-clockwise for turn 1. */function rotate([x, y, z]: Cell, axis: number, turn: number): Cell { if (axis === 0) return [x, -turn * z, turn * y]; if (axis === 1) return [turn * z, y, -turn * x]; return [-turn * y, turn * x, z];}export interface Score { readonly score: number; readonly lines: number; readonly level: number;}export interface BlockfallOptions { readonly blocks: Blocks; /** Runs layer clears and the game-over cascade. */ readonly scheduler: Coroutines; /** Receives the score at startup and after each clear or game over. */ readonly onScore: (score: Score) => void; /** Receives each cleared block's cell and kind as it breaks. Default none. */ readonly onBreak?: (x: number, y: number, z: number, kind: number) => void;}/** Runs game rules on the CPU. */export function createBlockfall(options: BlockfallOptions) { const { blocks, scheduler, onScore, onBreak } = options; const grid: (BlockId | undefined)[] = Array.from({ length: CELLS }); /** The block ids of layer `y`, from the bottom. */ const layer = (y: number) => grid.slice(y * LAYER, (y + 1) * LAYER); /** The layers with no gaps, from the bottom. */ const fullLayers = () => Array.from({ length: HEIGHT }, (_, y) => y).filter((y) => layer(y).every((id) => id !== undefined), ); /** Whether `cell` lies inside the walls, on or above the floor, and holds no block. */ const free = ([x, y, z]: Cell) => inside(x, z) && y >= 0 && (y >= HEIGHT || grid[at(x, y, z)] === undefined); /** The falling piece: its kind, its cells around its origin, and their blocks. */ const piece = { kind: 0, cells: [] as Cell[], origin: [0, 0, 0] as Cell, ids: [] as BlockId[] }; const state = { score: 0, lines: 0, level: 1 }; /** Drops the piece a cell a step, at the level's rate. */ const gravity = clock.fixedStep({ step: dropStep(1), maxSteps: 1 }); // Until the game spawns the first piece gravity.paused = true; const startLevel = (level: number) => { state.level = level; gravity.step = dropStep(level); gravity.reset(); }; /** `cell` of a piece at `origin`, in the well. */ const world = ([x, y, z]: Cell, [ox, oy, oz] = piece.origin): Cell => [x + ox, y + oy, z + oz]; const fits = (cells: readonly Cell[], origin: Cell) => cells.every((cell) => free(world(cell, origin))); const landing = (): Cell => { const [x, y, z] = piece.origin; let floor = y; while (fits(piece.cells, [x, floor - 1, z])) floor -= 1; return [x, floor, z]; }; /** Eases piece blocks toward their target cells and ghosts toward landing cells. */ const show = () => { const below = landing(); piece.cells.forEach((cell, i) => { blocks.move(piece.ids[i]!, world(cell)); blocks.ghost(i, piece.kind, world(cell, below)); }); }; /** Tries cells and origin, easing blocks to the target if they fit. */ const attempt = (cells: Cell[], origin: Cell) => { if (!fits(cells, origin)) return false; piece.cells = cells; piece.origin = origin; show(); return true; }; /** Starts a new piece above the well. Returns false when it has no room, ending the game. */ const spawn = () => { piece.kind = Math.floor(Math.random() * SHAPES.length); piece.cells = SHAPES[piece.kind]!.map((cell): Cell => [...cell]); piece.origin = [...SPAWN]; if (!fits(piece.cells, piece.origin)) return false; const above: Cell = [piece.origin[0], HEIGHT + 1, piece.origin[2]]; piece.ids = piece.cells.map((cell) => blocks.add(piece.kind, world(cell, above))); for (const id of piece.ids) blocks.fall(id, true); show(); gravity.paused = false; return true; }; /** Sets the piece into the grid where it is and stops its fall. */ const lock = () => { piece.cells.forEach((cell, i) => { const [x, y, z] = world(cell); grid[at(x, y, z)] = piece.ids[i]!; blocks.fall(piece.ids[i]!, false); }); gravity.paused = true; }; const playing = () => !gravity.paused; /** Resumes once `lock` stops the falling piece. */ const landed = coroutines.wait({ ready: () => !playing() }); const flashed = coroutines.seconds(CLEAR_TIME); const cascaded = coroutines.seconds(CASCADE); /** Flashes and removes full layers, then drops blocks above them. */ function* clear(layers: readonly number[]) { for (const y of layers) { for (const id of layer(y)) blocks.flash(id!); } yield* flashed; let removed = 0; for (let y = 0; y < HEIGHT; y++) { const cleared = layers.includes(y); for (let z = 0; z < DEPTH; z++) { for (let x = 0; x < WIDTH; x++) { const id = grid[at(x, y, z)]; if (id === undefined) continue; grid[at(x, y, z)] = undefined; if (cleared) { onBreak?.(x, y, z, blocks.kindOf(id)); blocks.remove(id); } else { grid[at(x, y - removed, z)] = id; blocks.move(id, [x, y - removed, z]); } } } if (cleared) removed += 1; } state.lines += layers.length; state.score += POINTS[layers.length]! * state.level; const level = 1 + Math.floor(state.lines / LINES_PER_LEVEL); if (level !== state.level) startLevel(level); onScore(state); } /** Flashes the well layer by layer from the top, then empties it and resets the score. */ function* gameOver() { for (let y = HEIGHT - 1; y >= 0; y--) { for (const id of layer(y)) if (id !== undefined) blocks.flash(id); yield* cascaded; } yield* flashed; for (const id of grid) if (id !== undefined) blocks.remove(id); grid.fill(undefined); Object.assign(state, { score: 0, lines: 0 }); startLevel(1); onScore(state); } /** Plays game after game: drops pieces and clears the layers they fill until one has no room. */ function* play() { for (;;) { while (spawn()) { yield* landed; const full = fullLayers(); if (full.length > 0) yield* clear(full); } yield* gameOver(); } } scheduler.start(play()); onScore(state); return { /** Advances the drop clock once per frame. Each drop moves the piece one cell. */ update(frame: Frame) { for (let steps = gravity.advance(frame.dt); steps > 0; steps--) { const [x, y, z] = piece.origin; if (!attempt(piece.cells, [x, y - 1, z])) lock(); } }, move(dx: number, dz: number) { if (!playing()) return; const [x, y, z] = piece.origin; attempt(piece.cells, [x + dx, y, z + dz]); }, /** A quarter turn around `axis` (0 x, 1 y, 2 z); `turn` is 1 or -1. */ rotate(axis: number, turn: number) { if (!playing()) return; const cells = piece.cells.map((cell) => rotate(cell, axis, turn)); KICKS.some((kick) => attempt(cells, world(kick))); }, drop() { if (!playing()) return; attempt(piece.cells, landing()); lock(); }, };}blocks.ts
import { instances, materials, meshPool, shading, type Frame, type Inject } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { d, std, tgpu, type TgpuRoot, type TgpuUniform } from 'typegpu';/** Seconds a move takes on screen. */const MOVE_TIME = 0.08;/** Seconds a cleared block flashes before it goes. */export const CLEAR_TIME = 0.3;/** Each kind's pigment, linear: vermilion, amber, saffron, jade, turquoise, lapis and amethyst. */export const PALETTE = [ d.vec3f(0.85, 0.22, 0.14), d.vec3f(0.95, 0.5, 0.12), d.vec3f(0.95, 0.78, 0.2), d.vec3f(0.18, 0.62, 0.36), d.vec3f(0.08, 0.6, 0.7), d.vec3f(0.2, 0.32, 0.85), d.vec3f(0.55, 0.25, 0.8),].map((color) => srgbToLinear(color));/** Edge length slightly below cell size, leaving gaps between blocks. */const SIZE = 0.94;/** The radius of a block's bevel, in cells. */const BEVEL = 0.035;/** Seconds after blocks start to change until they hold still: a flash, plus a frame or two. */const SETTLE = Math.max(MOVE_TIME, CLEAR_TIME) + 0.05;/** Final flash brightness exceeds white so cleared blocks bloom. */const FLASH = 6;/** A ghost's opacity, which also tells its material from a block's. */const GHOST_OPACITY = 0.3;/** Initial ghost positions below the well, hidden until a piece moves them. */const HIDDEN: Cell = [0, -2, 0];/** Frame-clock time in seconds. */const clock = tgpu.accessor(d.f32);/** * Movement progress from 0 to 1 after `since` seconds. The CPU also uses it to resume motion * from the visible position. */const eased = (since: number) => { 'use gpu'; return std.smoothstep(0, 1, since / MOVE_TIME);};/** Flash progress from 0 to 1. */const flashed = (clearedAt: number) => { 'use gpu'; if (clearedAt < 0) return d.f32(0); return std.saturate((clock.$ - clearedAt) / CLEAR_TIME);};/** How much of its colour a block gives off, settled and falling, so the piece in play stands out. */const LIFT = { settled: 0, falling: 0.08 };/** * The paint: its roughness and how far it varies from block to block, how much pale stone mutes * the pigment, how much lighter or darker a block's coat may be, and how far the chamfers wear * through to the stone. */const PAINT = { roughness: 0.8, spread: 0.12, chalk: 0.08, shade: 0.14, worn: 0.5 };/** The pale limestone the blocks are cut from. */export const LIMESTONE = srgbToLinear(d.vec3f(0.76, 0.71, 0.62));/** Chalky pigment of `color`, a shade lighter or darker by `seed`, from 0 to 1. */export const pigmentOf = (color: d.v3f, seed: number) => { 'use gpu'; return std.mix(color, LIMESTONE, PAINT.chalk) * (1 + (seed - 0.5) * PAINT.shade);};/** The paint's roughness, varied by `seed`, from 0 to 1. */export const roughnessOf = (seed: number) => { 'use gpu'; return PAINT.roughness + (seed - 0.5) * PAINT.spread;};/** * The brass inlaid in the plinth: its colour, how strongly it shows, its glow, and its line * half-width in cells. */const BRASS = { color: srgbToLinear(d.vec3f(0.85, 0.66, 0.4)), strength: 0.8, glow: 0.08, width: 0.012,};/** The fewest pixels across a line is drawn; a line any thinner fades instead. */const LINE_PIXELS = 1.5;/** How many pixels either side of a line's edge its coverage softens over. */const LINE_SOFTNESS = 0.75;/** A ghost's fill, as a share of its paint, and the glow of its outline. */const GHOST = { fill: 0.5, outline: 2.4 };/** * How much of a line of half-width `width` covers a point `distance` from its middle, where a * `pixel` spans that many of the same units, as Ben Golus's pristine grid draws it. Its edges * soften over `LINE_SOFTNESS` pixels either side, and a line thinner than `LINE_PIXELS` is drawn * that wide and fades by how much thinner it is. * * A bright line on a dark floor blends in linear light, where a pixel half covered looks much * more than half as bright. Squaring the coverage evens that out, so a shallow line keeps one * weight as it crosses rows of pixels instead of swelling where it straddles two. */const line = (distance: number, width: number, pixel: number) => { 'use gpu'; const half = width / pixel; const drawn = std.max(half, LINE_PIXELS / 2); const away = std.abs(distance) / pixel; const covered = 1 - std.smoothstep(drawn - LINE_SOFTNESS, drawn + LINE_SOFTNESS, away); return covered * covered * (half / drawn);};/** How much of a line covers a point `over` past its end, softened as `line` softens its edges. */const capped = (over: number, width: number, pixel: number) => { 'use gpu'; const drawn = std.max(width / pixel, LINE_PIXELS / 2); return 1 - std.smoothstep(drawn - LINE_SOFTNESS, drawn + LINE_SOFTNESS, over / pixel);};/** * Painted limestone. Eases from `origin` to the target over `MOVE_TIME`, starting at `movedAt`, * and shrinks over `CLEAR_TIME` from `clearedAt`, negative before clearing, by the frame clock. * The paint wears through on the chamfers, and the piece in play gives off a little of its colour. * A ghost is a faint fill with a glowing outline. A floor, whose `grid` holds the well's half * width and depth, is dark polished stone with the well's cells inlaid in brass. */const Block = instances.define('Block', { host: { origin: d.vec3f, movedAt: d.f32, clearedAt: d.f32, falling: d.f32, grid: d.vec2f, seed: d.f32, }, deform: (vertex, block) => { 'use gpu'; const at = std.mix(block.origin, block.position, eased(clock.$ - block.movedAt)); const cleared = flashed(block.clearedAt); // Divide world displacement by scale because deformation uses local block units return vertex.position * (1 - cleared * cleared) + (at - block.position) / block.scale; }, carry: (vertexIndex) => { 'use gpu'; return d.vec4f(meshPool.positionAt(vertexIndex), 0); }, material: (point, block, base, carried) => { 'use gpu'; const p = point.position; const n = std.abs(point.normal); // Derivatives run before any branch, in uniform control flow. They come from positions, // since the distances below jump between lines and where faces meet const across = d.vec2f( std.length(d.vec2f(std.dpdx(p.x), std.dpdy(p.x))), std.length(d.vec2f(std.dpdx(p.z), std.dpdy(p.z))), ); const pixel = std.max(across, d.vec2f(1e-5)); const span = std.max(std.length(std.fwidth(carried.xyz)) * 0.7, 1e-5); // Each line of the grid is a segment across the well, ending in a soft cap at its border const rel = p.xz - block.position.xz; const nearest = std.round(std.clamp(rel, block.grid * -1, block.grid)); const off = rel - nearest; const over = std.max(std.abs(rel) - block.grid, d.vec2f(0)); const grid = std.max( line(off.x, BRASS.width, pixel.x) * capped(over.y, BRASS.width, pixel.y), line(off.y, BRASS.width, pixel.y) * capped(over.x, BRASS.width, pixel.x), ); const local = std.abs(carried.xyz); const face = std.select( std.select(local.xy, local.xz, n.y > n.z), local.zy, n.x > n.y && n.x > n.z, ); const outline = line(std.max(face.x, face.y) - 0.5 + 0.035, 0.03, span); if (block.grid.x > 0) { const gold = std.select(d.f32(0), grid * BRASS.strength, point.normal.y > 0.5); return shading.Material({ color: std.mix(base.color, BRASS.color, gold), roughness: std.mix(base.roughness, 0.3, gold), emission: BRASS.color * (BRASS.glow * gold), metallic: gold, occlusion: 1, }); } // Chalky pigment, a shade lighter or darker on each block. The chamfer is where the normal // leaves its face's axis, since blocks never turn, and there the paint wears to the stone const pigment = pigmentOf(base.color, block.seed); const chamfer = std.smoothstep(0.04, 0.25, 1 - std.max(std.max(n.x, n.y), n.z)); const color = std.mix(pigment, LIMESTONE, chamfer * PAINT.worn); if (block.opacity < 0.99) { const lit = materials.matte(color * GHOST.fill); lit.emission = base.color * (GHOST.outline * outline); return lit; } const lit = materials.glossy(color, roughnessOf(block.seed)); const lift = std.mix(LIFT.settled, LIFT.falling, block.falling); const flash = FLASH * flashed(block.clearedAt); lit.emission = base.color * (lift + flash * 0.4) + d.vec3f(flash * 0.2); return lit; },});export type Cell = readonly [x: number, y: number, z: number];type Triple = [number, number, number];type BlockSet = instances.Instances<typeof Block>;/** Block handle. Invalid after removal. */export type BlockId = instances.Id<'Block'>;/** CPU row state for resuming motion from the visible position. */interface Row { readonly set: BlockSet; readonly id: BlockId; kind: number; /** Origin, target and last requested cell centres. Motion starts at `movedAt`. */ readonly origin: Triple; readonly to: Triple; readonly goal: Triple; movedAt: number; /** Flash start time. -1 means inactive, and NaN requests a start on the next update. */ clearedAt: number;}export interface BlocksOptions { /** Maximum solid-block count. */ readonly capacity: number; /** One ghost block per piece cube. */ readonly ghosts: number; /** Frame-clock time in seconds. */ readonly time: TgpuUniform<d.F32>; /** A slab drawn with the solid blocks, by its centre and size. */ readonly plinth: { readonly at: Cell; readonly size: Cell; readonly color: d.v3f }; /** The well's width and depth, which the plinth's grid covers, centred on the plinth. */ readonly grid: readonly [width: number, depth: number]; /** The scenery's meshes, added to the same pool. */ readonly scenery?: readonly meshes.Geometry[];}/** * Draws carved solid blocks, their plinth and landing ghosts as rounded-box instances. The GPU * eases blocks between cells. Row writes occur only when motion or a flash starts. */export function createBlocks(root: TgpuRoot, options: BlocksOptions) { const { capacity } = options; const shape = meshes.roundedBox({ radius: BEVEL }); const [width, height, depth] = options.plinth.size; const slab = meshes.roundedBox({ width, height, depth, radius: 0.12 }); const scenery = options.scenery ?? []; const pool = meshPool.create(root, meshPool.roomFor([shape, slab, ...scenery])); const cube = pool.add(shape); const plinth = pool.add(slab); const sceneryMeshes = scenery.map((mesh) => pool.add(mesh)); const solid = instances.create(root, pool, { kind: Block, capacity: capacity + 1 }); const ghosts = instances.create(root, pool, { kind: Block, capacity: options.ghosts }); const timed: Inject = (cfg) => cfg.with(clock, options.time); const solidRenderer = solid.renderer().pipe(timed); const ghostRenderer = ghosts.renderer({ blend: 'translucent' }).pipe(timed); const rows = new Map<BlockId, Row>(); const starting = new Set<Row>(); /** When solid blocks last started to change, and whether one came or went since `update`. */ let stirredAt = -Infinity; let stirred = false; const written = { position: [0, 0, 0] as Triple, origin: [0, 0, 0] as Triple, movedAt: 0, clearedAt: -1, }; const write = (row: Row) => { written.position = row.to; written.origin = row.origin; written.movedAt = row.movedAt; written.clearedAt = row.clearedAt; row.set.write(row.id, written); }; let placed = 0; const placeBlock = (set: BlockSet, kind: number, cell: Cell, opacity = 1): Row => { const centre: Triple = [cell[0] + 0.5, cell[1] + 0.5, cell[2] + 0.5]; const id = set.add(cube, { position: centre, scale: SIZE, ...materials.matte(PALETTE[kind]!), // Golden-ratio steps spread the coats' shades evenly seed: (placed++ * 0.618034) % 1, opacity, origin: centre, movedAt: 0, clearedAt: -1, }); return { set, id, kind, origin: [...centre], to: [...centre], goal: [...centre], movedAt: 0, clearedAt: -1, }; }; /** Sets the row's goal to `cell`, so the next `update` starts it easing there. */ const ease = (row: Row, cell: Cell) => { for (let axis = 0; axis < 3; axis++) row.goal[axis] = cell[axis]! + 0.5; starting.add(row); }; /** Starts easing the row from its visible position to its goal at `time`. */ const restart = (row: Row, time: number) => { const moved = eased(time - row.movedAt); for (let axis = 0; axis < 3; axis++) { row.origin[axis] = row.origin[axis]! + (row.to[axis]! - row.origin[axis]!) * moved; row.to[axis] = row.goal[axis]!; } row.movedAt = time; }; /** Whether the requested cell differs from the current motion target. */ const redirected = (row: Row) => row.goal[0] !== row.to[0] || row.goal[1] !== row.to[1] || row.goal[2] !== row.to[2]; const at = [...options.plinth.at] as Triple; solid.add(plinth, { position: at, ...materials.glossy(options.plinth.color, 0.4), origin: at, movedAt: 0, clearedAt: -1, grid: [options.grid[0] / 2, options.grid[1] / 2], }); const ghostRows = Array.from({ length: options.ghosts }, () => placeBlock(ghosts, 0, HIDDEN, GHOST_OPACITY), ); const destroy = () => { solid.destroy(); ghosts.destroy(); pool.destroy(); }; return { /** Adds a block of `kind` at `cell` without easing. */ add(kind: number, cell: Cell) { const row = placeBlock(solid, kind, cell); rows.set(row.id, row); stirred = true; return row.id; }, /** Eases a block from its visible position to `cell`. */ move(id: BlockId, cell: Cell) { ease(rows.get(id)!, cell); }, /** Starts a clear flash lasting `CLEAR_TIME` seconds. */ flash(id: BlockId) { const row = rows.get(id)!; row.clearedAt = Number.NaN; starting.add(row); }, kindOf: (id: BlockId) => rows.get(id)!.kind, /** Marks a block as part of the falling piece, which shines brightest. */ fall(id: BlockId, falling: boolean) { solid.write(id, { falling: falling ? 1 : 0 }); }, remove(id: BlockId) { stirred = true; starting.delete(rows.get(id)!); rows.delete(id); solid.remove(id); }, /** Eases ghost `i` to `cell`, in the colour of `kind`. */ ghost(i: number, kind: number, cell: Cell) { const row = ghostRows[i]!; if (row.kind !== kind) ghosts.write(row.id, { color: PALETTE[kind]! }); row.kind = kind; ease(row, cell); }, /** * Whether no solid block has moved, flashed, come or gone since a little over `CLEAR_TIME` * before `time`, so shadows drawn since then still hold. */ still: (time: number) => time - stirredAt > SETTLE, /** Writes rows starting motion or flashes, then flushes solid and ghost instances. */ update(frame: Frame) { if (stirred || starting.size > 0) stirredAt = frame.time; stirred = false; for (const row of starting) { if (redirected(row)) restart(row, frame.time); if (Number.isNaN(row.clearedAt)) row.clearedAt = frame.time; write(row); } starting.clear(); solid.flush(); ghosts.flush(); }, pool, sceneryMeshes, solid: solidRenderer, /** Add ghosts after solid blocks so they blend over them. */ ghosts: ghostRenderer, destroy, [Symbol.dispose]: destroy, };}export type Blocks = ReturnType<typeof createBlocks>;shards.ts
import { instances, materials, meshPool, particles, type CameraBase, type Inject,} from '@typegpu/kit';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot, type TgpuUniform } from 'typegpu';import { LIMESTONE, PALETTE, pigmentOf, roughnessOf } from './blocks.ts';import { DEPTH, LAYER, PIECE, WIDTH } from './game.ts';/** Chips a block breaks into. */const CHIPS = 4;/** Downward acceleration of a chip, in cells per second squared. */const GRAVITY = 14;/** How far a chip keeps from the well's walls, in cells. */const MARGIN = 0.12;/** A chip's size range along its three axes, in cells. */const CHIP = { small: [0.2, 0.08, 0.16], large: [0.5, 0.22, 0.42] } as const;/** A chip's life range in seconds; it shrinks away over the last `FADE` of it. */const LIFE = [0.9, 1.4] as const;const FADE = 0.35;/** Seconds a landed chip takes to tip onto its nearest face. */const TIP = 0.1;/** The sandstone dust a block leaves where it stood. */const SAND = [0.3, 0.26, 0.22, 0.2] as const;/** Frame-clock time in seconds. */const clock = tgpu.accessor(d.f32);/** The unit quaternion turning by `angle` about the unit `axis`. */const turn = (axis: d.v3f, angle: number) => { 'use gpu'; return d.vec4f(axis * std.sin(angle / 2), std.cos(angle / 2));};/** The rotation `a` after `b`, of unit quaternions. */const after = (a: d.v4f, b: d.v4f) => { 'use gpu'; return d.vec4f( b.xyz * a.w + a.xyz * b.w + std.cross(a.xyz, b.xyz), a.w * b.w - std.dot(a.xyz, b.xyz), );};/** The heights of the unit axes `rotation` turns: how far each points up. */const heightsOf = (rotation: d.v4f) => { 'use gpu'; const q = rotation; return d.vec3f( 2 * (q.x * q.y + q.w * q.z), 1 - 2 * (q.x * q.x + q.z * q.z), 2 * (q.y * q.z - q.w * q.x), );};/** Seconds until something thrown up at `rise` lands `drop` below where it started. */const landing = (drop: number, rise: number) => { 'use gpu'; const below = std.max(drop, 0); return (rise + std.sqrt(rise * rise + 2 * GRAVITY * below)) / GRAVITY;};/** How far a box of size `scale`, turned by `rotation`, reaches below its centre. */const reachOf = (rotation: d.v4f, scale: d.v3f) => { 'use gpu'; return std.dot(std.abs(heightsOf(rotation)), scale) * 0.5;};/** * The least turn after `rotation` that stands the axis nearest the vertical upright, so the box * lies on a face, turned about the vertical as it was. */const flattened = (rotation: d.v4f) => { 'use gpu'; const height = std.abs(heightsOf(rotation)); const axis = std.select( std.select(d.vec3f(0, 0, 1), d.vec3f(0, 1, 0), height.y >= height.z), d.vec3f(1, 0, 0), height.x >= height.y && height.x >= height.z, ); const tilted = instances.rotate(rotation, axis); const upright = d.vec3f(0, std.sign(tilted.y), 0); const level = std.normalize(d.vec4f(std.cross(tilted, upright), 1 + std.dot(tilted, upright))); return after(level, rotation);};/** * A chip of a broken block: painted on the block's outer faces, raw limestone where it broke * away. It flies from `position` at `velocity`, spinning about `spin.xyz` at `spin.w` radians a * second, lands on the floor at `rest`, tips onto a face over `TIP` seconds, and shrinks away by * `bornAt + life`. */const Chip = instances.define('Chip', { host: { velocity: d.vec3f, spin: d.vec4f, bornAt: d.f32, life: d.f32, rest: d.f32, seed: d.f32 }, place: (chip) => { 'use gpu'; const age = clock.$ - chip.bornAt; const rise = chip.velocity.y; const phase = chip.seed * 6.283; // It lands about when its lowest corner meets the floor: first guessed from its smallest // half extent, then from how it is turned then const smallest = std.min(std.min(chip.scale.x, chip.scale.y), chip.scale.z) * 0.5; const guess = landing(chip.position.y - chip.rest - smallest, rise); const guessed = turn(chip.spin.xyz, phase + chip.spin.w * guess); const landed = landing(chip.position.y - chip.rest - reachOf(guessed, chip.scale), rise); const flying = std.min(age, landed); const touched = turn(chip.spin.xyz, phase + chip.spin.w * landed); const tipped = std.smoothstep(0, TIP, age - landed); const rotation = std.normalize(std.mix(touched, flattened(touched), tipped)); const turning = std.select( rotation, turn(chip.spin.xyz, phase + chip.spin.w * flying), age < landed, ); const size = std.select(std.saturate((chip.life - age) / (chip.life * FADE)), 0, age < 0); // It rests on its lowest corner as it tips and shrinks away const resting = chip.rest + reachOf(turning, chip.scale) * size; const fallen = chip.position.y + rise * flying - 0.5 * GRAVITY * flying * flying; return instances.Placement({ rotation: turning, position: d.vec3f( std.clamp(chip.position.x + chip.velocity.x * flying, MARGIN, WIDTH - MARGIN), std.select(std.max(fallen, resting), resting, age >= landed), std.clamp(chip.position.z + chip.velocity.z * flying, MARGIN, DEPTH - MARGIN), ), scale: chip.scale * size, }); }, visible: (chip) => { 'use gpu'; const age = clock.$ - chip.bornAt; return age >= 0 && age < chip.life; }, carry: (vertexIndex) => { 'use gpu'; return d.vec4f(meshPool.vertexAt(vertexIndex).normal, 0); }, material: (_point, chip, base, carried) => { 'use gpu'; const broken = carried.y < -0.5 || carried.x < -0.5; const color = std.select(pigmentOf(base.color, chip.seed), LIMESTONE * 0.8, broken); return materials.glossy(color, roughnessOf(chip.seed)); },});const random = (low: number, high: number) => low + Math.random() * (high - low);/** Lays a dust patch flat on the floor, turned and stretched by its seed. */const lying = (dust: particles.Particle, corner: d.v2f) => { 'use gpu'; const angle = dust.seed * 6.283; const along = d.vec3f(std.cos(angle), 0, std.sin(angle)); const across = d.vec3f(-along.z, 0, along.x); const stretch = 1.2 + std.fract(dust.seed * 7.31) * 0.6; return dust.position + (along * (corner.x * stretch) + across * corner.y) * (dust.size * 0.5);};/** Shades a dust patch with a ragged edge that spreads and thins with its age. */const ragged = (dust: particles.Particle, local: d.v2f) => { 'use gpu'; const aged = dust.age / dust.life; const angle = std.atan2(local.y, local.x); const edge = 1 + 0.25 * std.sin(angle * 3 + dust.seed * 40) + 0.12 * std.sin(angle * 5 + dust.seed * 17); const reach = std.length(local) / (edge * (0.6 + 0.4 * std.sqrt(aged))); const body = std.saturate(1 - reach); const alpha = dust.color.w * body * body * (1 - aged) * std.smoothstep(0, 0.1, aged); return d.vec4f(dust.color.xyz * alpha, alpha);};export interface ShardsOptions { /** The length of a step in seconds; see `particles.Options.step`. */ readonly step: number; readonly camera: CameraBase; /** Frame-clock time in seconds. */ readonly time: TgpuUniform<d.F32>; /** The pool holding `chip`. */ readonly pool: meshPool.MeshPool; /** A unit box, which a chip stretches. */ readonly chip: meshPool.Mesh;}/** Breaks cleared blocks into painted stone chips and a little sandstone dust. */export function createShards(root: TgpuRoot, options: ShardsOptions) { const { camera, step } = options; // One piece can clear at most one layer per cube, and all cleared blocks break together const capacity = CHIPS * PIECE * LAYER; const chips = instances.create(root, options.pool, { kind: Chip, capacity }); const timed: Inject = (cfg) => cfg.with(clock, options.time); const ids = Array.from({ length: capacity }, () => chips.add(options.chip, { ...materials.matte(PALETTE[0]!), bornAt: -1e6, life: 1 }), ); let next = 0; let brokeAt = -Infinity; const dust = particles.create(root, camera, { step, capacity: PIECE * LAYER, blend: 'over', bursts: PIECE * LAYER, place: lying, shade: ragged, settings: { gravity: [0, 0, 0], drag: 3 }, }); const chip = { position: [0, 0, 0] as [number, number, number], velocity: [0, 0, 0] as [number, number, number], spin: [0, 0, 0, 0] as [number, number, number, number], scale: [0, 0, 0] as [number, number, number], color: PALETTE[0]!, bornAt: 0, life: 0, rest: 0, seed: 0, }; const destroy = () => { chips.destroy(); dust.destroy(); }; return { chips: chips.renderer().pipe(timed), /** Whether any chip is still moving or shrinking at `time`, so shadows must follow it. */ flying: (time: number) => time - brokeAt < LIFE[1], drawings: [dust.drawing], /** Breaks the block at cell `x, y, z`, of kind `kind`, at `time`. */ break(x: number, y: number, z: number, kind: number, time: number) { chip.color = PALETTE[kind]!; chip.bornAt = time; brokeAt = time; chip.rest = y; for (let i = 0; i < CHIPS; i++) { const out = [random(-0.35, 0.35), random(-0.35, 0.35), random(-0.35, 0.35)]; for (let axis = 0; axis < 3; axis++) { chip.position[axis] = [x, y, z][axis]! + 0.5 + out[axis]!; chip.scale[axis] = random(CHIP.small[axis]!, CHIP.large[axis]!); } chip.velocity[0] = out[0]! * 4 + random(-0.6, 0.6); chip.velocity[1] = random(2, 4.5); chip.velocity[2] = out[2]! * 4 + random(-0.6, 0.6); const axis = [random(-1, 1), random(-1, 1), random(-1, 1)]; const length = Math.hypot(...axis) || 1; chip.spin = [axis[0]! / length, axis[1]! / length, axis[2]! / length, random(4, 11)]; chip.life = random(LIFE[0], LIFE[1]); chip.seed = Math.random(); chips.write(ids[next]!, chip); next = (next + 1) % capacity; } dust.burst({ count: 1, position: [x + 0.5, y + 0.1, z + 0.5], area: [0.4, 0, 0.4], scatter: 0.4, size: 1.1, life: 1.5, variance: 0.4, color: SAND, }); }, /** Uploads the chips broken since the last frame. */ update() { chips.flush(); }, step(pass: TgpuComputePass) { dust.step(pass); }, spawn(pass: TgpuComputePass) { dust.spawn(pass); }, compact(pass: TgpuComputePass, ahead: number) { dust.compact(pass, ahead); }, initAsync: () => dust.initAsync(), destroy, [Symbol.dispose]: destroy, };}controls.ts
import type { Tap } from '../ui.ts';import type { createBlockfall } from './game.ts';const QUARTER = Math.PI / 2;type Direction = readonly [x: number, z: number];type Blockfall = ReturnType<typeof createBlockfall>;/** Rotation axis and turn for tipping a piece along a grid direction. */const axisAlong = ([x, z]: Direction): [axis: number, turn: number] => (x !== 0 ? [0, x] : [2, z]);/** * The game's buttons, as `ui.ts` declares them. Moves follow the camera's goal, so they turn with a * camera turn at once. */export function createControls(game: Blockfall, view: { readonly goal: { yaw: number } }) { const along = (act: (away: Direction, right: Direction) => void): Tap => ({ press() { const quarter = Math.round(view.goal.yaw / QUARTER); const sin = Math.round(Math.sin(quarter * QUARTER)); const cos = Math.round(Math.cos(quarter * QUARTER)); act([-sin, -cos], [cos, -sin]); }, }); const turn = (by: number): Tap => ({ press: () => (view.goal.yaw += by * QUARTER) }); return { away: along((away) => game.move(...away)), near: along(([x, z]) => game.move(-x, -z)), left: along((_, [x, z]) => game.move(-x, -z)), right: along((_, right) => game.move(...right)), spin: { press: () => game.rotate(1, 1) }, roll: along((_, right) => game.rotate(...axisAlong(right))), tip: along((away) => game.rotate(...axisAlong(away))), drop: { press: () => game.drop() }, turnLeft: turn(-1), turnRight: turn(1), };}stage.ts
import { environment, lighting, particles, table, type CameraBase } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { d, std, type TgpuRoot } from 'typegpu';type Vec3 = [number, number, number];export const cube = meshes.box();export const STAGE = { settings: { // A cool fill from high behind the well, its rim light and shadow sunDirection: [0.35, 0.9, -0.75], sunIntensity: 1.2, sunColor: [0.55, 0.65, 1], skyTop: [0.05, 0.065, 0.14], skyBottom: [0.03, 0.03, 0.045], skyIntensity: 1, skyReflection: 1, fogDensity: 0, }, plinth: srgbToLinear(d.vec3f(0.09, 0.085, 0.11)), finish: { exposure: 1.45, vignette: 0.3 }, bloom: { threshold: 1.4 },} as const;/** Gradient colours, from looking straight down to above the horizon. */const DEEP = srgbToLinear(d.vec3f(0.05, 0.06, 0.08));const HIGH = srgbToLinear(d.vec3f(0.13, 0.15, 0.2));/** The dark gradient the well floats in. */const gradient = (direction: d.v3f) => { 'use gpu'; return std.mix(DEEP, HIGH, std.smoothstep(-0.9, 0.5, direction.y));};export const backdrop = environment.background(gradient);/** The warm key, from high at the front left, out of view, and where it aims, low in the well. */const KEY = { position: [-9, 15, 14] as Vec3, aim: [3, 3.5, 3] as Vec3, color: [1, 0.68, 0.42] as Vec3, intensity: 320, range: 45, cone: lighting.cone(0.55, 0.85),};/** Embers drifting up through the space around the well, from fires out of view below. */const EMBERS = { position: [3, -3, 3], area: [7, 4, 7], velocity: [0, 1.1, 0], scatter: 0.35, color: [3, 1.1, 0.25, 1], size: 0.05, life: 14, variance: 0.5,} as const;export interface StageOptions { readonly camera: CameraBase; /** The length of a step in seconds; see `particles.Options.step`. */ readonly step: number;}/** The well's light, a warm spot out of view, and the embers drifting past it. */export function createStage(root: TgpuRoot, options: StageOptions) { const lamps = table.create(root, lighting.SpotLight, { capacity: 1, defaults: { ...lighting.WHITE_SPOT }, }); const toward = KEY.aim.map((at, axis) => at - KEY.position[axis]!); const length = Math.hypot(...toward); const key = lamps.add({ position: KEY.position, aim: d.vec3f(...(toward.map((v) => v / length) as Vec3)), cone: KEY.cone, color: KEY.color, intensity: KEY.intensity, range: KEY.range, }); lamps.flush(); const embers = particles.create(root, options.camera, { capacity: 128, step: options.step, shade: particles.glowingDisc, label: 'embers', settings: { gravity: [0, 0, 0], drag: 0 }, }); // The space starts with embers already risen through it, then more drift up from below embers.burst({ ...EMBERS, count: 48, position: [3, 5, 3], area: [7, 8, 7], aged: true }); embers.stream(4, { ...EMBERS }); const destroy = () => { lamps.destroy(); embers.destroy(); }; return { lamps, key, embers, destroy };}well.ts
import { cameras, passes } from '@typegpu/kit';import { meshes } from '@typegpu/kit/geometry';import { srgbToLinear } from '@typegpu/color';import { d, std } from 'typegpu';/** The gold of the well's edges and cell lines, a little brighter than white so the edges glow. */const GOLD = srgbToLinear(d.vec3f(1, 0.78, 0.48)).mul(1.4);/** The faint cool tint of the glass walls. */const GLASS = srgbToLinear(d.vec3f(0.45, 0.55, 0.75));/** The walls' fill, their cell lines and their edges, as opacities. */const OPACITY = { walls: 0.045, lines: 0.11, edges: 0.75 } as const;/** Cell line and edge widths in pixels. */const STROKE = { lines: 1.1, edges: 2.2 } as const;/** The fewest world units a pixel spans in the grid's distances. */const SMALLEST_PIXEL = 1e-6;const cube = meshes.unindexed(meshes.box());const Corner = d.struct({ position: d.vec4f, world: d.vec3f, normal: d.vec3f });const Seen = d.struct({ color: d.vec4f });/** * Draws the well's far glass walls with a faint grid of its cells and gold edges, so its limits * read from any side without standing between the camera and the play. The plinth carries the * floor's grid. Add it after the solid blocks: it blends over them and writes no depth. */export function createWell( size: readonly [width: number, height: number, depth: number],): passes.Drawing<typeof Corner, d.Infer<typeof Seen>> { const extent = d.vec3f(...size); return { Vertex: Corner, at: (vertexIndex) => { 'use gpu'; const corner = cube.vertexAt(vertexIndex); const world = (corner.position + 0.5) * extent; return Corner({ position: cameras.current.$.viewProjection * d.vec4f(world, 1), world, normal: corner.normal, }); }, surfaceOf: (corner) => { 'use gpu'; const world = corner.world; // World units per pixel. The axis along the normal is constant across a face, so the // smallest pixel keeps it from dividing by zero const pixel = std.max(std.fwidth(world), d.vec3f(SMALLEST_PIXEL)); const across = 1 - std.abs(corner.normal); // Pixel distance to the cell lines and to the face's edges, along the face's two axes const cells = std.abs(std.fract(world + 0.5) - 0.5) / pixel; const reach = across * STROKE.lines - cells; // The plinth's brass border is the walls' foot, so neither a cell line nor an edge doubles it const foot = std.select(reach.y, d.f32(-1), world.y < 0.5); const lines = std.saturate(std.max(std.max(reach.x, foot), reach.z)); // Interpolation can land a hair outside a wall, so distances to the sides start at zero const sides = std.min(world, extent - world); const inside = std.max(d.vec3f(sides.x, extent.y - world.y, sides.z), d.vec3f(0)); const rim = across * STROKE.edges - inside / pixel; const edges = std.saturate(std.max(std.max(rim.x, rim.y), rim.z)); // The plinth carries the floor and the open top has no glass, and the walls fade upward so the pile carries the light const wall = std.select(d.f32(1), d.f32(0), std.abs(corner.normal.y) > 0.5); const fade = std.mix(1, 0.35, std.saturate(world.y / extent.y)); const glow = std.max(lines * OPACITY.lines * fade, edges * OPACITY.edges); const alpha = std.saturate(OPACITY.walls * fade + glow) * wall; const tint = std.mix(GLASS, GOLD, glow / std.max(alpha, 1e-4)); return Seen({ color: d.vec4f(tint * alpha, alpha) }); }, color: passes.shaded, translucent: true, depth: { depthWriteEnabled: false, depthCompare: 'less' }, // Front-face culling shows the inside of the far walls primitive: { cullMode: 'front' }, draw: (pass) => pass.draw(cube.vertexCount), };}ui.ts
import { buttons, text } from '../ui.ts';export const ui = { score: text({ corner: 'top-right' }), // Dragging the board orbits it, so the pads leave out the turns controls: buttons( { away: { label: '▲', name: 'away', keys: ['ArrowUp'] }, near: { label: '▼', name: 'near', keys: ['ArrowDown'] }, left: { label: '◀', name: 'left', keys: ['ArrowLeft'] }, right: { label: '▶', name: 'right', keys: ['ArrowRight'] }, spin: { label: 'spin', keys: ['KeyZ'] }, roll: { label: 'roll', keys: ['KeyX'] }, tip: { label: 'tip', keys: ['KeyC'] }, drop: { label: 'drop', keys: ['Space'] }, turnLeft: { label: 'Q', name: 'turn left', keys: ['KeyQ'] }, turnRight: { label: 'E', name: 'turn right', keys: ['KeyE'] }, }, { left: [ [null, 'away', null], ['left', 'near', 'right'], ], right: [['spin', 'roll', 'tip'], ['drop']], }, ),};