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

  1. scene.tsCreates the scene: its resources, its settings and what each frame records.
  2. island.tsBuilds map around the origin with sea extending to sea world units.
  3. isle.tsStores all island models in one mesh pool and all pieces in one instance set, with one lamp per house.
  4. sky.tsWrites lighting for hour, from 0 to 24, into out.
  5. 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`)) };