Hex isle
CC0 KayKit hex tiles form an island through a day. The glTF reader loads models and textures. A moving sun casts shadows and the sky passes through sunset and a blue hour to night. One instance kind spins windmills, drifts clouds lit in the colour of the sky and lights windows at dusk. Depth of field blurs above and below a band so the island looks like a miniature, or, set to the pointer, focuses on what is under it.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- island.tsBuilds
maparound the origin with sea extending toseaworld units. - isle.tsStores all island models in one mesh pool and all pieces in one instance set, with one lamp per house.
- sky.tsWrites lighting for
hour, from 0 to 24, intoout. - assets.ts
Kit modules bloom, cameras, dof, environment, finish, instances, lighting, meshPool, owner, passes, pipelines, shading, table, targets. Also @typegpu/color, math, typegpu.
scene.ts
import { bloom, cameras, dof, finish, lighting, owner, passes, pipelines, table, targets, type Frame, type meshPool, type Surface,} from '@typegpu/kit';import { d, std, type TgpuRoot } from 'typegpu';import type { Pointing } from '../contract.ts';import type { Settings } from '../settings.ts';import type { ModelName } from './island.ts';import { createIsle } from './isle.ts';import { createDaylight, daylight } from './sky.ts';const SAMPLES = 4;const HDR = 'rgba16float';/** Hours in a day, where the hour wraps. */const DAY = 24;/** The band a miniature keeps sharp, in picture heights on each side of its middle. */const BAND = 0.07;/** How fast the blur grows away from the band, in picture heights per picture height. */const TILT = 0.06;/** The middle of the band, a little below the island's centre, where the land is nearest. */const MIDDLE = 0.54;type Focus = 'miniature' | 'pointer' | 'off';const Lens = d.struct({ /** Where the pointer focuses, from 0 to 1 over the picture. */ pointer: d.vec2f, /** 1 to focus under the pointer, 0 for a miniature's band. */ pointing: d.u32,});/** * Keeps a band across the island sharp and blurs above and below it, as a tilted lens does, so * the island reads as a model on a table. Below the band is nearer the eye, so it blurs in front. */const miniature = (uv: d.v2f) => { 'use gpu'; const away = uv.y - MIDDLE; return std.sign(-away) * std.max(std.abs(away) - BAND, 0) * TILT;};export interface SceneOptions { /** Each model as `gltf.read` returns it. */ readonly models: Readonly<Record<ModelName, meshPool.ModelData>>; /** Receives compilation progress as a fraction after each part finishes. */ readonly onProgress?: (fraction: number) => void;}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 sky = createDaylight(); const view = cameras.orbit(root, { size: surface, target: [0, 0.3, 0], distance: 24, yaw: 0.35, pitch: 0.55, pitchRange: [0.1, 1.4], distanceRange: [6, 40], }); const time = root.createUniform(d.f32); const houses = own( table.create(root, lighting.PointLight, { capacity: 16, defaults: { ...lighting.WHITE } }), ); const light = own(lighting.create(root, { points: { table: houses }, shadow: { extent: 26 } })); // `daylight` updates the horizon clear colour in place const scene = passes .create( root, screen, { color: passes.color, depth: passes.depth }, { resolve: { color: 'hdr' }, clear: sky.horizon }, ) .pipe(view.inject()) .beneath(light.inject()); const isle = own(createIsle(root, { houses, time, models: options.models })); const shown = scene.add(isle.renderer); const casters = [light.shadow.add(isle.renderer)]; /** The isle's depth again at half resolution, as the scene's depth is multisampled. */ 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 shapes = [prepass.add(isle.renderer)]; const lens = root.createUniform(Lens, { pointer: d.vec2f(0.5), pointing: 0 }); const focusing = (distance: number, uv: d.v2f) => { 'use gpu'; if (lens.$.pointing === 1) return dof.thinLens(distance, dof.distanceAt(lens.$.pointer)); return miniature(uv); }; const blur = own( dof.create(root, view, { source: () => screen.textures.hdr, depth: () => early.textures.depth, radius: focusing, }), ); const look = { exposure: finish.defaults.exposure, threshold: bloom.defaults.threshold, glowIntensity: finish.defaults.glowIntensity, vignette: finish.defaults.vignette, }; const values = { hour: 16.5, speed: 0.3, paused: false, /** Blurs above and below a band of the island, focuses under the pointer, or keeps it sharp. */ focus: 'miniature' as Focus, /** The lens opening for focusing under the pointer, in world units. */ aperture: 1.5, ...look, }; // Bloom and the final pass read the depth of field's output while it is on const picture = () => (values.focus === 'off' ? screen.textures.hdr : blur.output); const glow = own(bloom.create(root, { source: picture })); const final = own(finish.create(root, { format: surface.format, source: picture, glow })); const writeDay = () => { daylight(values.hour, sky); light.settings.patch(sky); isle.lightUp(sky.lamps); }; writeDay(); const writeLook = () => { final.settings.patch(values); glow.settings.patch(values); }; const writeLens = () => { lens.patch({ pointing: values.focus === 'pointer' ? 1 : 0 }); blur.settings.patch({ aperture: values.aperture }); }; writeLens(); const looks = { folder: 'look' } as const; const settings: Settings<typeof values> = { values, knobs: { hour: { min: 0, max: DAY, step: 0.01, live: true }, speed: { label: 'hours per second', min: 0, max: 2, step: 0.05 }, paused: {}, focus: { label: 'depth of field', options: ['miniature', 'pointer', 'off'] }, aperture: { label: 'aperture under the pointer', min: 0, max: 4, step: 0.05 }, exposure: { ...looks, min: 0.2, max: 4, step: 0.05 }, threshold: { ...looks, min: 0, max: 3, step: 0.05 }, glowIntensity: { ...looks, min: 0, max: 1, step: 0.01 }, vignette: { ...looks, min: 0, max: 1, step: 0.01 }, }, write(key) { if (key === undefined || key === 'hour') writeDay(); if (key === undefined || key === 'focus' || key === 'aperture') writeLens(); if (key === undefined || key in look) writeLook(); }, }; /** Hovering focuses on what is under the pointer, in picture coordinates. */ const pointing = { move(x, y) { lens.patch({ pointer: d.vec2f(x, y) }); }, } satisfies Pointing; const parts = [scene, light.shadow, prepass, blur, glow, final]; await Promise.all([isle.ready, pipelines.compile(parts, options)]); return { view, settings, pointing, frame(frame: Frame) { time.write(frame.time); view.update(frame.dt); const hours = values.paused ? 0 : frame.dt * values.speed; if (hours > 0) { values.hour = (values.hour + hours) % DAY; writeDay(); } isle.flush(); screen.fit(); light.shadow.record(frame, casters); const blurring = values.focus !== 'off'; if (blurring) { early.fit(); prepass.record(frame, shapes); } scene.record(frame, shown); if (blurring) blur.record(frame); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}island.ts
/** Binary glTF models from Kay Lousberg's KayKit Medieval Hexagon Pack (CC0). */export const MODELS = [ 'grass', 'water', 'coast-a', 'coast-b', 'coast-c', 'coast-d', 'home-a', 'home-b', 'church', 'tavern', 'windmill', 'windmill-fan', 'well', 'market', 'blacksmith', 'trees-large', 'trees-medium', 'tree', 'mountain', 'hills', 'rock', 'cloud-big', 'cloud-small',] as const;export type ModelName = (typeof MODELS)[number];type Vec3 = [number, number, number];/** * Maps hex tiles to characters spaced two columns apart. Odd rows shift one character right, * giving horizontal and diagonal neighbours. Land tiles start as grass, sea-facing edges become * beaches, and remaining tiles become sea. */export const MAP = [ ' . . . . ', ' . T ^ Y . . ', ' . Y ^ ^ T T . ', ' . . n T . m . . ', ' . . h . c . y . . ', ' . H w t k . H . ', ' . b . . h . . ', ' . y . r . . ', ' . . . . ',];const STANDING: Record<string, ModelName> = { h: 'home-a', H: 'home-b', c: 'church', t: 'tavern', m: 'windmill', w: 'well', k: 'market', b: 'blacksmith', T: 'trees-large', Y: 'trees-medium', y: 'tree', '^': 'mountain', n: 'hills', r: 'rock',};/** * Buildings with illuminated windows. Excludes the market because its awning shares the window * atlas cell. */const HOUSES = new Set<ModelName>([ 'home-a', 'home-b', 'church', 'tavern', 'windmill', 'blacksmith',]);/** Neighbour offsets in map-character coordinates, starting at +x and turning toward +z. */const AROUND = [ [2, 0], [1, 1], [-1, 1], [-2, 0], [-1, -1], [1, -1],] as const;/** Beach models indexed by exposed edge count and first exposed edge. */const BEACHES = [ { model: 'coast-a', first: 1 }, { model: 'coast-b', first: 1 }, { model: 'coast-c', first: 0 }, { model: 'coast-d', first: 5 },] as const;/** Windmill hub position from the glTF nodes. */const HUB: Vec3 = [0, 0.957, 0.332];/** Radians a second the windmill's fan turns. */const FAN_SPIN = 1.2;/** Minimum and maximum window brightness across five night-time levels. */const GLOW = [0.6, 1] as const;/** World units above a house's tile where its lamp hangs. */const LAMP_HEIGHT = 0.4;/** World units from the island's centre that the sea reaches. */const SEA = 12;/** World units the clouds drift before they come round again. */export const LOOP = 44;const CLOUDS = [ { model: 'cloud-big', position: [-9, 5.5, -3], drift: 0.5 }, { model: 'cloud-small', position: [4, 6.2, 4], drift: 0.65 }, { model: 'cloud-big', position: [16, 5.2, 7], drift: 0.55 }, { model: 'cloud-small', position: [-18, 6.5, 1], drift: 0.6 },] as const;/** Cloud scale multiplier relative to the KayKit model. */const CLOUD_SCALE = 1.3;export interface Piece { readonly model: ModelName; readonly position: Vec3; /** Radians around +y. */ readonly yaw: number; readonly scale?: number; /** Radians a second about the mesh's z axis. */ readonly spin?: number; /** World units a second along +x, round the loop. */ readonly drift?: number; /** Window brightness at night. */ readonly glow?: number;}export interface Island { readonly pieces: readonly Piece[]; /** Lamp position above each house. */ readonly lamps: readonly Vec3[];}const turned = ([x, y, z]: Vec3, yaw: number): Vec3 => [ x * Math.cos(yaw) + z * Math.sin(yaw), y, z * Math.cos(yaw) - x * Math.sin(yaw),];/** Builds `map` around the origin with sea extending to `sea` world units. */export function layout(map: readonly string[] = MAP, sea = SEA): Island { const land = new Map<string, string>(); let left = Infinity; let right = -Infinity; map.forEach((line, row) => { for (let column = 0; column < line.length; column++) { const tile = line[column]!; if (tile === ' ') continue; if ((column + row) % 2 !== 0) { throw new Error(`The tile at column ${column}, row ${row} is off the grid.`); } if (tile !== '.' && !STANDING[tile]) throw new Error(`No model stands for '${tile}'.`); land.set(`${column},${row}`, tile); left = Math.min(left, column); right = Math.max(right, column); } }); const middle = { column: (left + right) / 2, row: (map.length - 1) / 2 }; const at = (column: number, row: number): Vec3 => [ column - middle.column, 0, (row - middle.row) * Math.sqrt(3), ]; const pieces: Piece[] = []; const lamps: Vec3[] = []; for (const [key, tile] of land) { const [column, row] = key.split(',').map(Number) as [number, number]; const position = at(column, row); const shore = AROUND.map(([dc, dr]) => !land.has(`${column + dc},${row + dr}`)); const edges = shore.filter(Boolean).length; if (edges === 0) { pieces.push({ model: 'grass', position, yaw: 0 }); } else { // Rotate the beach to align its exposed edges with the map const first = shore.findIndex((wet, edge) => wet && !shore[(edge + 5) % 6]); const beach = BEACHES[edges - 1]; const run = shore.every((wet, edge) => wet === (edge - first + 6) % 6 < edges); if (!beach || !run || tile !== '.') { throw new Error( tile === '.' ? `The tile at column ${column}, row ${row} has no beach that fits its coast.` : `The ${STANDING[tile]} at column ${column}, row ${row} stands on a beach.`, ); } pieces.push({ model: beach.model, position, yaw: ((beach.first - first) * Math.PI) / 3 }); } const model = STANDING[tile]; if (!model) continue; // Vary rotation and window brightness by position const yaw = (((column * 3 + row * 5) % 6) * Math.PI) / 3; const step = ((column * 7 + row * 3) % 5) / 4; const glow = HOUSES.has(model) ? GLOW[0] + (GLOW[1] - GLOW[0]) * step : 0; pieces.push({ model, position, yaw, glow }); if (HOUSES.has(model)) lamps.push([position[0], LAMP_HEIGHT, position[2]]); if (model === 'windmill') { const [x, y, z] = turned(HUB, yaw); pieces.push({ model: 'windmill-fan', position: [position[0] + x, y, position[2] + z], yaw, spin: FAN_SPIN, }); } } const rows = Math.ceil(sea / Math.sqrt(3)); for (let row = Math.round(middle.row) - rows; row <= middle.row + rows; row++) { for (let column = Math.floor(middle.column - sea); column <= middle.column + sea; column++) { if ((column + row) % 2 !== 0 || land.has(`${column},${row}`)) continue; const position = at(column, row); if (Math.hypot(position[0], position[2]) <= sea) { pieces.push({ model: 'water', position, yaw: 0 }); } } } for (const cloud of CLOUDS) { pieces.push({ ...cloud, position: [...cloud.position], yaw: 0, scale: CLOUD_SCALE }); } return { pieces, lamps };}isle.ts
import { srgbToLinear } from '@typegpu/color';import { instances, meshPool, owner, shading, type lighting } from '@typegpu/kit';import { d, std, tgpu, type TgpuRoot, type TgpuUniform } from 'typegpu';import { layout, LOOP, MODELS, type ModelName } from './island.ts';/** Seconds since the start, for the fan and the clouds. */const clock = tgpu.accessor(d.f32);/** Night-light factor, 0 by day and 1 at night. */const lamps = tgpu.accessor(d.f32);const WARM = srgbToLinear(d.vec3f(1, 0.6, 0.28));/** Window brightness relative to white at full glow. Values above white produce bloom. */const BLAZE = 4;/** House lamp colour and range at night. */const LAMP = { range: 2.2, intensity: 0.4 };/** * Clouds take on the colour of the air, most on their undersides and on the side facing the sun * or moon, which still lights them while it sits low. They carry a share of the sunlight through * to their shaded sides, so they stay soft against the sky at every hour. */const CLOUD = { air: 0.3, under: 0.6, lit: 0.8, through: 0.15 };/** * KayKit's atlas is 8 cells across and 4 down. The window panes are the cell at column 6, row 1, * and the water, of the sea and of the beaches alike, the cell at column 1, row 1. */const ATLAS = d.vec2f(8, 4);const PANE = d.vec2f(6, 1);const WATER = d.vec2f(1, 1);/** Glossier water roughness, to catch sunlight. */const SEA_ROUGHNESS = 0.2;/** Whether a KayKit atlas uv falls on `cell`. */const onCell = (uv: d.v2f, cell: d.v2f) => { 'use gpu'; return std.all(std.eq(std.floor(uv * ATLAS), cell));};/** * Deforms island instances for windmill rotation and cloud drift, lights windows, and lights * clouds from the open sky around them. */const Piece = instances.define('Piece', { host: { spin: d.f32, drift: d.f32, glow: d.f32 }, deform: (vertex, piece) => { 'use gpu'; const angle = clock.$ * piece.spin; const c = std.cos(angle); const s = std.sin(angle); const at = vertex.position; const spun = d.vec3f(at.x * c - at.y * s, at.x * s + at.y * c, at.z); if (piece.drift === 0) return spun; // Divide world displacement by cloud scale because deformation uses unrotated local // coordinates const travelled = std.fract((piece.position.x + clock.$ * piece.drift) / LOOP + 0.5) - 0.5; return spun + d.vec3f((travelled * LOOP - piece.position.x) / piece.scale.x, 0, 0); }, material: (point, piece, base) => { 'use gpu'; const pane = onCell(point.uv, PANE); const burning = std.select(d.f32(0), piece.glow * lamps.$ * BLAZE, pane); // The lighting's fog colour holds the colour of the air, which the sky shows behind the clouds const env = shading.settings(); const under = 0.5 - point.normal.y * 0.5; const lit = std.saturate(std.dot(point.normal, shading.towardsSun())); const air = env.fogColor * (CLOUD.air + CLOUD.under * under + CLOUD.lit * lit); const through = env.sunColor * (env.sunIntensity * CLOUD.through); const cloud = std.select(d.vec3f(), air + through, piece.drift !== 0); const shaded = shading.Material(base); shaded.emission = base.emission + WARM * burning + base.color * cloud; shaded.roughness = std.select(base.roughness, SEA_ROUGHNESS, onCell(point.uv, WATER)); return shaded; },});export interface IsleOptions { /** The lighting's table of point lights, which takes a lamp per house. */ readonly houses: lighting.PointLights; /** Frame-clock time in seconds. */ readonly time: TgpuUniform<d.F32>; /** Each model as `gltf.read` returns it. */ readonly models: Readonly<Record<ModelName, meshPool.ModelData>>;}/** * Stores all island models in one mesh pool and all pieces in one instance set, with one lamp * per house. */export function createIsle(root: TgpuRoot, options: IsleOptions) { const { houses: lights } = options; const own = owner(); const pool = own( meshPool.create(root, meshPool.roomFor(MODELS.map((name) => options.models[name]))), ); const models = new Map(MODELS.map((name) => [name, pool.add(options.models[name])])); const modelOf = (name: ModelName) => models.get(name)!; const island = layout(); const rows = island.pieces.reduce((sum, piece) => sum + modelOf(piece.model).meshes.length, 0); const pieces = own(instances.create(root, pool, { kind: Piece, capacity: rows })); for (const piece of island.pieces) { const { position, yaw, scale = 1, spin = 0, drift = 0, glow = 0 } = piece; const rotation = [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)] as const; pieces.add(modelOf(piece.model), { position, rotation, scale, spin, drift, glow }); } const color = [WARM.x, WARM.y, WARM.z] as const; const houses = island.lamps.map((position) => lights.add({ position, color, range: LAMP.range, intensity: 0 }), ); const intensity = lights.fields.intensity; const burning = root.createUniform(d.f32); const renderer = pieces .renderer() .pipe((cfg) => cfg.with(clock, options.time).with(lamps, burning)); let lit = 0; return { /** Settles when all model textures reach the GPU. */ ready: Promise.all([...models.values()].map((model) => model.ready)), /** Sets window and lamp brightness from 0, dark, to 1, fully lit. */ lightUp(level: number) { if (level === lit) return; lit = level; burning.write(level); for (const house of houses) intensity.set(house, level * LAMP.intensity); }, flush: () => { pieces.flush(); lights.flush(); }, renderer, destroy() { for (const house of houses) lights.remove(house); own.destroy(); }, };}sky.ts
import { environment } from '@typegpu/kit';import { srgbToLinear } from '@typegpu/color';import { vec3 } from 'math';import { d, std } from 'typegpu';type Rgb = [number, number, number];const linear = (r: number, g: number, b: number): Rgb => { const { x, y, z } = srgbToLinear(d.vec3f(r, g, b)); return [x, y, z];};/** * Sky presets at the sun heights where each holds, from night through the blue hour and sunset to * day, in sRGB converted to linear light. The sky between two presets blends them. */const MOMENTS = [ { height: -0.3, top: linear(0.08, 0.1, 0.2), bottom: linear(0.02, 0.02, 0.04), horizon: linear(0.07, 0.09, 0.19), }, { height: -0.1, top: linear(0.2, 0.22, 0.4), bottom: linear(0.05, 0.05, 0.08), horizon: linear(0.42, 0.4, 0.62), }, { height: 0.02, top: linear(0.36, 0.34, 0.52), bottom: linear(0.2, 0.13, 0.1), horizon: linear(0.8, 0.58, 0.56), }, { height: 0.35, top: linear(0.55, 0.68, 0.85), bottom: linear(0.32, 0.28, 0.22), horizon: linear(0.7, 0.8, 0.92), },] as const;/** Sun intensity above the air, which reddens and dims it towards the horizon. */const SUN_INTENSITY = 1.6;/** Linear moon colour converted from sRGB, and midnight intensity. */const MOON = { color: linear(0.55, 0.65, 0.9), intensity: 0.25 };/** The isle's latitude in radians, which leans the noon sun south, +z. */const LATITUDE = 0.42;/** Lighting at one hour: sun and sky settings, the background colour and the lamps' brightness. */export const createDaylight = () => ({ sunDirection: [0, 1, 0] as Rgb, sunColor: [1, 1, 1] as Rgb, sunIntensity: 1, skyTop: [0, 0, 0] as Rgb, skyBottom: [0, 0, 0] as Rgb, /** Background clear colour with alpha. */ horizon: [0, 0, 0, 1] as [number, number, number, number], /** The horizon's colour, which clouds take on where the sun leaves them. There is no fog. */ fogColor: [0, 0, 0] as Rgb, /** Night-light factor, 0 by day and 1 at night. */ lamps: 0,});export type Daylight = ReturnType<typeof createDaylight>;/** Writes `a` mixed `t` of the way to `b` into the first three components of `out`. */const mix = <T extends number[]>(out: T, a: Rgb, b: Rgb, t: number) => { for (let i = 0; i < 3; i++) out[i] = a[i]! + (b[i]! - a[i]!) * t; return out;};/** * Writes lighting for `hour`, from 0 to 24, into `out`. The sun rises at 6 in the east, +x, and * sets at 18, on an equinox day. It leans south, +z, at noon. At night, the moon uses the same * light from the opposite side. */export function daylight(hour: number, out: Daylight = createDaylight()): Daylight { const sun = environment.sunAt(hour, LATITUDE, 0); const height = sun.y; const up = height >= 0 ? 1 : -1; const direction = out.sunDirection; direction[0] = sun.x * up; direction[1] = sun.y * up; direction[2] = sun.z * up; const above = MOMENTS.findIndex((moment) => moment.height > height); const next = above === -1 ? MOMENTS.length - 1 : Math.max(above, 1); const [from, to] = [MOMENTS[next - 1]!, MOMENTS[next]!]; const t = std.smoothstep(from.height, to.height, height); mix(out.skyTop, from.top, to.top, t); mix(out.skyBottom, from.bottom, to.bottom, t); mix(out.horizon, from.horizon, to.horizon, t); mix(out.fogColor, from.horizon, to.horizon, t); // Fade each light at the horizon to hide the switch between sun and moon if (height >= 0) { const color = environment.sunColor(sun); out.sunColor[0] = color.x; out.sunColor[1] = color.y; out.sunColor[2] = color.z; out.sunIntensity = SUN_INTENSITY * std.smoothstep(0, 0.12, height); } else { vec3.copy(out.sunColor, MOON.color); out.sunIntensity = MOON.intensity * std.smoothstep(0, 0.2, -height); } out.lamps = std.smoothstep(0.15, -0.05, height); return out;}assets.ts
import { each, glb } from '../assets.ts';import { MODELS } from './island.ts';/** Each model of the island. */export const assets = { models: each(MODELS, (name) => glb(`models/${name}.glb`)) };