Classroom
A classroom in the afternoon, a low sun through its windows and six ceiling lamps. The sun's light and shadow are computed live. The rest is baked in Blender's Cycles onto the model's second uv set, TEXCOORD_1. One lightmap holds the sky's light through the windows and the sun's bounce, and another holds the lamps' light with its soft shadows and bounce. Each stores the logarithm of the light per channel and is unpacked into light at load. An ambient occlusion map on the same set is read as glTF's occlusion texture. The lightmaps light the reflections as well as the diffuse light, and the sun's shadow map draws once, since nothing moves. For the dust in the air, a grid of cells along the view samples the sun's shadow at a few spread points a frame, keeps each cell's earlier samples at their positions from the frame before, and sums the light in front of every pixel. Four thousand motes drift on Perlin noise and glint where the sun reaches them. Toggles turn off the baked daylight, leaving the live sky's, the baked occlusion, the lamps and the dust. Its floor tiles by KHR_texture_transform. TAA and bloom finish the frame. The room is Christophe Seux's Classroom from the Blender demo files, CC0, simplified and baked for the kit.
Files, in reading order
- scene.tsCreates the scene: its resources, its settings and what each frame records.
- air.tsThe dust in the classroom's air: beams where the sun's shadow lets its light through the windows, gathered in a grid along the view, and motes drifting through them.
- assets.ts
Kit modules bindings, bloom, cameras, clock, environment, finish, history, images, instances, lighting, memo, meshPool, owner, particles, passes, pipelines, shading, taa, targets, tone. Also @typegpu/noise, typegpu.
scene.ts
import { bindings, bloom, cameras, clock, environment, finish, images, instances, lighting, meshPool, owner, passes, pipelines, shading, taa, targets, tone, type Frame, type Surface,} from '@typegpu/kit';import { d, std, tgpu, type TgpuRoot } from 'typegpu';import type { Picture } from '../contract.ts';import type { Settings } from '../settings.ts';import { createAir } from './air.ts';type Vec3 = [number, number, number];/** * A low afternoon sun through the windows and a clear sky, as the daylight was baked under. The * sun's direct light and shadow stay live; the lightmap holds the sky's light and the sun's bounce. */const DAY = { sunDirection: [0.851, 0.431, -0.3], sunColor: [1, 0.86, 0.68], sunIntensity: 2.8, skyTop: [0.45, 0.55, 0.75], skyBottom: [0.18, 0.22, 0.3], skyIntensity: 1, skyReflection: 1,} as const;/** * The light the lightmaps span. Each channel stores the logarithm of its light between these in 8 * bits, which leaves alpha alone: React Native's decoder premultiplies by alpha and would lose any * light packed there. */const LOWEST = 1 / 1024;const HIGHEST = 4;/** Unpacks a lightmap's texel, a logarithm of light per channel, into light. */const unlogged = (texel: d.v4f) => { 'use gpu'; const stops = std.log2((HIGHEST + LOWEST) / LOWEST); return d.vec4f(std.exp2(texel.xyz * stops) * LOWEST - LOWEST, 1);};const LOOK = { exposure: 1, glowIntensity: 0.2, vignette: 0.25 };const BLOOM = { threshold: 1.2 };/** The room the camera stays inside, as its centre, its farthest reach and its eye height. */const ROOM = { center: [0.55, 1.2, 0.5] as Vec3, reach: 2.9, eye: 0.5 };/** The room's air, inside its walls, floor and ceiling, as its lowest and highest corners. */const AIR = { low: [-2.55, 0.02, -3.45] as Vec3, high: [3.7, 2.95, 4.55] as Vec3 };/** The motes' simulation step in seconds. */const STEP = 1 / 30;const Switches = d.struct({ /** 1 lights the room by the baked daylight, 0 by the live sky. */ gi: d.u32, /** 1 darkens the light by the baked occlusion, 0 leaves it open. */ ao: d.u32, /** The lamps' brightness, as a share of the brightness they were baked at. */ lamps: d.f32,});const switches = tgpu.accessor(Switches);/** The daylight and the lamps' light, baked on the second uv set and unpacked to linear light. */const daylight = tgpu.accessor(d.texture2d(d.f32));const lampLight = tgpu.accessor(d.texture2d(d.f32));const lightmapFiltering = tgpu.accessor(d.sampler());/** * The room's light besides the sun's: the lamps', baked with their shadows and bounces, and the * daylight's, baked with the sky through the windows and the sun's bounce, or else the live sky's. * Baked light lights the reflections too, since indoors they see the room, not the sky. The bake * already holds the room's shadowing, so the occlusion darkens only the reflections. */const ambient: shading.AmbientHook = (material, point) => { 'use gpu'; const lamps = std.textureSampleLevel(lampLight.$, lightmapFiltering.$, point.uv1, 0).xyz * switches.$.lamps; if (switches.$.gi === 0) { return shading.hemisphere(material, point) + shading.surrounded(material, point, lamps, lamps); } const light = std.textureSampleLevel(daylight.$, lightmapFiltering.$, point.uv1, 0).xyz + lamps; return shading.surrounded(material, point, light, light);};/** The room, its furniture and the lamps' glowing globes, which burn as the lamps do. */const Room = instances.define('Room', { material: (_point, _room, base) => { 'use gpu'; const made = shading.Material(base); made.emission = base.emission * switches.$.lamps; if (switches.$.ao === 0) made.occlusion = 1; return made; },});const defaults = { /** Lights the room by the daylight baked on its second uv set, or by the live sky. */ gi: true, /** Darkens the light by the occlusion baked on the second uv set. */ ao: true, /** Lights the ceiling lamps, whose light comes baked on the second uv set. */ lamps: true, /** Draws the dust in the air: beams where the sun gets through the windows, and motes. */ sunbeams: true,};export interface SceneOptions { /** The classroom as `gltf.read` returns it. */ readonly model: meshPool.ModelData; /** The sky's light and the sun's bounce on the second uv set, as `unlogged` reads it. */ readonly daylight: Picture; /** The lamps' light, direct and bounced, on the second uv set, as `unlogged` reads it. */ readonly lamps: Picture; /** Receives compilation progress as a fraction after each part finishes. */ readonly onProgress?: (fraction: number) => void;}/** * Lights a classroom by a low sun through its windows and six ceiling lamps. The sun draws its * light and shadow live. The sky's light, the sun's bounce and the lamps' light come baked on the * model's second uv set, and settings turn the baked daylight, the occlusion and the lamps off. */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, target: ROOM.center, distance: 2.8, yaw: -0.65, pitch: 0.12, pitchRange: [-0.2, 0.45], distanceRange: [1, ROOM.reach], floor: () => ROOM.eye, fov: 1.1, near: 0.05, far: 60, }); // The model carries a second uv set, so the room it takes keeps one const pool = own(meshPool.create(root, meshPool.roomFor([options.model]))); const model = pool.add(options.model); const room = own( instances.create(root, pool, { kind: Room, capacity: options.model.meshes.length }), ); room.add(model); const light = own( lighting.create(root, { settings: DAY, shading: { ambient }, shadow: { center: ROOM.center, extent: 10, size: 2048, filter: 4 }, }), ); // Unpacked to linear light once, so the lightmaps filter as light. Sampled at their first level // only: the room shows them magnified const unpacked = { packed: unlogged, mips: false }; const lightmaps = { daylight: own(images.create(root, options.daylight, unpacked)), lamps: own(images.create(root, options.lamps, unpacked)), }; const switched = root.createUniform(Switches, { gi: 1, ao: 1, lamps: 1 }); const dayView = lightmaps.daylight.createView(d.texture2d(d.f32)); const lampView = lightmaps.lamps.createView(d.texture2d(d.f32)); const filtering = images.sampler(root, { wrap: 'clamp-to-edge' }); const shared = bindings.share(root, { shadow: light.shadow.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()) .pipe((cfg) => cfg .with(switches, switched) .with(daylight, dayView) .with(lampLight, lampView) .with(lightmapFiltering, () => filtering.$), ) .beneath(light.inject()); const sim = clock.fixedStep({ step: STEP }); const dust = own( createAir(root, { camera: view, light, room: AIR, screen, depth: () => screen.textures.depth, shared, step: STEP, }), ); const furniture = room.renderer(); // The sky behind the windows is the gradient the daylight was baked under const clear = [scene.add(furniture), scene.add(environment.background(environment.gradient))]; const dusty = [...clear, scene.add(dust.drawing)]; const cast = [light.shadow.add(furniture)]; 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.shadow, dust, aa, glow, final]; await Promise.all([model.ready, pipelines.compile(parts, options)]); const settings: Settings<typeof values> = { values, knobs: { gi: { label: 'baked GI' }, ao: { label: 'baked AO' }, lamps: { label: 'ceiling lamps' }, sunbeams: { label: 'sunbeams' }, }, write(key) { if (key === undefined || key === 'gi') switched.patch({ gi: values.gi ? 1 : 0 }); if (key === undefined || key === 'ao') switched.patch({ ao: values.ao ? 1 : 0 }); if (key === undefined || key === 'lamps') switched.patch({ lamps: values.lamps ? 1 : 0 }); }, }; // Nothing in the room moves, so the sun's shadow draws once and keeps its map const still = { redraw: () => false }; return { view, settings, frame(frame: Frame) { view.update(frame.dt); room.flush(); if (values.sunbeams) { const steps = sim.advance(frame.dt); const compute = frame.encoder.beginComputePass(); dust.simulate(compute, steps, sim.ahead); compute.end(); } screen.fit(); light.shadow.record(frame, cast, still); scene.record(frame, values.sunbeams ? dusty : clear); if (values.sunbeams) dust.record(frame); aa.record(frame); glow.record(frame); final.record(frame, surface.view(frame)); }, destroy: own.destroy, };}air.ts
import { cameras, history, memo, Particle, particles, passes, pipelines, shading, targets, type CameraBase, type Depth, type Frame, type Lighting, type SharedBindings, type Targets,} from '@typegpu/kit';import { perlin3d, randf } from '@typegpu/noise';import { d, std, tgpu, type TgpuComputePass, type TgpuRoot } from 'typegpu';type Vec3 = readonly [number, number, number];/** * The air the beams light, as a grid of cells along the view: columns across the screen, and * slices spaced by distance, finer near the eye, from `NEAREST` to `FARTHEST` metres. Each frame * samples the sun's shadow at `SAMPLES` points in each cell and keeps `BLEND` of them over what * the cell held before. The blinds stripe the light finer than a cell, so a cell needs many * samples, spread over the frames by `SPREAD`, to settle. */const GRID = [160, 90, 64] as const;const NEAREST = 0.1;const FARTHEST = 14;const BLEND = 0.05;const SAMPLES = 4;const WORKGROUP = 8;/** Roberts' R3 steps, which move a cell's samples on each time so they cover it evenly. */const SPREAD = d.vec3f(0.8191725, 0.6710436, 0.5497005);/** How strongly dust scatters light forward, from 0, the same every way, towards 1. */const FORWARD = 0.4;/** The share of the sunlight crossing a metre of air that the dust scatters. */const DENSITY = 0.35;/** How many motes drift in the room, and how long each lives, in seconds. */const MOTES = 4000;const MOTE_LIFE = 40;/** A mote's width in metres and how much brighter it shines than the air around it. */const MOTE_SIZE = 0.004;const MOTE_GLOW = 6;/** How fast the air turns the motes, in metres per second squared, and over what span, in metres. */const SWIRL = 0.02;const EDDY = 1.5;const Air = d.struct({ /** The room's air as a box, its lowest and highest corners. */ low: d.vec3f, frame: d.f32, high: d.vec3f, /** The share of this frame's light in each cell, 1 when the grid holds none from before. */ blend: d.f32,});const air = tgpu.accessor(Air);const ScatterLayout = tgpu.bindGroupLayout({ before: { texture: d.texture3d(d.f32) }, into: { storageTexture: d.textureStorage3d('rgba16float', 'write-only') }, smooth: { sampler: 'filtering' },});const SumLayout = tgpu.bindGroupLayout({ scattered: { texture: d.texture3d(d.f32) }, summed: { storageTexture: d.textureStorage3d('rgba16float', 'write-only') },});const BeamsLayout = tgpu.bindGroupLayout({ summed: { texture: d.texture3d(d.f32) }, depth: { texture: d.textureDepth2d() }, smooth: { sampler: 'filtering' },});const Seen = d.struct({ color: d.vec4f });/** Henyey and Greenstein's phase function: the share of light scattered at an angle's cosine. */const phase = (cosine: number) => { 'use gpu'; const g2 = FORWARD * FORWARD; return (1 - g2) / (4 * Math.PI * std.pow(1 + g2 - 2 * FORWARD * cosine, 1.5));};/** The sunlight dust scatters towards an eye looking along `direction`, per metre of air lit. */const scatteredAlong = (direction: d.v3f) => { 'use gpu'; const env = shading.settings(); return ( env.sunColor * (env.sunIntensity * DENSITY * phase(std.dot(shading.towardsSun(), direction))) );};const sunlitAt = (position: d.v3f) => { 'use gpu'; const zero = d.vec2f(); const at = shading.Point({ position, normal: d.vec3f(), view: d.vec3f(), uv: zero, uv1: zero }); return shading.sunShare(at);};const clipOf = (world: d.v3f) => { 'use gpu'; return cameras.current.$.viewProjection * d.vec4f(world, 1);};const clipBefore = history.previously(clipOf);/** The distance from the eye along the view of a slice's place in the grid, from 0 to 1. */const distanceAt = (slice: number) => { 'use gpu'; return NEAREST * std.pow(FARTHEST / NEAREST, slice);};const sliceAt = (distance: number) => { 'use gpu'; return std.log(std.max(distance, NEAREST) / NEAREST) / std.log(FARTHEST / NEAREST);};const rayAt = (uv: d.v2f) => { 'use gpu'; return cameras.rayOf(cameras.current.$, d.vec2f(uv.x * 2 - 1, 1 - uv.y * 2));};/** * Lights each cell of the grid by the sunlight the dust scatters towards the eye, sampled at a * few points in the cell, over what the cell held last frame where it was then. */const scatter = tgpu.computeFn({ workgroupSize: [WORKGROUP, WORKGROUP, 1], in: { id: d.builtin.globalInvocationId },})(({ id }) => { 'use gpu'; if (id.x >= GRID[0] || id.y >= GRID[1] || id.z >= GRID[2]) return; const cells = d.vec3f(GRID[0], GRID[1], GRID[2]); randf.seed3(d.vec3f(id)); const start = d.vec3f(randf.sample(), randf.sample(), randf.sample()); let light = d.vec3f(); for (let i = d.i32(0); i < SAMPLES; i++) { const at = (d.vec3f(id) + std.fract(start + (air.$.frame * SAMPLES + d.f32(i)) * SPREAD)) / cells; const ray = rayAt(at.xy); const ahead = std.dot(ray.direction, cameras.forwardOf(cameras.current.$)); const point = ray.origin + ray.direction * (distanceAt(at.z) / ahead); const inside = std.all(std.ge(point, air.$.low)) && std.all(std.le(point, air.$.high)); if (inside) light += scatteredAlong(ray.direction) * (sunlitAt(point) / SAMPLES); } // Find where the cell's centre was in last frame's grid const centre = (d.vec3f(id) + 0.5) / cells; const middle = rayAt(centre.xy); const along = distanceAt(centre.z) / std.dot(middle.direction, cameras.forwardOf(cameras.current.$)); const clip = clipBefore(middle.origin + middle.direction * along); const before = d.vec3f((clip.xy / clip.w) * d.vec2f(0.5, -0.5) + 0.5, sliceAt(clip.w)); const kept = std.all(std.ge(before, d.vec3f())) && std.all(std.le(before, d.vec3f(1))); if (kept) { const was = std.textureSampleLevel(ScatterLayout.$.before, ScatterLayout.$.smooth, before, 0); light = std.mix(was.xyz, light, air.$.blend); } std.textureStore(ScatterLayout.$.into, id, d.vec4f(light, 1));});/** Sums each column's cells from the eye outwards, so a cell holds the light in front of it. */const sum = tgpu.computeFn({ workgroupSize: [WORKGROUP, WORKGROUP, 1], in: { id: d.builtin.globalInvocationId },})(({ id }) => { 'use gpu'; if (id.x >= GRID[0] || id.y >= GRID[1]) return; const ray = rayAt((d.vec2f(id.xy) + 0.5) / d.vec2f(GRID[0], GRID[1])); const ahead = std.dot(ray.direction, cameras.forwardOf(cameras.current.$)); let total = d.vec3f(); let near = d.f32(0); for (let k = d.u32(0); k < GRID[2]; k++) { const far = distanceAt(d.f32(k + 1) / GRID[2]); const cell = std.textureLoad(SumLayout.$.scattered, d.vec3u(id.xy, k), 0).xyz; total += cell * ((far - near) / ahead); std.textureStore(SumLayout.$.summed, d.vec3u(id.xy, k), d.vec4f(total, 1)); near = far; }});/** Adds the light the air scatters in front of each pixel's surface onto the scene. */const over: passes.Drawing<typeof passes.ScreenVertex, d.Infer<typeof Seen>> = { ...passes.fullScreen, label: 'sunbeams', surfaceOf: (corner) => { 'use gpu'; const size = std.textureDimensions(BeamsLayout.$.depth); const texel = std.min(d.vec2u(corner.uv * d.vec2f(size)), size - 1); const depth = std.textureLoad(BeamsLayout.$.depth, d.vec2i(texel), 0); // A cell's centre holds the light up to its far side const slice = sliceAt(cameras.distanceOf(cameras.current.$, depth)) - 0.5 / GRID[2]; const at = d.vec3f(corner.uv, std.max(slice, 0.5 / GRID[2])); const light = std.textureSampleLevel(BeamsLayout.$.summed, BeamsLayout.$.smooth, at, 0).xyz; return Seen({ color: d.vec4f(light, 0) }); }, color: passes.shaded, blend: pipelines.blend.add, draw(pass) { pass.draw(3); },};/** A mote turns slowly on the air, a different eddy for each. */const drift = (mote: Particle, dt: number) => { 'use gpu'; const at = mote.position / EDDY + d.vec3f(mote.seed * 7, mote.age * 0.05, 0); const push = d.vec3f( perlin3d.sample(at), perlin3d.sample(at + d.vec3f(31, 0, 0)), perlin3d.sample(at + d.vec3f(0, 0, 57)), ); const next = Particle(mote); next.velocity = mote.velocity + push * (SWIRL * dt); const inside = std.all(std.ge(mote.position, air.$.low)) && std.all(std.le(mote.position, air.$.high)); if (!inside) next.life = 0; return next;};/** A mote glints where the sun reaches it, most when the eye looks towards the sun. */const glint = (mote: Particle, local: d.v2f) => { 'use gpu'; const edge = std.saturate(1 - std.length(local)); const t = mote.age / mote.life; const fade = std.saturate(t * 10) * std.saturate((1 - t) * 10); const direction = std.normalize(mote.position - cameras.current.$.position); const light = scatteredAlong(direction) * ((sunlitAt(mote.position) * MOTE_GLOW) / DENSITY); return d.vec4f(light * (edge * edge * fade * mote.color.w), 0);};export interface AirOptions<T extends Record<string, targets.Spec>> { readonly camera: CameraBase; readonly light: Lighting; /** The room's air as a box, its lowest and highest corners in metres. */ readonly room: { readonly low: Vec3; readonly high: Vec3 }; /** The targets the scene drew, whose `hdr` takes the beams. */ readonly screen: Targets<T>; /** The scene's depth, where each pixel's view of the air ends. */ readonly depth: () => Depth; /** The bindings the scene shares, which hold the sun's shadow. */ readonly shared: SharedBindings; /** The length of a simulation step in seconds. */ readonly step: number;}/** * The dust in the classroom's air: beams where the sun's shadow lets its light through the * windows, gathered in a grid along the view, and motes drifting through them. */export function createAir<T extends Record<string, targets.Spec> & { hdr: targets.Spec }>( root: TgpuRoot, options: AirOptions<T>,) { const { camera: view, light, room, screen, depth, step } = options; const settings = root.createUniform(Air, { low: room.low, frame: 0, high: room.high, blend: 1, }); const volume = () => root .createTexture({ size: [...GRID], format: 'rgba16float', dimension: '3d' }) .$usage('storage', 'sampled'); const scattered = [volume(), volume()] as const; const summed = volume(); const smooth = root.createSampler({ magFilter: 'linear', minFilter: 'linear' }); const sampled = (texture: ReturnType<typeof volume>) => texture.createView(d.texture3d(d.f32)); const writes = (texture: ReturnType<typeof volume>) => texture.createView(d.textureStorage3d('rgba16float', 'write-only')); // Each frame scatters into one grid over the other, then sums the one it wrote const scatterings = [0, 1].map((i) => root.createBindGroup(ScatterLayout, { before: sampled(scattered[1 - i]!), into: writes(scattered[i]!), smooth, }), ); const sums = [0, 1].map((i) => root.createBindGroup(SumLayout, { scattered: sampled(scattered[i]!), summed: writes(summed) }), ); const lit = root.pipe((cfg) => light.inject()(view.inject()(cfg)).with(air, settings)); const scattering = lit.createComputePipeline({ compute: scatter }); const summing = lit.createComputePipeline({ compute: sum }); const adding = passes .create(root, screen, { hdr: passes.color }, { label: 'sunbeams' }) .pipe(view.inject()); const summedView = sampled(summed); const beamsOf = memo((deep: Depth) => root.createBindGroup(BeamsLayout, { summed: summedView, depth: deep.createView(d.textureDepth2d()), smooth, }), ); const added = adding.add({ ...over, draw(pass) { pass.setBindGroup(BeamsLayout, beamsOf(depth())); pass.draw(3); }, }); const motes = particles.create(root, view, { label: 'motes', capacity: MOTES, step, blend: 'add', settings: { gravity: [0, -0.003, 0], drag: 0.5 }, update: tgpu.fn(drift).pipe((cfg) => cfg.with(air, settings)), shade: tgpu .fn(glint) .pipe(light.inject()) .pipe((cfg) => cfg.with(air, settings)), }); const center: Vec3 = [ (room.low[0] + room.high[0]) / 2, (room.low[1] + room.high[1]) / 2, (room.low[2] + room.high[2]) / 2, ]; const area: Vec3 = [ (room.high[0] - room.low[0]) / 2, (room.high[1] - room.low[1]) / 2, (room.high[2] - room.low[2]) / 2, ]; const cloud = { position: center, area, size: MOTE_SIZE, life: MOTE_LIFE, variance: 0.5 }; motes.burst({ ...cloud, count: MOTES, aged: true }); motes.stream(MOTES / MOTE_LIFE, cloud); const groups = [Math.ceil(GRID[0] / WORKGROUP), Math.ceil(GRID[1] / WORKGROUP), GRID[2]] as const; let last: number | undefined; return { /** The motes, for the scene's pass. */ drawing: motes.drawing, /** Spawns, moves and lists the motes, `steps` fixed steps, then shows them `ahead` seconds on. */ simulate(pass: TgpuComputePass, steps: number, ahead: number) { motes.spawn(pass); for (let i = 0; i < steps; i++) motes.step(pass); motes.compact(pass, ahead); }, /** Lights the air and adds it onto the scene the frame drew. Record it after the scene's pass. */ record(frame: Frame) { // The grid before is missing when the last frame lit no air const kept = last !== undefined && frame.index === last + 1; settings.patch({ frame: frame.index % 1024, blend: kept ? BLEND : 1 }); const into = frame.index % 2; const pass = frame.encoder.beginComputePass(); pass.setBindGroup(options.shared.layout, options.shared.group()); pass.setPipeline(scattering); pass.setBindGroup(ScatterLayout, scatterings[into]!); pass.dispatchWorkgroups(...groups); pass.setPipeline(summing); pass.setBindGroup(SumLayout, sums[into]!); pass.dispatchWorkgroups(groups[0], groups[1]); pass.end(); adding.record(frame, added, { clear: false }); last = frame.index; }, initAsync: () => Promise.all([ scattering.initAsync(), summing.initAsync(), adding.initAsync(), motes.initAsync(), ]), destroy() { for (const texture of [...scattered, summed]) texture.destroy(); adding.destroy(); motes.destroy(); }, };}assets.ts
import { glb, image } from '../assets.ts';/** The classroom, Draco compressed, and its daylight and its lamps' light baked in logarithms. */export const assets = { model: glb('models/classroom.glb', { draco: true }), daylight: image('textures/lightmap-daylight.webp'), lamps: image('textures/lightmap-lamps.webp'),};