Cloister
A cloister at the blue hour, lit by the sky and eight lanterns that cast point shadows through its arcades. The shadows are drawn once, since nothing moves. The model is loaded as Blender exports it, Draco-compressed with WebP textures. Each texture's KHR_texture_transform tiles the paving and the stone roofs, turns the border and cuts the carved panels from one atlas. Its ambient occlusion is baked onto a second uv set, TEXCOORD_1, which the mesh pool keeps beside the first. The occlusion darkens only the sky's light, under the arches, along the walks and at the column bases, and a setting turns it off or shows it alone. The camera stays inside the garth, pulling in along its view where it would pass through the arcades. TAA and bloom finish the frame. The model, its border, its panels and its occlusion were made for the kit and are CC0. Its stone, plaster and paving are Poly Haven's CC0 scans Medieval Blocks 05 and White Plaster Rough 01 by Rob Tuytel and Monastery Stone Floor by Amal Kumar, its lawn is Grass Ground by Charlotte Baglioni, and its props are Poly Haven's CC0 Large Castle Door by Tina, Painted Wooden Bench by Kirill Sannikov, Wine Barrel 01 by James Ray Cock, Potted Plant 02, Shrub Sorrel 01 and Flower Empodium by Rico Cilliers.
Files, in reading order
Kit modules bindings, bloom, cameras, environment, finish, instances, lighting, meshPool, owner, passes, pipelines, shading, taa, table, targets, tone. Also typegpu.
scene.ts
import { bindings, bloom, cameras, environment, finish, instances, lighting, meshPool, owner, passes, pipelines, shading, table, taa, targets, tone, type Frame, type Inject, type Surface,} from '@typegpu/kit';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import type { Settings } from '../settings.ts';import { DUSK, sky } from './sky.ts';type Vec3 = [number, number, number];/** Where the model's empties `Lantern.001` to `Lantern.008` hang, in its metres: two in each walk. */export const LANTERNS: readonly Vec3[] = [ [-3, 2.48, -5.78], [3, 2.48, -5.78], [5.78, 2.48, -3], [5.78, 2.48, 3], [3, 2.48, 5.78], [-3, 2.48, 5.78], [-5.78, 2.48, 3], [-5.78, 2.48, -3],];const LANTERN = { color: [1, 0.5, 0.2] as Vec3, intensity: 1.8, range: 8 };/** The flame's flicker, as a share of its brightness. */const FLICKER = 0.12;/** The ways to show the occlusion the model bakes into its second uv set. */const SHOWN = ['baked', 'off', 'alone'] as const;/** The occlusion's brightness drawn alone, white where nothing hides the sky. */const ALONE = 0.3;const LOOK = { exposure: 1.3, glowIntensity: 0.25, vignette: 0.3 };const BLOOM = { threshold: 1.1 };/** The lanterns and the cloister never move, so each lantern's shadow is drawn once and kept. */const STILL = { redraw: () => false };/** Lowest eye height over the paving and the garth's planting, in metres. */const EYE = 0.6;/** Half the side of the square the eye stays in: the column line less the capitals and a margin. */const COURT = 3.9;const PITCH = [-0.3, 1.2] as const;const DISTANCE = [1.5, 7] as const;/** How far a ray from `from` may go `along` an axis before it crosses the court's side. */const across = (from: number, along: number) => along === 0 ? Infinity : (Math.sign(along) * COURT - from) / along;/** How far the eye may go from `target` along a yaw and pitch before it leaves the court. */const reach = (target: ArrayLike<number>, yaw: number, pitch: number) => Math.min(across(target[0]!, Math.sin(yaw)), across(target[2]!, Math.cos(yaw))) / Math.cos(pitch);const Shown = d.struct({ /** 1 to darken the sky's light by the baked occlusion, 0 to leave it out. */ baked: d.f32, /** 1 to draw the occlusion alone, as grey. */ alone: d.u32,});const shown = tgpu.accessor(Shown);/** * The cloister's stone, plaster and lanterns. Their colour textures read the first uv set through * each texture's transform, and their occlusion the second, as the model's materials say. */const Masonry = instances.define('Masonry', { material: (_point, _masonry, base) => { 'use gpu'; const made = shading.Material(base); made.occlusion = std.mix(1, base.occlusion, shown.$.baked); if (shown.$.alone === 1) { made.color = d.vec3f(); made.roughness = 1; made.metallic = 0; made.emission = d.vec3f(base.occlusion * ALONE); } return made; },});/** The lanterns' iron and glass, which hold the lights and so cast no shadow from them. */const isLantern = (mesh: meshPool.MeshData) => /glass|iron/i.test(mesh.material.name ?? '');const defaults = { /** Darkens the sky's light by the occlusion baked on the second uv set, or shows it alone. */ occlusion: 'baked' as (typeof SHOWN)[number], /** Draws each lantern's shadow; without them, light passes through the columns. */ shadows: true,};export interface SceneOptions { /** The cloister as `gltf.read` returns it. */ readonly model: meshPool.ModelData; /** Receives compilation progress as a fraction after each part finishes. */ readonly onProgress?: (fraction: number) => void;}/** * Lights a cloister at the blue hour with the sky and eight lanterns casting point shadows. Its * tiled paving, rotated border and atlas panels move their uvs with `KHR_texture_transform`, and * its ambient occlusion comes baked on a second uv set, which a setting turns off or shows alone. */export async function createScene(root: TgpuRoot, surface: Surface, options: SceneOptions) { const values = { ...defaults }; const own = owner(); const screen = own( targets.create(root, surface, { hdr: { format: 'rgba16float', sampled: true }, motion: { format: 'rg16float', sampled: true }, depth: { format: 'depth24plus', sampled: true }, }), ); const view = cameras.orbit(root, { size: surface, // Across the garth to the north walk's door, the well to one side target: [-1, 2.5, -2.2], distance: 6.8, yaw: 0.55, pitch: -0.06, pitchRange: PITCH, distanceRange: DISTANCE, floor: () => EYE, reach, near: 0.05, far: 100, }); // The model carries a second uv set, so the room it takes keeps one const { meshes, images } = options.model; const pool = own(meshPool.create(root, meshPool.roomFor([options.model]))); const masonry = pool.add({ meshes: meshes.filter((mesh) => !isLantern(mesh)), images }); const lanterns = pool.add({ meshes: meshes.filter(isLantern), images }); const walls = own(instances.create(root, pool, { kind: Masonry, capacity: meshes.length })); const glass = own(instances.create(root, pool, { kind: Masonry, capacity: meshes.length })); walls.add(masonry); glass.add(lanterns); const lamps = own( table.create(root, lighting.PointLight, { capacity: LANTERNS.length, defaults: { ...lighting.WHITE }, }), ); const light = own( lighting.create(root, { settings: DUSK, points: { table: lamps }, pointShadow: { count: LANTERNS.length, filter: 9, enabled: () => values.shadows }, }), ); const flames = LANTERNS.map((position) => lamps.add({ position, ...LANTERN })); light.pointShadow.cast(flames); const shared = bindings.share(root, { pointShadow: light.pointShadow.part }); const aa = own( taa.create(root, { source: () => screen.textures.hdr, motion: () => screen.textures.motion, depth: () => screen.textures.depth, camera: view, }), ); const scene = passes .create( root, screen, { hdr: passes.color, motion: taa.motion, depth: passes.depth }, { jitter: aa.jitter, shared }, ) .pipe(view.inject()) .beneath(light.inject()); const showing = root.createUniform(Shown, { baked: 1, alone: 0 }); const show: Inject = (cfg) => cfg.with(shown, showing); const stone = walls.renderer().pipe(show); const drawn = [ scene.add(stone), scene.add(glass.renderer().pipe(show)), scene.add(environment.background(sky)), ]; const cast = [light.pointShadow.add(stone)]; const glow = own(bloom.create(root, { source: aa, settings: BLOOM })); const final = own( finish.create(root, { format: surface.format, source: aa, tone: tone.aces, glow, settings: LOOK, }), ); const parts = [scene, light.pointShadow, aa, glow, final]; await Promise.all([masonry.ready, lanterns.ready, pipelines.compile(parts, options)]); const settings: Settings<typeof values> = { values, knobs: { occlusion: { label: 'baked occlusion (second uv set)', options: SHOWN }, shadows: { label: 'lantern shadows' }, }, write(key) { if (key === undefined || key === 'occlusion') { showing.write({ baked: values.occlusion === 'off' ? 0 : 1, alone: values.occlusion === 'alone' ? 1 : 0, }); } }, }; let seconds = 0; /** Each flame flickers on its own, out of step with the others. */ const flicker = () => { flames.forEach((flame, i) => { const t = seconds + i * 1.7; const wave = 0.6 * Math.sin(t * 9.1) + 0.3 * Math.sin(t * 23 + i) + 0.1 * Math.sin(t * 3.7); lamps.fields.intensity.set(flame, LANTERN.intensity * (1 + FLICKER * wave)); }); }; return { view, settings, frame(frame: Frame) { seconds += frame.dt; view.update(frame.dt); flicker(); lamps.flush(); walls.flush(); glass.flush(); screen.fit(); light.pointShadow.record(frame, cast, STILL); scene.record(frame, drawn); aa.record(frame); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}sky.ts
import { d, std } from 'typegpu';/** A deep blue overhead, paling to a mauve glow low over the roofs, as the sky in the blue hour. */const ZENITH = d.vec3f(0.012, 0.028, 0.1);const HORIZON = d.vec3f(0.11, 0.1, 0.2);const AFTERGLOW = d.vec3f(0.09, 0.04, 0.03);const GROUND = d.vec3f(0.01, 0.012, 0.025);/** * The court's light without a sun: the sky's blue from above and its dark bounce from the paving, * which the cloister's baked occlusion darkens, and a thin blue haze. */export const DUSK = { sunIntensity: 0, skyTop: [0.17, 0.23, 0.48], skyBottom: [0.045, 0.038, 0.05], skyIntensity: 1, skyReflection: 1, fogColor: [0.06, 0.07, 0.14], fogDensity: 0.01,} as const;/** The blue-hour sky in a direction, for `environment.background`. */export const sky = (look: d.v3f) => { 'use gpu'; const up = look.y; // The afterglow lingers in the west, -x, where the sun set const west = std.saturate(-look.x * 0.5 + 0.5); let above = std.mix(HORIZON, ZENITH, std.pow(std.saturate(up), 0.5)); above += AFTERGLOW * west * std.exp(-std.max(up, 0) * 8); const below = std.mix(HORIZON, GROUND, std.saturate(-up * 6)); return std.select(below, above, up > 0);};assets.ts
import { glb } from '../assets.ts';/** The cloister, its arcade, well and lanterns in one file. */export const assets = { model: glb('models/cloister.glb', { draco: true }) };