diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 6bd92ff..f5f7c2e 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -24,7 +24,8 @@ "n8ao": "2.0.1", "postprocessing": "6.39.5", "react": "catalog:", - "three": "catalog:" + "three": "catalog:", + "three-mesh-bvh": "0.8.3" }, "devDependencies": { "@repo/prettier": "workspace:*", diff --git a/packages/renderer/src/area-lighting.test.ts b/packages/renderer/src/area-lighting.test.ts new file mode 100644 index 0000000..f4e7f2f --- /dev/null +++ b/packages/renderer/src/area-lighting.test.ts @@ -0,0 +1,191 @@ +import { MeshStandardMaterial } from "three"; +import { describe, expect, it } from "vitest"; + +import { createAreaLighting, lightmapTexture } from "./area-lighting"; +import type { BakedLightmap, BakeJob } from "./bake"; +import type { BakeDone, BakeQueue } from "./bake-client"; +import { areaOf, testRoom } from "./test-rooms"; + +/** A queue the test finishes by hand. */ +const fakeQueue = () => { + const jobs: { job: BakeJob; onDone: BakeDone; cancelled: boolean }[] = []; + let disposed = 0; + const queue: BakeQueue = { + request: (job, onDone) => { + const entry = { job, onDone, cancelled: false }; + jobs.push(entry); + return () => { + entry.cancelled = true; + }; + }, + dispose: () => { + disposed += 1; + }, + }; + const finish = (index: number, lightmap: BakedLightmap | null) => { + const entry = jobs[index]; + if (entry !== undefined && !entry.cancelled) { + entry.onDone(lightmap); + } + }; + return { queue, jobs, finish, disposed: () => disposed }; +}; + +const bakedFor = (job: BakeJob): BakedLightmap => ({ + width: job.layout.width, + height: job.layout.height, + data: new Uint16Array(job.layout.width * job.layout.height * 4), +}); + +const shared = () => { + const lightMap = lightmapTexture(new Uint16Array(4), 1, 1); + return { + lightMap, + wall: new MeshStandardMaterial({ vertexColors: true, lightMap }), + floor: new MeshStandardMaterial({ lightMap }), + ceiling: new MeshStandardMaterial({ lightMap }), + }; +}; + +const A = areaOf(testRoom("a", 0, 0, 4, 4)); +const B = areaOf(testRoom("b", 10, 0, 4, 4)); + +describe("createAreaLighting", () => { + it("lights a new area with the placeholder at once and the bake later", () => { + const surfaces = shared(); + const fake = fakeQueue(); + const store = createAreaLighting(surfaces, fake.queue); + const lighting = store.get(A); + expect(lighting.floor).not.toBe(surfaces.floor); + expect(lighting.floor.lightMap).toBe(surfaces.lightMap); + store.sync([A]); + expect(fake.jobs).toHaveLength(1); + const job = fake.jobs[0]?.job; + expect(job?.lights).toHaveLength(1); + if (job === undefined) { + return; + } + fake.finish(0, bakedFor(job)); + expect(lighting.floor.lightMap).not.toBe(surfaces.lightMap); + expect(lighting.wall.lightMap).toBe(lighting.floor.lightMap); + // The shared materials keep the placeholder. + expect(surfaces.floor.lightMap).toBe(surfaces.lightMap); + }); + + it("keeps every program-relevant setting of the shared materials", () => { + const surfaces = shared(); + const fake = fakeQueue(); + const store = createAreaLighting(surfaces, fake.queue); + const lighting = store.get(A); + store.sync([A]); + const job = fake.jobs[0]?.job; + if (job !== undefined) { + fake.finish(0, bakedFor(job)); + } + for (const key of ["wall", "floor", "ceiling"] as const) { + const clone = lighting[key]; + const original = surfaces[key]; + expect(clone.defines).toEqual(original.defines); + expect(clone.vertexColors).toBe(original.vertexColors); + const baked = clone.lightMap; + const placeholder = original.lightMap; + expect(baked).not.toBeNull(); + expect(baked?.channel).toBe(1); + expect(baked?.channel).toBe(placeholder?.channel); + expect(baked?.type).toBe(placeholder?.type); + expect(baked?.format).toBe(placeholder?.format); + expect(baked?.colorSpace).toBe(placeholder?.colorSpace); + } + }); + + it("asks once per area, however often it syncs", () => { + const fake = fakeQueue(); + const store = createAreaLighting(shared(), fake.queue); + store.sync([A]); + store.sync([A, B]); + store.sync([A, B]); + expect(fake.jobs).toHaveLength(2); + }); + + it("cancels and drops an area that left; a new generation bakes anew", () => { + const fake = fakeQueue(); + const store = createAreaLighting(shared(), fake.queue); + const old = store.get(A); + store.sync([A]); + const regenerated = areaOf(testRoom("a", 0, 0, 4, 4)); + store.sync([regenerated]); + expect(fake.jobs[0]?.cancelled).toBe(true); + expect(fake.jobs).toHaveLength(2); + expect(store.get(regenerated)).not.toBe(old); + }); + + it("keeps the placeholder for an area past the budget", () => { + const surfaces = shared(); + const fake = fakeQueue(); + const sizeA = createAreaLighting(shared(), fakeQueue().queue).get(A).layout; + const store = createAreaLighting( + surfaces, + fake.queue, + sizeA.width * sizeA.height + ); + store.sync([A, B]); + expect(fake.jobs).toHaveLength(1); + expect(store.get(B).floor.lightMap).toBe(surfaces.lightMap); + }); + + it("bakes an area the budget held back once budget is freed", () => { + const size = createAreaLighting(shared(), fakeQueue().queue).get(A).layout; + const budget = size.width * size.height; + // Freed by an area leaving. + const leaving = fakeQueue(); + const store = createAreaLighting(shared(), leaving.queue, budget); + store.sync([A, B]); + expect(leaving.jobs).toHaveLength(1); + store.sync([B]); + expect(leaving.jobs).toHaveLength(2); + // Freed by a bake failing. + const failing = fakeQueue(); + const other = createAreaLighting(shared(), failing.queue, budget); + other.sync([A, B]); + failing.finish(0, null); + expect(failing.jobs).toHaveLength(2); + // The failed area is not tried again. + other.sync([A, B]); + expect(failing.jobs).toHaveLength(2); + }); + + it("gives back a failed bake's budget and keeps the placeholder", () => { + const surfaces = shared(); + const fake = fakeQueue(); + const size = createAreaLighting(shared(), fakeQueue().queue).get(A).layout; + const store = createAreaLighting( + surfaces, + fake.queue, + size.width * size.height + ); + store.sync([A]); + fake.finish(0, null); + expect(store.get(A).floor.lightMap).toBe(surfaces.lightMap); + store.sync([B]); + expect(fake.jobs).toHaveLength(2); + }); + + it("re-queues unfinished bakes after dispose, keeping the same lighting", () => { + const surfaces = shared(); + const fake = fakeQueue(); + const store = createAreaLighting(surfaces, fake.queue); + const lighting = store.get(A); + store.sync([A]); + store.dispose(); + expect(fake.jobs[0]?.cancelled).toBe(true); + expect(fake.disposed()).toBe(1); + store.sync([A]); + expect(store.get(A)).toBe(lighting); + expect(fake.jobs).toHaveLength(2); + const job = fake.jobs[1]?.job; + if (job !== undefined) { + fake.finish(1, bakedFor(job)); + } + expect(lighting.floor.lightMap).not.toBe(surfaces.lightMap); + }); +}); diff --git a/packages/renderer/src/area-lighting.ts b/packages/renderer/src/area-lighting.ts new file mode 100644 index 0000000..0d99152 --- /dev/null +++ b/packages/renderer/src/area-lighting.ts @@ -0,0 +1,226 @@ +import { DataTexture, HalfFloatType, LinearFilter, RGBAFormat } from "three"; +import type { MeshStandardMaterial } from "three"; + +import type { BakedLightmap } from "./bake"; +import type { BakeQueue } from "./bake-client"; +import { areaFixtures, LIGHT_DROP } from "./fixtures"; +import { layoutArea } from "./lightmap-layout"; +import type { AreaLayout, AreaRooms } from "./lightmap-layout"; + +/** The lightmapped surfaces of one area: its own copies of the shared materials. */ +type AreaLighting = { + readonly layout: AreaLayout; + readonly wall: MeshStandardMaterial; + readonly floor: MeshStandardMaterial; + readonly ceiling: MeshStandardMaterial; +}; + +type LightmappedSurfaces = Pick; + +type LightingStore = { + /** An area's lighting, made on first ask: placeholder-lit until baked. */ + readonly get: (area: AreaRooms) => AreaLighting; + /** Bakes the areas not baked yet; drops what is kept for areas gone. */ + readonly sync: (areas: readonly AreaRooms[]) => void; + /** + * Cancels unfinished bakes and frees the GPU side of every material and + * map, but keeps each area's lighting usable: three uploads again on the + * next draw, and the next `sync` queues the cancelled bakes again. So a + * StrictMode unmount and remount leaves the rendered materials valid. + */ + readonly dispose: () => void; +}; + +/** + * Texels baked at once across a World, 64 MiB of half-float RGBA. An area + * past it keeps the placeholder. + */ +const WORLD_TEXEL_BUDGET = 8 * 1024 * 1024; + +/** A lightmap texture as the materials read it: through `uv1`, filtered. */ +const lightmapTexture = ( + data: Uint16Array, + width: number, + height: number +): DataTexture => { + const texture = new DataTexture( + data, + width, + height, + RGBAFormat, + HalfFloatType + ); + texture.channel = 1; + texture.magFilter = LinearFilter; + texture.minFilter = LinearFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + return texture; +}; + +/** The cancel of an area never queued. */ +const nothing = (): void => undefined; + +type Entry = { + readonly lighting: AreaLighting; + readonly texels: number; + /** + * `new`: not queued yet. `queued`: waiting or baking, cancelled by + * `cancel`. `baked`: its map is on the materials. `over`: the budget had + * no room, tried again once some is freed. `failed`: its bake failed; + * not tried again. + */ + state: "new" | "queued" | "baked" | "over" | "failed"; + cancel: () => void; + texture: DataTexture | null; +}; + +/** + * Lighting per area, keyed by the area object: a regenerated or reseeded + * area is a new object, so a bake for the old one can never land on it. + * Each area's materials are clones of the shared ones, which carry the + * placeholder lightmap: the same maps and defines, so the same shader + * program, and swapping in the baked map recompiles nothing. + */ +const createAreaLighting = ( + surfaces: LightmappedSurfaces, + queue: BakeQueue, + budget = WORLD_TEXEL_BUDGET +): LightingStore => { + const entries = new Map(); + let used = 0; + let warned = false; + + const ensure = (area: AreaRooms): Entry => { + const known = entries.get(area); + if (known !== undefined) { + return known; + } + const layout = layoutArea(area); + const entry: Entry = { + lighting: { + layout, + wall: surfaces.wall.clone(), + floor: surfaces.floor.clone(), + ceiling: surfaces.ceiling.clone(), + }, + texels: layout.width * layout.height, + state: "new", + cancel: nothing, + texture: null, + }; + entries.set(area, entry); + return entry; + }; + + /** Stops its bake if unfinished and frees its GPU side; usable after. */ + const free = (entry: Entry) => { + if (entry.state === "queued") { + entry.cancel(); + used -= entry.texels; + entry.state = "new"; + } + entry.texture?.dispose(); + entry.lighting.wall.dispose(); + entry.lighting.floor.dispose(); + entry.lighting.ceiling.dispose(); + }; + + /** Lets go of an area for good. */ + const release = (area: AreaRooms, entry: Entry) => { + free(entry); + if (entry.state === "baked") { + used -= entry.texels; + } + entries.delete(area); + }; + + const land = (entry: Entry, lightmap: BakedLightmap) => { + const texture = lightmapTexture( + lightmap.data, + lightmap.width, + lightmap.height + ); + entry.texture = texture; + entry.lighting.wall.lightMap = texture; + entry.lighting.floor.lightMap = texture; + entry.lighting.ceiling.lightMap = texture; + }; + + const bake = (area: AreaRooms, entry: Entry) => { + if (used + entry.texels > budget) { + if (!warned) { + warned = true; + console.warn( + "lightmap budget spent: later areas keep the flat fill light" + ); + } + entry.state = "over"; + return; + } + used += entry.texels; + entry.state = "queued"; + const { layout } = entry.lighting; + entry.cancel = queue.request( + { + layout: { + width: layout.width, + height: layout.height, + texel: layout.texel, + faces: layout.faces, + solids: layout.solids, + }, + lights: areaFixtures(area).map( + ({ x, z, ceiling }) => [x, ceiling - LIGHT_DROP, z] as const + ), + }, + (lightmap) => { + // The queue never answers a cancelled job, so this entry is live. + if (lightmap === null) { + // Failed: keep the placeholder and give its texels to an area + // that was waiting for them. + used -= entry.texels; + entry.state = "failed"; + bakeWaiting(); + return; + } + entry.state = "baked"; + land(entry, lightmap); + } + ); + }; + + /** Bakes, in order, the areas still to bake and those the budget held back. */ + const bakeWaiting = () => { + for (const [area, entry] of entries) { + if (entry.state === "new" || entry.state === "over") { + bake(area, entry); + } + } + }; + + return { + get: (area) => ensure(area).lighting, + sync: (areas) => { + const current = new Set(areas); + for (const [area, entry] of entries) { + if (!current.has(area)) { + release(area, entry); + } + } + for (const area of areas) { + ensure(area); + } + bakeWaiting(); + }, + dispose: () => { + for (const entry of entries.values()) { + free(entry); + } + queue.dispose(); + }, + }; +}; + +export { createAreaLighting, lightmapTexture }; +export type { AreaLighting }; diff --git a/packages/renderer/src/area.tsx b/packages/renderer/src/area.tsx index a887991..b00b476 100644 --- a/packages/renderer/src/area.tsx +++ b/packages/renderer/src/area.tsx @@ -1,6 +1,7 @@ import type { BuiltArea } from "@repo/types"; import { memo } from "react"; +import type { AreaLighting } from "./area-lighting"; import { DoorFrames } from "./door-frames"; import { Portals } from "./portals"; import { Room } from "./room"; @@ -9,13 +10,20 @@ type AreaProps = { readonly area: BuiltArea; /** Ids of its portals into an area not generated yet. */ readonly pending: readonly string[]; + /** Its lightmapped materials and lightmap layout. */ + readonly lighting: AreaLighting; }; /** One area: its rooms, its door frames as one mesh and its portals. */ -const AreaView = ({ area, pending }: AreaProps) => ( +const AreaView = ({ area, pending, lighting }: AreaProps) => ( <> - {area.built.rooms.map((built) => ( - + {area.built.rooms.map((built, index) => ( + ))} @@ -32,7 +40,9 @@ const samePending = (a: readonly string[], b: readonly string[]): boolean => const Area = memo( AreaView, (previous, next) => - previous.area === next.area && samePending(previous.pending, next.pending) + previous.area === next.area && + previous.lighting === next.lighting && + samePending(previous.pending, next.pending) ); export { Area }; diff --git a/packages/renderer/src/bake-client.test.ts b/packages/renderer/src/bake-client.test.ts new file mode 100644 index 0000000..0d3f3cf --- /dev/null +++ b/packages/renderer/src/bake-client.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; + +import type { BakedLightmap, BakeJob } from "./bake"; +import { createBakeQueue } from "./bake-client"; +import type { BakeRequest, BakeResponse, BakeWorker } from "./bake-client"; + +const JOB: BakeJob = { + layout: { width: 1, height: 1, texel: 0.25, faces: [], solids: [] }, + lights: [], +}; + +const LIGHTMAP: BakedLightmap = { + width: 1, + height: 1, + data: new Uint16Array(4), +}; + +/** Workers the test answers by hand; every one the queue made, in order. */ +const fakeWorkers = (options: { readonly failToStart?: boolean } = {}) => { + const made: { + worker: BakeWorker; + posted: BakeRequest[]; + terminated: boolean; + }[] = []; + const create = (): BakeWorker => { + if (options.failToStart === true) { + throw new Error("blocked by CSP"); + } + const entry = { + posted: [] as BakeRequest[], + terminated: false, + worker: { + postMessage: (request: BakeRequest) => { + entry.posted.push(request); + }, + terminate: () => { + entry.terminated = true; + }, + onmessage: null, + onerror: null, + onmessageerror: null, + } as BakeWorker, + }; + made.push(entry); + return entry.worker; + }; + const last = () => { + const entry = made.at(-1); + if (entry === undefined) { + throw new Error("no worker made"); + } + return entry; + }; + return { + made, + create, + last, + reply: (data: BakeResponse) => + last().worker.onmessage?.({ data } as MessageEvent), + crash: () => last().worker.onerror?.({ message: "boom" } as ErrorEvent), + garble: () => + last().worker.onmessageerror?.(new MessageEvent("messageerror")), + }; +}; + +const collect = () => { + const results: (string | null)[] = []; + const onDone = + (name: string) => + (lightmap: BakedLightmap | null): void => { + results.push(lightmap === null ? null : name); + }; + return { results, onDone }; +}; + +/** Lets queued microtasks run. */ +const settle = () => Promise.resolve(); + +describe("createBakeQueue", () => { + it("bakes one job at a time, in order, on one worker", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + queue.request(JOB, onDone("b")); + expect(fake.last().posted.map(({ id }) => id)).toEqual([0]); + fake.reply({ id: 0, lightmap: LIGHTMAP }); + expect(fake.last().posted.map(({ id }) => id)).toEqual([0, 1]); + fake.reply({ id: 1, lightmap: LIGHTMAP }); + expect(results).toEqual(["a", "b"]); + expect(fake.made).toHaveLength(1); + }); + + it("drops a waiting job that was cancelled", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + const cancel = queue.request(JOB, onDone("b")); + cancel(); + fake.reply({ id: 0, lightmap: LIGHTMAP }); + expect(fake.last().posted.map(({ id }) => id)).toEqual([0]); + expect(results).toEqual(["a"]); + }); + + it("stops the worker for a cancelled running job; the next gets a new one", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + const cancel = queue.request(JOB, onDone("a")); + queue.request(JOB, onDone("b")); + cancel(); + expect(fake.made[0]?.terminated).toBe(true); + expect(fake.made).toHaveLength(2); + expect(fake.last().posted.map(({ id }) => id)).toEqual([1]); + fake.reply({ id: 1, lightmap: LIGHTMAP }); + expect(results).toEqual(["b"]); + }); + + it("ignores an answer for any job but the running one", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + fake.reply({ id: 7, lightmap: LIGHTMAP }); + expect(results).toEqual([]); + fake.reply({ id: 0, lightmap: LIGHTMAP }); + fake.reply({ id: 0, lightmap: LIGHTMAP }); + expect(results).toEqual(["a"]); + }); + + it("fails the job on an error answer and replaces the worker", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + queue.request(JOB, onDone("b")); + fake.reply({ id: 0, error: "out of memory" }); + expect(results).toEqual([null]); + expect(fake.made[0]?.terminated).toBe(true); + expect(fake.made).toHaveLength(2); + fake.reply({ id: 1, lightmap: LIGHTMAP }); + expect(results).toEqual([null, "b"]); + }); + + it("fails the job when the worker crashes or garbles, and goes on", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + queue.request(JOB, onDone("b")); + queue.request(JOB, onDone("c")); + fake.crash(); + fake.garble(); + fake.reply({ id: 2, lightmap: LIGHTMAP }); + expect(results).toEqual([null, null, "c"]); + expect(fake.made).toHaveLength(3); + }); + + it("lets a replaced worker's late error pass without failing the next job", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + const cancel = queue.request(JOB, onDone("a")); + queue.request(JOB, onDone("b")); + cancel(); + fake.made[0]?.worker.onerror?.({ message: "late" } as ErrorEvent); + fake.reply({ id: 1, lightmap: LIGHTMAP }); + expect(results).toEqual(["b"]); + expect(fake.made[1]?.terminated).toBe(false); + }); + + it("replaces a worker that broke while idle", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + fake.reply({ id: 0, lightmap: LIGHTMAP }); + fake.crash(); + queue.request(JOB, onDone("b")); + expect(fake.made).toHaveLength(2); + fake.reply({ id: 1, lightmap: LIGHTMAP }); + expect(results).toEqual(["a", "b"]); + }); + + it("fails a job whose worker cannot start, later, like an answer", async () => { + const fake = fakeWorkers({ failToStart: true }); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + expect(results).toEqual([]); + await settle(); + expect(results).toEqual([null]); + }); + + it("delivers nothing after dispose, and takes new jobs after", () => { + const fake = fakeWorkers(); + const queue = createBakeQueue(fake.create); + const { results, onDone } = collect(); + queue.request(JOB, onDone("a")); + queue.request(JOB, onDone("b")); + queue.dispose(); + expect(fake.made[0]?.terminated).toBe(true); + fake.reply({ id: 0, lightmap: LIGHTMAP }); + expect(results).toEqual([]); + queue.request(JOB, onDone("c")); + fake.reply({ id: 2, lightmap: LIGHTMAP }); + expect(results).toEqual(["c"]); + }); +}); diff --git a/packages/renderer/src/bake-client.ts b/packages/renderer/src/bake-client.ts new file mode 100644 index 0000000..1cbf6d5 --- /dev/null +++ b/packages/renderer/src/bake-client.ts @@ -0,0 +1,140 @@ +import type { BakedLightmap, BakeJob } from "./bake"; + +// The page's side of the bake worker, free of React so a fake worker can +// drive it in tests. + +type BakeRequest = { readonly id: number; readonly job: BakeJob }; + +type BakeResponse = + | { readonly id: number; readonly lightmap: BakedLightmap } + | { readonly id: number; readonly error: string }; + +/** What the queue uses of a `Worker`. */ +type BakeWorker = { + readonly postMessage: (request: BakeRequest) => void; + readonly terminate: () => void; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; + onmessageerror: ((event: MessageEvent) => void) | null; +}; + +/** Called once per request: the lightmap, or null when the bake failed. */ +type BakeDone = (lightmap: BakedLightmap | null) => void; + +type BakeQueue = { + /** Queues a bake; the returned cancel guarantees `onDone` is not called. */ + readonly request: (job: BakeJob, onDone: BakeDone) => () => void; + /** Cancels every job and stops the worker; a later request starts afresh. */ + readonly dispose: () => void; +}; + +/** The real worker, a module chunk of its own on this origin. */ +const createBakeWorker = (): BakeWorker => + new Worker(new URL("./bake.worker.ts", import.meta.url), { + type: "module", + }); + +type Job = { + readonly id: number; + readonly job: BakeJob; + readonly onDone: BakeDone; +}; + +/** + * Bakes one job at a time, in order, on one worker made when first needed + * and kept between jobs. Cancelling a waiting job drops it; cancelling the + * running one stops the worker, and the next job starts a fresh one. A + * worker that crashes, cannot start or answers garbled fails its job and is + * replaced. An answer for any job but the running one is dropped. + */ +const createBakeQueue = (createWorker: () => BakeWorker): BakeQueue => { + const waiting: Job[] = []; + let running: Job | null = null; + let worker: BakeWorker | null = null; + let nextId = 0; + + const stopWorker = () => { + worker?.terminate(); + worker = null; + }; + + const finish = (id: number, lightmap: BakedLightmap | null) => { + if (running?.id !== id) { + return; + } + const { onDone } = running; + running = null; + if (lightmap === null) { + stopWorker(); + } + onDone(lightmap); + pump(); + }; + + const start = (): BakeWorker => { + const created = createWorker(); + created.onmessage = ({ data }) => + finish(data.id, "lightmap" in data ? data.lightmap : null); + // A broken worker goes even when idle, so the next job gets a new one. + // One already replaced says nothing about the job on its successor. + const fail = () => { + if (worker !== created) { + return; + } + stopWorker(); + if (running !== null) { + finish(running.id, null); + } + }; + created.onerror = fail; + created.onmessageerror = fail; + return created; + }; + + const pump = () => { + if (running !== null) { + return; + } + const next = waiting.shift(); + if (next === undefined) { + return; + } + running = next; + try { + worker ??= start(); + worker.postMessage({ id: next.id, job: next.job }); + } catch { + // Failed later, like a worker's answer, so callers see one shape. + queueMicrotask(() => finish(next.id, null)); + } + }; + + return { + request: (job, onDone) => { + const entry: Job = { id: nextId, job, onDone }; + nextId += 1; + waiting.push(entry); + pump(); + return () => { + const index = waiting.indexOf(entry); + if (index !== -1) { + waiting.splice(index, 1); + return; + } + if (running === entry) { + running = null; + stopWorker(); + pump(); + } + }; + }, + dispose: () => { + waiting.length = 0; + running = null; + stopWorker(); + }, + }; +}; + +export { createBakeQueue, createBakeWorker }; +export type { BakeDone, BakeQueue, BakeRequest, BakeResponse, BakeWorker }; diff --git a/packages/renderer/src/bake-job.test.ts b/packages/renderer/src/bake-job.test.ts new file mode 100644 index 0000000..9d5d5da --- /dev/null +++ b/packages/renderer/src/bake-job.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import type { BakeJob } from "./bake"; +import { readBakeJob } from "./bake-job"; +import { layoutArea } from "./lightmap-layout"; +import { areaOf, testRoom } from "./test-rooms"; + +const layout = layoutArea( + areaOf( + testRoom("a", 0, 0, 4, 4, ["east"]), + testRoom("b", 4, 0, 4, 4, ["west"]) + ) +); + +const JOB: BakeJob = { + layout: { + width: layout.width, + height: layout.height, + texel: layout.texel, + faces: layout.faces, + solids: layout.solids, + }, + lights: [[0, 2.4, 0]], +}; + +/** The job with its first face's fields replaced. */ +const withFace = (patch: Record): unknown => ({ + ...JOB, + layout: { + ...JOB.layout, + faces: [{ ...JOB.layout.faces[0], ...patch }, ...JOB.layout.faces.slice(1)], + }, +}); + +describe("readBakeJob", () => { + it("reads back a job the page builds, unchanged", () => { + expect(readBakeJob(structuredClone(JOB))).toEqual(JOB); + }); + + it.each([ + ["an axis that is not 0, 1 or 2", withFace({ axis: "__proto__" })], + [ + "a chart past the atlas", + withFace({ chart: { x: 1, y: 1, w: 1e6, h: 1 } }), + ], + [ + "a chart on the atlas edge", + withFace({ chart: { x: 0, y: 1, w: 1, h: 1 } }), + ], + ["a fractional chart", withFace({ chart: { x: 1.5, y: 1, w: 1, h: 1 } })], + ["a room that does not exist", withFace({ room: 99 })], + ["a coordinate that is not a number", withFace({ at: "far" })], + ["no layout", { lights: [] }], + ["not an object", "bake"], + ])("rejects %s", (_, data) => { + expect(() => readBakeJob(data)).toThrow(/malformed bake job/); + }); +}); diff --git a/packages/renderer/src/bake-job.ts b/packages/renderer/src/bake-job.ts new file mode 100644 index 0000000..56443ae --- /dev/null +++ b/packages/renderer/src/bake-job.ts @@ -0,0 +1,123 @@ +import type { BakeJob } from "./bake"; +import type { Rgb } from "./lane-tint"; +import type { Chart, LightmapFace, Solid } from "./lightmap-layout"; + +// Reads a bake job out of a worker message: a fresh copy holding only +// finite numbers, axes 0, 1 or 2, and charts of whole texels inside the +// atlas, so nothing the bake indexes by comes from the message unchecked. + +const fail = (what: string): never => { + throw new Error(`malformed bake job: ${what}`); +}; + +const field = (value: unknown, key: string): unknown => + typeof value === "object" && value !== null + ? (value as Record)[key] + : fail(`no ${key}`); + +const list = (value: unknown, what: string): readonly unknown[] => + Array.isArray(value) ? value : fail(`${what} is not a list`); + +const finite = (value: unknown, what: string): number => { + const number = Number(value); + return Number.isFinite(number) ? number : fail(`${what} is not a number`); +}; + +const whole = (value: unknown, what: string, below: number): number => { + const number = finite(value, what); + return Number.isInteger(number) && number >= 0 && number < below + ? number + : fail(`${what} is out of range`); +}; + +const triple = (value: unknown, what: string): [number, number, number] => { + const [x, y, z] = list(value, what); + return [finite(x, what), finite(y, what), finite(z, what)]; +}; + +const pair = (value: unknown, what: string): [number, number] => { + const [x, y] = list(value, what); + return [finite(x, what), finite(y, what)]; +}; + +const axisOf = (value: unknown): LightmapFace["axis"] => { + const axis = Number(value); + if (axis === 0) { + return 0; + } + if (axis === 1) { + return 1; + } + return axis === 2 ? 2 : fail("axis is not 0, 1 or 2"); +}; + +const chartOf = ( + value: unknown, + width: number, + height: number +): Chart | null => { + if (value === null) { + return null; + } + // A chart keeps a one-texel gutter inside the atlas on every side. + const x = whole(field(value, "x"), "chart x", width); + const y = whole(field(value, "y"), "chart y", height); + const w = whole(field(value, "w"), "chart w", width - x); + const h = whole(field(value, "h"), "chart h", height - y); + return x > 0 && y > 0 && w > 0 && h > 0 + ? { x, y, w, h } + : fail("chart outside the atlas"); +}; + +const faceOf = ( + value: unknown, + width: number, + height: number, + rooms: number +): LightmapFace => ({ + axis: axisOf(field(value, "axis")), + sign: finite(field(value, "sign"), "sign") < 0 ? -1 : 1, + at: finite(field(value, "at"), "at"), + min: pair(field(value, "min"), "min"), + max: pair(field(value, "max"), "max"), + albedo: triple(field(value, "albedo"), "albedo") satisfies Rgb, + chart: chartOf(field(value, "chart"), width, height), + room: whole(field(value, "room"), "room", rooms), +}); + +const solidOf = (value: unknown): Solid => ({ + min: triple(field(value, "min"), "solid min"), + max: triple(field(value, "max"), "solid max"), +}); + +/** A whole atlas side: positive and no larger than any layout makes. */ +const MAX_SIDE = 4096; + +/** The job in a worker message, or a throw if it is not one. */ +const readBakeJob = (data: unknown): BakeJob => { + const layout = field(data, "layout"); + const width = whole(field(layout, "width"), "width", MAX_SIDE + 1); + const height = whole(field(layout, "height"), "height", MAX_SIDE + 1); + if (width === 0 || height === 0) { + fail("empty atlas"); + } + const solids = list(field(layout, "solids"), "solids").map((room) => + list(room, "room solids").map(solidOf) + ); + return { + layout: { + width, + height, + texel: finite(field(layout, "texel"), "texel"), + faces: list(field(layout, "faces"), "faces").map((face) => + faceOf(face, width, height, solids.length) + ), + solids, + }, + lights: list(field(data, "lights"), "lights").map((light) => + triple(light, "light") + ), + }; +}; + +export { readBakeJob }; diff --git a/packages/renderer/src/bake.test.ts b/packages/renderer/src/bake.test.ts new file mode 100644 index 0000000..f1949cc --- /dev/null +++ b/packages/renderer/src/bake.test.ts @@ -0,0 +1,128 @@ +import { WALL_HEIGHT } from "@repo/world-generator/config"; +import { DataUtils, Ray, Vector3 } from "three"; +import { describe, expect, it } from "vitest"; + +import { bakeLightmap, faceOf, firstHit, occluders } from "./bake"; +import type { BakedLightmap, BakeJob } from "./bake"; +import { LIGHT_DROP } from "./fixtures"; +import { layoutArea } from "./lightmap-layout"; +import type { AreaLayout, LightmapFace } from "./lightmap-layout"; +import { areaOf, testRoom } from "./test-rooms"; + +const LIGHT_Y = WALL_HEIGHT - LIGHT_DROP; + +const jobFor = (layout: AreaLayout, lights: BakeJob["lights"]): BakeJob => ({ + layout, + lights, +}); + +/** Mean luminance-ish (r + g + b) of a face's texels at (u, v) in 0..1. */ +const sample = ( + lightmap: BakedLightmap, + face: LightmapFace | undefined, + u: number, + v: number +): number => { + const chart = face?.chart; + if (chart === null || chart === undefined) { + throw new Error("face has no chart"); + } + const i = Math.min(chart.w - 1, Math.floor(u * chart.w)); + const j = Math.min(chart.h - 1, Math.floor(v * chart.h)); + const texel = (chart.y + j) * lightmap.width + chart.x + i; + return [0, 1, 2].reduce( + (sum, c) => + sum + DataUtils.fromHalfFloat(lightmap.data[texel * 4 + c] ?? 0), + 0 + ); +}; + +/** One 4 × 4 room, light at its centre. */ +const ONE = areaOf(testRoom("a", 0, 0, 4, 4)); +const ONE_LAYOUT = layoutArea(ONE); +const ONE_BAKE = bakeLightmap(jobFor(ONE_LAYOUT, [[0, LIGHT_Y, 0]])); + +/** Room a lit, room b dark, joined by a door. */ +const TWO = areaOf( + testRoom("a", 0, 0, 4, 4, ["east"]), + testRoom("b", 4, 0, 4, 4, ["west"]) +); +const TWO_LAYOUT = layoutArea(TWO); + +/** The face of room `room` facing `sign` along `axis` at `at`. */ +const faceAt = ( + layout: AreaLayout, + room: number, + match: Partial +): LightmapFace | undefined => + layout.rooms[room]?.find( + (face) => + face.chart !== null && + Object.entries(match).every( + ([key, value]) => + Math.abs(Number(face[key as keyof LightmapFace]) - Number(value)) < + 1e-6 + ) + ); + +describe("faceOf", () => { + it("names the face a ray hit, whatever order the BVH keeps", () => { + // Enough faces that building the tree reorders them. + const rooms = Array.from({ length: 36 }, (_, i) => + testRoom(`r${i}`, (i % 6) * 5, Math.floor(i / 6) * 5, 4, 4) + ); + const layout = layoutArea(areaOf(...rooms)); + const bvh = occluders(layout.faces); + const ray = new Ray(); + rooms.forEach((built, index) => { + const [x, , z] = built.room.position; + ray.set(new Vector3(x + 0.3, 1, z - 0.2), new Vector3(0, -1, 0)); + const hit = firstHit(bvh, ray); + const floor = layout.rooms[index]?.[0]; + expect(hit === null ? null : layout.faces[faceOf(hit) ?? -1]).toBe(floor); + }); + }); +}); + +describe("bakeLightmap", () => { + it("is deterministic", () => { + const again = bakeLightmap(jobFor(ONE_LAYOUT, [[0, LIGHT_Y, 0]])); + expect(again.data).toEqual(ONE_BAKE.data); + }); + + it("lights a wall's inner face more than its outer face", () => { + // The south wall (z = −2 side): its +z face looks into the room. + const inner = faceAt(ONE_LAYOUT, 0, { axis: 2, sign: 1, at: -2 + 0.15 }); + const outer = faceAt(ONE_LAYOUT, 0, { axis: 2, sign: -1, at: -2 }); + expect(sample(ONE_BAKE, inner, 0.5, 0.5)).toBeGreaterThan( + sample(ONE_BAKE, outer, 0.5, 0.5) + 0.05 + ); + }); + + it("darkens a floor corner against the room centre", () => { + const floor = ONE_LAYOUT.rooms[0]?.[0]; + // The second texel in from both walls (the first is under them), and + // the middle. + const corner = sample(ONE_BAKE, floor, 0.07, 0.07); + const centre = sample(ONE_BAKE, floor, 0.5, 0.5); + expect(corner).toBeLessThan(centre * 0.9); + }); + + it("fills floor texels under a wall from their neighbours, not black", () => { + const floor = ONE_LAYOUT.rooms[0]?.[0]; + // u = 0 is under the west wall. + expect(sample(ONE_BAKE, floor, 0, 0.5)).toBeGreaterThan(0.01); + }); + + it("bounces light through a door into an unlit room", () => { + const bake = bakeLightmap(jobFor(TWO_LAYOUT, [[0, LIGHT_Y, 0]])); + const dark = bakeLightmap(jobFor(TWO_LAYOUT, [])); + const floorA = TWO_LAYOUT.rooms[0]?.[0]; + const floorB = TWO_LAYOUT.rooms[1]?.[0]; + // Room b's floor near its door gains light over a bake without lights. + const lit = sample(bake, floorB, 0.15, 0.5); + const unlit = sample(dark, floorB, 0.15, 0.5); + expect(lit).toBeGreaterThan(unlit); + expect(lit).toBeLessThan(sample(bake, floorA, 0.5, 0.5)); + }); +}); diff --git a/packages/renderer/src/bake.ts b/packages/renderer/src/bake.ts new file mode 100644 index 0000000..43065da --- /dev/null +++ b/packages/renderer/src/bake.ts @@ -0,0 +1,448 @@ +import { + BufferAttribute, + BufferGeometry, + Color, + DataUtils, + DoubleSide, + Ray, + Vector3, +} from "three"; +import type { Intersection } from "three"; +import { MeshBVH } from "three-mesh-bvh"; + +import { LIGHT_COLOR, LIGHT_DISTANCE, LIGHT_INTENSITY } from "./fixtures"; +import type { Rgb } from "./lane-tint"; +import { faceUv, tangents } from "./lightmap-layout"; +import type { AreaLayout, LightmapFace, Solid } from "./lightmap-layout"; + +// Bakes an area's indirect light into its lightmap: one bounce of the +// fixtures' light off the walls, floor and ceiling, plus a flat fill that +// the surroundings occlude. Direct light is not stored: the light pool +// draws it live. Values are irradiance on three's scale, which is what +// `MeshStandardMaterial.lightMap` adds to the indirect diffuse. + +/** What the worker needs: the layout's faces and solids, and the lights. */ +type BakeJob = { + readonly layout: Pick< + AreaLayout, + "width" | "height" | "texel" | "faces" | "solids" + >; + /** Each fixture's light point, world space. */ + readonly lights: readonly (readonly [number, number, number])[]; +}; + +type BakedLightmap = { + readonly width: number; + readonly height: number; + /** RGBA half floats, row by row from the atlas origin. */ + readonly data: Uint16Array; +}; + +/** Hemisphere rays per texel. */ +const SAMPLES = 16; +/** Surroundings closer than this occlude the fill. */ +const AO_RADIUS = 1.5; +/** Scales the bounce; 1 is physical. */ +const BOUNCE_GAIN = 1; +/** + * The fill an open texel gets on top of the bounce: light from beyond the + * area's fixtures and from bounces past the first, as a warm constant. + */ +const AMBIENT_FILL: Rgb = [0.08, 0.065, 0.038]; +/** The light pool's flicker averages this over time. */ +const FLICKER_MEAN = 0.93; +/** How far a texel or ray start sits off its face. */ +const LIFT = 1e-3; +/** Dilation passes before the leftovers take their chart's mean. */ +const DILATE_PASSES = 4; + +const lightColour = new Color(LIGHT_COLOR); +const LIGHT_RGB: Rgb = [lightColour.r, lightColour.g, lightColour.b]; + +/** three's punctual falloff: inverse square, windowed to zero at the cutoff. */ +const falloff = (distance: number): number => { + const window = Math.min(1, Math.max(0, 1 - (distance / LIGHT_DISTANCE) ** 4)); + return (window * window) / Math.max(distance * distance, 0.01); +}; + +/** Van der Corput radical inverse in base 2. */ +const radicalInverse = (index: number): number => { + let bits = index; + bits = ((bits << 16) | (bits >>> 16)) >>> 0; + bits = (((bits & 0x55_55_55_55) << 1) | ((bits & 0xaa_aa_aa_aa) >>> 1)) >>> 0; + bits = (((bits & 0x33_33_33_33) << 2) | ((bits & 0xcc_cc_cc_cc) >>> 2)) >>> 0; + bits = (((bits & 0x0f_0f_0f_0f) << 4) | ((bits & 0xf0_f0_f0_f0) >>> 4)) >>> 0; + bits = (((bits & 0x00_ff_00_ff) << 8) | ((bits & 0xff_00_ff_00) >>> 8)) >>> 0; + return bits / 2 ** 32; +}; + +/** + * A fixed Hammersley set of cosine-weighted directions about +z: the same + * every bake, so the result is deterministic. + */ +const HEMISPHERE = Array.from({ length: SAMPLES }, (_, i) => { + const u = (i + 0.5) / SAMPLES; + const phi = 2 * Math.PI * radicalInverse(i); + const r = Math.sqrt(u); + return [r * Math.cos(phi), r * Math.sin(phi), Math.sqrt(1 - u)] as const; +}); + +/** A per-texel turn of the sample set, so neighbours do not band together. */ +const turnOf = (index: number): number => { + let h = Math.imul(index ^ 0x9e_37_79_b9, 0x85_eb_ca_6b); + h = Math.imul(h ^ (h >>> 13), 0xc2_b2_ae_35); + return (((h ^ (h >>> 16)) >>> 0) / 2 ** 32) * 2 * Math.PI; +}; + +/** + * The nearest hit along a ray, or null: `raycastFirst` returns null on a + * miss though its typings say otherwise. + */ +const firstHit = ( + bvh: MeshBVH, + ray: Ray, + far = Infinity +): Intersection | null => bvh.raycastFirst(ray, DoubleSide, 0, far); + +/** Each face is six vertices of the occluder geometry, in face order. */ +const VERTICES_PER_FACE = 6; + +/** + * The face a ray hit. Not from `faceIndex`: building the BVH reorders the + * triangles behind an index it adds, and `faceIndex` counts in that new + * order. The hit triangle's vertex indices still name the original vertices. + */ +const faceOf = (hit: Intersection): number | null => + hit.face ? Math.floor(hit.face.a / VERTICES_PER_FACE) : null; + +/** + * The world point at `at` on a face's axis and (s, t) on its tangent axes, + * spelled out per axis rather than written through a computed index. + */ +const pointOn = ( + axis: LightmapFace["axis"], + at: number, + s: number, + t: number +): [number, number, number] => { + if (axis === 0) { + return [at, s, t]; + } + if (axis === 1) { + return [s, at, t]; + } + return [s, t, at]; +}; + +/** One BVH over every face, two triangles each, non-indexed. */ +const occluders = (faces: readonly LightmapFace[]): MeshBVH => { + const positions = new Float32Array(faces.length * VERTICES_PER_FACE * 3); + faces.forEach((face, f) => { + const corner = (s: number, t: number) => pointOn(face.axis, face.at, s, t); + const p00 = corner(face.min[0], face.min[1]); + const p10 = corner(face.max[0], face.min[1]); + const p11 = corner(face.max[0], face.max[1]); + const p01 = corner(face.min[0], face.max[1]); + positions.set( + [...p00, ...p10, ...p11, ...p00, ...p11, ...p01], + f * VERTICES_PER_FACE * 3 + ); + }); + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new BufferAttribute(positions, 3)); + return new MeshBVH(geometry); +}; + +const inside = (point: readonly number[], solids: readonly Solid[]): boolean => + solids.some(({ min, max }) => + [0, 1, 2].every((axis) => { + const value = point[axis] ?? 0; + return value > (min[axis] ?? 0) && value < (max[axis] ?? 0); + }) + ); + +/** + * Bakes one area. Each visible face's texels get, in turn: the direct light + * of every fixture in reach, with a shadow ray each (kept only to bounce); + * then `SAMPLES` rays over the hemisphere, gathering what the surfaces they + * hit reflect, and counting how many leave within `AO_RADIUS` for the fill. + * Texels buried in a wall, and the gutters, are filled from their + * neighbours so filtering never pulls black onto a visible edge. + */ +const bakeLightmap = ({ layout, lights }: BakeJob): BakedLightmap => { + const { width, height, faces, solids } = layout; + const bvh = occluders(faces); + const texels = width * height; + const valid = new Uint8Array(texels); + /** Each texel's world point, kept from the direct pass for the bounce. */ + const points = new Float32Array(texels * 3); + const owner = new Int32Array(texels).fill(-1); + const direct = new Float32Array(texels * 3); + const ray = new Ray(); + const toLight = new Vector3(); + + // Direct light, texel by texel. + faces.forEach((face, f) => { + const { chart } = face; + if (chart === null) { + return; + } + for (let j = 0; j < chart.h; j += 1) { + for (let i = 0; i < chart.w; i += 1) { + const texel = (chart.y + j) * width + chart.x + i; + owner[texel] = f; + const point = pointOn( + face.axis, + face.at + face.sign * LIFT, + face.min[0] + ((i + 0.5) / chart.w) * (face.max[0] - face.min[0]), + face.min[1] + ((j + 0.5) / chart.h) * (face.max[1] - face.min[1]) + ); + if (inside(point, solids[face.room] ?? [])) { + continue; + } + valid[texel] = 1; + points.set(point, texel * 3); + let e = 0; + for (const light of lights) { + toLight.set( + light[0] - point[0], + light[1] - point[1], + light[2] - point[2] + ); + const distance = toLight.length(); + const cos = (toLight.getComponent(face.axis) * face.sign) / distance; + if (distance >= LIGHT_DISTANCE || cos <= 0) { + continue; + } + ray.origin.fromArray(point); + ray.direction.copy(toLight).divideScalar(distance); + if (firstHit(bvh, ray, distance - LIFT) !== null) { + continue; + } + e += cos * falloff(distance); + } + const scale = LIGHT_INTENSITY * FLICKER_MEAN * e; + direct[texel * 3] = LIGHT_RGB[0] * scale; + direct[texel * 3 + 1] = LIGHT_RGB[1] * scale; + direct[texel * 3 + 2] = LIGHT_RGB[2] * scale; + } + } + }); + + /** What a surface point reflects: its albedo times its direct light. */ + const reflected = (f: number, point: Vector3, into: number[]) => { + const face = faces[f]; + const chart = face?.chart; + if (face === undefined || chart === null || chart === undefined) { + return; + } + const [a, b] = tangents(face.axis); + const [u, v] = faceUv(face, point.getComponent(a), point.getComponent(b)); + const i = Math.min(chart.w - 1, Math.floor(u * chart.w)); + const j = Math.min(chart.h - 1, Math.floor(v * chart.h)); + const texel = (chart.y + j) * width + chart.x + i; + into[0] = (into[0] ?? 0) + face.albedo[0] * (direct[texel * 3] ?? 0); + into[1] = (into[1] ?? 0) + face.albedo[1] * (direct[texel * 3 + 1] ?? 0); + into[2] = (into[2] ?? 0) + face.albedo[2] * (direct[texel * 3 + 2] ?? 0); + }; + + // The bounce and the occluded fill. + const out = new Float32Array(texels * 3); + const gathered = [0, 0, 0]; + for (let texel = 0; texel < texels; texel += 1) { + const face = faces[owner[texel] ?? -1]; + if (valid[texel] !== 1 || face === undefined) { + continue; + } + const [a, b] = tangents(face.axis); + const turn = turnOf(texel); + const cos = Math.cos(turn); + const sin = Math.sin(turn); + gathered[0] = 0; + gathered[1] = 0; + gathered[2] = 0; + let open = 0; + ray.origin.fromArray(points, texel * 3); + for (const [sx, sy, sz] of HEMISPHERE) { + ray.direction.set(0, 0, 0); + ray.direction.setComponent(a, sx * cos - sy * sin); + ray.direction.setComponent(b, sx * sin + sy * cos); + ray.direction.setComponent(face.axis, sz * face.sign); + const hit = firstHit(bvh, ray); + if (hit === null || hit.distance > AO_RADIUS) { + open += 1; + } + const struck = hit === null ? null : faceOf(hit); + if (hit !== null && struck !== null) { + reflected(struck, hit.point, gathered); + } + } + const fill = open / SAMPLES; + for (let c = 0; c < 3; c += 1) { + out[texel * 3 + c] = + ((gathered[c] ?? 0) / SAMPLES) * BOUNCE_GAIN + + (AMBIENT_FILL[c] ?? 0) * fill; + } + } + + smooth(out, valid, faces, width); + dilate(out, valid, faces, width); + const data = new Uint16Array(texels * 4); + for (let texel = 0; texel < texels; texel += 1) { + data[texel * 4] = DataUtils.toHalfFloat(out[texel * 3] ?? 0); + data[texel * 4 + 1] = DataUtils.toHalfFloat(out[texel * 3 + 1] ?? 0); + data[texel * 4 + 2] = DataUtils.toHalfFloat(out[texel * 3 + 2] ?? 0); + data[texel * 4 + 3] = DataUtils.toHalfFloat(1); + } + bvh.geometry.dispose(); + return { width, height, data }; +}; + +/** A 3 × 3 tent: the texel itself 4, its sides 2, its corners 1. */ +const TENT = [1, 2, 1, 2, 4, 2, 1, 2, 1]; + +/** + * One tent-filter pass over each chart's valid texels: a few rays a texel + * leave the bounce noisy, and bounce light is soft enough that a texel's + * neighbours are as good an estimate as its own rays. Reads from a copy, + * stays inside the chart and skips texels buried in a wall. + */ +const smooth = ( + out: Float32Array, + valid: Uint8Array, + faces: readonly LightmapFace[], + width: number +): void => { + const source = Float32Array.from(out); + for (const { chart } of faces) { + if (chart === null) { + continue; + } + for (let y: number = chart.y; y < chart.y + chart.h; y += 1) { + for (let x: number = chart.x; x < chart.x + chart.w; x += 1) { + const texel = y * width + x; + if (valid[texel] !== 1) { + continue; + } + const sum = [0, 0, 0]; + let weight = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + const near = ny * width + nx; + const w = TENT[(dy + 1) * 3 + dx + 1] ?? 0; + if ( + nx < chart.x || + nx >= chart.x + chart.w || + ny < chart.y || + ny >= chart.y + chart.h || + valid[near] !== 1 + ) { + continue; + } + sum[0] = (sum[0] ?? 0) + w * (source[near * 3] ?? 0); + sum[1] = (sum[1] ?? 0) + w * (source[near * 3 + 1] ?? 0); + sum[2] = (sum[2] ?? 0) + w * (source[near * 3 + 2] ?? 0); + weight += w; + } + } + out[texel * 3] = (sum[0] ?? 0) / weight; + out[texel * 3 + 1] = (sum[1] ?? 0) / weight; + out[texel * 3 + 2] = (sum[2] ?? 0) / weight; + } + } + } +}; + +/** + * Fills every texel of a chart's padded rectangle that has no value (buried + * in a wall, or the gutter) with the mean of its filled neighbours, pass by + * pass; what is still empty after that takes the chart's mean. Stays inside + * each chart, so charts never bleed into one another. + */ +const dilate = ( + out: Float32Array, + valid: Uint8Array, + faces: readonly LightmapFace[], + width: number +): void => { + for (const { chart } of faces) { + if (chart === null) { + continue; + } + const x0 = chart.x - 1; + const y0 = chart.y - 1; + const x1 = chart.x + chart.w; + const y1 = chart.y + chart.h; + for (let pass = 0; pass < DILATE_PASSES; pass += 1) { + const filled: number[] = []; + for (let y = y0; y <= y1; y += 1) { + for (let x = x0; x <= x1; x += 1) { + const texel = y * width + x; + if (valid[texel] === 1) { + continue; + } + const sum = [0, 0, 0]; + let count = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const nx = x + dx; + const ny = y + dy; + const near = ny * width + nx; + if ( + nx < x0 || + nx > x1 || + ny < y0 || + ny > y1 || + valid[near] !== 1 + ) { + continue; + } + sum[0] = (sum[0] ?? 0) + (out[near * 3] ?? 0); + sum[1] = (sum[1] ?? 0) + (out[near * 3 + 1] ?? 0); + sum[2] = (sum[2] ?? 0) + (out[near * 3 + 2] ?? 0); + count += 1; + } + } + if (count > 0) { + out[texel * 3] = (sum[0] ?? 0) / count; + out[texel * 3 + 1] = (sum[1] ?? 0) / count; + out[texel * 3 + 2] = (sum[2] ?? 0) / count; + filled.push(texel); + } + } + } + // Marked after the pass, so one pass grows the filled region by one. + for (const texel of filled) { + valid[texel] = 1; + } + } + const mean = [0, 0, 0]; + let count = 0; + for (let y = y0; y <= y1; y += 1) { + for (let x = x0; x <= x1; x += 1) { + const texel = y * width + x; + if (valid[texel] === 1) { + mean[0] = (mean[0] ?? 0) + (out[texel * 3] ?? 0); + mean[1] = (mean[1] ?? 0) + (out[texel * 3 + 1] ?? 0); + mean[2] = (mean[2] ?? 0) + (out[texel * 3 + 2] ?? 0); + count += 1; + } + } + } + for (let y = y0; y <= y1; y += 1) { + for (let x = x0; x <= x1; x += 1) { + const texel = y * width + x; + if (valid[texel] !== 1) { + out[texel * 3] = count > 0 ? (mean[0] ?? 0) / count : 0; + out[texel * 3 + 1] = count > 0 ? (mean[1] ?? 0) / count : 0; + out[texel * 3 + 2] = count > 0 ? (mean[2] ?? 0) / count : 0; + } + } + } + } +}; + +export { bakeLightmap, faceOf, firstHit, occluders }; +export type { BakedLightmap, BakeJob }; diff --git a/packages/renderer/src/bake.worker.ts b/packages/renderer/src/bake.worker.ts new file mode 100644 index 0000000..93a2701 --- /dev/null +++ b/packages/renderer/src/bake.worker.ts @@ -0,0 +1,38 @@ +import { bakeLightmap } from "./bake"; +import type { BakeResponse } from "./bake-client"; +import { readBakeJob } from "./bake-job"; + +// Bakes area lightmaps off the main thread, one job per message. The answer +// carries the job's id; the texels move rather than copy. + +globalThis.addEventListener("message", (event: MessageEvent) => { + // Only the page that made this worker can post to it, and its messages + // carry no origin; anything else is not ours to answer. + if (event.origin !== "" && event.origin !== globalThis.location.origin) { + return; + } + const id = Number( + typeof event.data === "object" && event.data !== null && "id" in event.data + ? event.data.id + : Number.NaN + ); + let response: BakeResponse; + try { + const job = readBakeJob( + typeof event.data === "object" && + event.data !== null && + "job" in event.data + ? event.data.job + : null + ); + response = { id, lightmap: bakeLightmap(job) }; + } catch (error) { + response = { + id, + error: error instanceof Error ? error.message : String(error), + }; + } + globalThis.postMessage(response, { + transfer: "lightmap" in response ? [response.lightmap.data.buffer] : [], + }); +}); diff --git a/packages/renderer/src/fixtures.ts b/packages/renderer/src/fixtures.ts index e8fa4ff..4be0133 100644 --- a/packages/renderer/src/fixtures.ts +++ b/packages/renderer/src/fixtures.ts @@ -1,18 +1,25 @@ -import type { BuiltArea, RoomData } from "@repo/types"; +import type { Rect, RoomData } from "@repo/types"; import { WALL_HEIGHT } from "@repo/world-generator/config"; +import type { AreaRooms } from "./lightmap-layout"; + /** Distance between fluorescent panels in the ceiling grid. */ const LIGHT_SPACING = 4; -/** How far below the ceiling the fixture body hangs. */ -const FIXTURE_DROP = 0.04; /** Where the light itself sits, well clear of the fixture and ceiling. */ const LIGHT_DROP = 0.3; +/** Each fixture's light: shared by the live pool and the bake. */ +const LIGHT_INTENSITY = 14; +const LIGHT_COLOR = "#fff2cc"; +const LIGHT_DISTANCE = 9; + type FixturePoint = { readonly x: number; readonly z: number; /** Ceiling height at this fixture. */ readonly ceiling: number; + /** The floor of its room: who stands on it sees the fixture unblocked. */ + readonly room: Rect; }; /** Fixture centres on a grid roughly LIGHT_SPACING apart, centred in the room. */ @@ -22,6 +29,12 @@ const fixturePositions = (room: RoomData): readonly FixturePoint[] => { const stepX = room.width / columns; const stepZ = room.depth / rows; const [cx, y, cz] = room.position; + const floor: Rect = { + minX: cx - room.width / 2, + maxX: cx + room.width / 2, + minZ: cz - room.depth / 2, + maxZ: cz + room.depth / 2, + }; const positions: FixturePoint[] = []; for (let row = 0; row < rows; row += 1) { for (let column = 0; column < columns; column += 1) { @@ -29,6 +42,7 @@ const fixturePositions = (room: RoomData): readonly FixturePoint[] => { x: cx - room.width / 2 + stepX * (column + 0.5), z: cz - room.depth / 2 + stepZ * (row + 0.5), ceiling: y + WALL_HEIGHT, + room: floor, }); } } @@ -36,10 +50,10 @@ const fixturePositions = (room: RoomData): readonly FixturePoint[] => { }; /** Each area's fixtures, worked out once: an area never changes once built. */ -const fixturesByArea = new WeakMap(); +const fixturesByArea = new WeakMap(); /** Every fixture of an area's rooms, in room order. */ -const areaFixtures = (area: BuiltArea): readonly FixturePoint[] => { +const areaFixtures = (area: AreaRooms): readonly FixturePoint[] => { const known = fixturesByArea.get(area); if (known !== undefined) { return known; @@ -51,5 +65,12 @@ const areaFixtures = (area: BuiltArea): readonly FixturePoint[] => { return created; }; -export { areaFixtures, FIXTURE_DROP, fixturePositions, LIGHT_DROP }; +export { + areaFixtures, + fixturePositions, + LIGHT_COLOR, + LIGHT_DISTANCE, + LIGHT_DROP, + LIGHT_INTENSITY, +}; export type { FixturePoint }; diff --git a/packages/renderer/src/lightmap-layout.test.ts b/packages/renderer/src/lightmap-layout.test.ts new file mode 100644 index 0000000..4874a0b --- /dev/null +++ b/packages/renderer/src/lightmap-layout.test.ts @@ -0,0 +1,132 @@ +import { PlaneGeometry } from "three"; +import { describe, expect, it } from "vitest"; + +import { + layoutArea, + MAX_TEXELS, + TEXEL, + writeLightmapUv, +} from "./lightmap-layout"; +import type { Chart } from "./lightmap-layout"; +import { mergeBoxes } from "./merge"; +import { areaOf, testRoom } from "./test-rooms"; + +/** A chart with its gutter, as packed. */ +const padded = ({ x, y, w, h }: Chart) => ({ + x0: x - 1, + y0: y - 1, + x1: x + w + 1, + y1: y + h + 1, +}); + +const charts = (layout: ReturnType): Chart[] => + layout.faces.flatMap(({ chart }) => (chart === null ? [] : [chart])); + +const TWO_ROOMS = areaOf( + testRoom("a", 0, 0, 4, 4, ["east"]), + testRoom("b", 4, 0, 4, 6, ["west"]) +); + +describe("layoutArea", () => { + it("packs charts without overlap, inside the atlas", () => { + const layout = layoutArea(TWO_ROOMS); + const boxes = charts(layout).map(padded); + for (const [i, a] of boxes.entries()) { + expect(a.x0).toBeGreaterThanOrEqual(0); + expect(a.y0).toBeGreaterThanOrEqual(0); + expect(a.x1).toBeLessThanOrEqual(layout.width); + expect(a.y1).toBeLessThanOrEqual(layout.height); + for (const b of boxes.slice(i + 1)) { + const apart = + a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0; + expect(apart).toBe(true); + } + } + }); + + it("is deterministic", () => { + expect(layoutArea(TWO_ROOMS)).toEqual(layoutArea(TWO_ROOMS)); + }); + + it("gives a room its floor, ceiling and six faces a wall segment", () => { + const layout = layoutArea(TWO_ROOMS); + const [a] = TWO_ROOMS.built.rooms; + expect(layout.rooms[0]).toHaveLength(2 + 6 * (a?.segments.length ?? 0)); + expect(layout.rooms[0]?.[0]).toMatchObject({ axis: 1, sign: 1, at: 0 }); + expect(layout.rooms[0]?.[1]).toMatchObject({ axis: 1, sign: -1 }); + }); + + it("charts a lintel's underside but not a wall's top or bottom", () => { + const layout = layoutArea(TWO_ROOMS); + const segments = TWO_ROOMS.built.rooms[0]?.segments ?? []; + segments.forEach((segment, index) => { + // +y is the third face of a box, −y the fourth. + const top = layout.rooms[0]?.[2 + index * 6 + 2]; + const bottom = layout.rooms[0]?.[2 + index * 6 + 3]; + expect(top?.chart).toBeNull(); + expect(bottom?.chart !== null).toBe(segment.kind === "lintel"); + }); + }); + + it("doubles the texel when the atlas would exceed MAX_TEXELS", () => { + const huge = layoutArea(areaOf(testRoom("h", 0, 0, 200, 200))); + expect(huge.texel).toBeGreaterThan(TEXEL); + expect(huge.width * huge.height).toBeLessThanOrEqual(MAX_TEXELS); + expect(layoutArea(TWO_ROOMS).texel).toBe(TEXEL); + }); + + it("fits a long narrow corridor's floor in the atlas width", () => { + const layout = layoutArea(areaOf(testRoom("c", 0, 0, 120, 2))); + const floor = layout.rooms[0]?.[0]?.chart; + expect(floor).not.toBeNull(); + expect((floor?.x ?? 0) + (floor?.w ?? 0)).toBeLessThan(layout.width); + }); +}); + +describe("writeLightmapUv", () => { + it("maps every vertex of a visible face inside its chart", () => { + const layout = layoutArea(TWO_ROOMS); + const room = TWO_ROOMS.built.rooms[0]; + const faces = layout.rooms[0] ?? []; + const walls = mergeBoxes(room?.segments ?? [], 1); + expect(walls).not.toBeNull(); + if (walls === null) { + return; + } + writeLightmapUv(walls, faces.slice(2), layout, 4); + const uv1 = walls.getAttribute("uv1"); + for (let i = 0; i < uv1.count; i += 1) { + const chart = faces[2 + Math.floor(i / 4)]?.chart; + if (chart === null || chart === undefined) { + continue; + } + // uv1 is stored as float32. + const x = uv1.getX(i) * layout.width; + const y = uv1.getY(i) * layout.height; + expect(x).toBeGreaterThanOrEqual(chart.x - 1e-4); + expect(x).toBeLessThanOrEqual(chart.x + chart.w + 1e-4); + expect(y).toBeGreaterThanOrEqual(chart.y - 1e-4); + expect(y).toBeLessThanOrEqual(chart.y + chart.h + 1e-4); + } + }); + + it("spans the whole chart for a floor in world space", () => { + const layout = layoutArea(TWO_ROOMS); + const floorFace = layout.rooms[0]?.[0]; + const chart = floorFace?.chart; + const plane = new PlaneGeometry(4, 4).rotateX(-Math.PI / 2); + writeLightmapUv( + plane, + floorFace === undefined ? [] : [floorFace], + layout, + 4 + ); + const uv1 = plane.getAttribute("uv1"); + const xs = Array.from({ length: 4 }, (_, i) => uv1.getX(i) * layout.width); + const ys = Array.from({ length: 4 }, (_, i) => uv1.getY(i) * layout.height); + expect(Math.min(...xs)).toBeCloseTo(chart?.x ?? -1); + expect(Math.max(...xs)).toBeCloseTo((chart?.x ?? 0) + (chart?.w ?? 0)); + expect(Math.min(...ys)).toBeCloseTo(chart?.y ?? -1); + expect(Math.max(...ys)).toBeCloseTo((chart?.y ?? 0) + (chart?.h ?? 0)); + }); +}); diff --git a/packages/renderer/src/lightmap-layout.ts b/packages/renderer/src/lightmap-layout.ts new file mode 100644 index 0000000..2a3ce6a --- /dev/null +++ b/packages/renderer/src/lightmap-layout.ts @@ -0,0 +1,329 @@ +import type { BuiltWorld, Vec3, WallSegment } from "@repo/types"; +import { WALL_HEIGHT } from "@repo/world-generator/config"; +import { Float32BufferAttribute } from "three"; +import type { BufferGeometry } from "three"; + +import type { Rgb } from "./lane-tint"; +import { segmentColour } from "./wall-colour"; + +// Every surface the bake lights, as an axis-aligned rectangle, and its patch +// (chart) in the area's lightmap. Shared by the page, which writes each +// mesh's `uv1` from it, and the bake worker, which fills the texels: both +// place a face by projecting onto its two tangent axes, so they agree on +// where a texel lies without depending on any geometry's vertex order. + +/** World axis index: 0 = x, 1 = y, 2 = z. */ +type Axis = 0 | 1 | 2; + +/** A rectangle of texels in the atlas: the chart's interior, gutter excluded. */ +type Chart = { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; +}; + +/** + * One flat, axis-aligned face. It lies in the plane `axis = at`, faces + * `sign` along that axis and spans [min, max] on its tangent axes, which + * are the other two axes in x, y, z order. + */ +type LightmapFace = { + readonly axis: Axis; + readonly sign: 1 | -1; + readonly at: number; + readonly min: readonly [number, number]; + readonly max: readonly [number, number]; + /** Linear diffuse colour, for light bounced off it. */ + readonly albedo: Rgb; + /** Its texels; null for a face no one sees (on the floor, under the ceiling). */ + readonly chart: Chart | null; + /** Index of its room in the area, for the solids it may sit inside. */ + readonly room: number; +}; + +/** An axis-aligned box: a wall segment, as the bake sees it. */ +type Solid = { + readonly min: Vec3; + readonly max: Vec3; +}; + +type AreaLayout = { + readonly width: number; + readonly height: number; + /** Metres per texel. */ + readonly texel: number; + readonly faces: readonly LightmapFace[]; + /** + * Each room's faces, a run of `faces`: floor, ceiling, then the six faces + * of every wall segment in `BoxGeometry` order (+x, −x, +y, −y, +z, −z), + * so a room's merged walls line up with them four vertices a face. + */ + readonly rooms: readonly (readonly LightmapFace[])[]; + /** Each room's wall segments, as solids. */ + readonly solids: readonly (readonly Solid[])[]; +}; + +/** + * Metres per texel, before an area too large for the budget doubles it. + * Bounce light is soft, so half a metre holds it; contact shadows come from + * the surface maps' own occlusion. At 0.25 m a typical area of the + * repository's own world took 6 s to bake. + */ +const TEXEL = 0.5; +/** + * At most this many texels in one area's atlas: 1 MiB of half-float RGBA, + * and a few seconds' bake for the largest area of the repository's world. + */ +const MAX_TEXELS = 128 * 1024; +/** Longest atlas side. */ +const MAX_ATLAS = 1024; +/** The coarsest texel: no face is wider than a few of these. */ +const MAX_TEXEL = 8; +/** A face this close to the floor or ceiling is against it, unseen. */ +const FLUSH = 0.01; + +/** + * Mean linear colour of each surface's albedo map: the painter's base colour + * in `textures.ts`, a few per cent darker for its pattern and speckle. + */ +const ALBEDO = { + wallpaper: [0.59, 0.46, 0.1], + carpet: [0.25, 0.175, 0.062], + ceiling: [0.67, 0.62, 0.48], +} as const satisfies Record; + +const times = ([r, g, b]: Rgb, [tr, tg, tb]: Rgb): Rgb => [ + r * tr, + g * tg, + b * tb, +]; + +const TANGENTS = [ + [1, 2], + [0, 2], + [0, 1], +] as const satisfies readonly (readonly [Axis, Axis])[]; + +/** The two tangent axes of a face on `axis`, in x, y, z order. */ +const tangents = (axis: Axis): readonly [Axis, Axis] => TANGENTS[axis]; + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +type Draft = Omit & { readonly visible: boolean }; + +/** A box's six faces in `BoxGeometry` order. */ +const boxFaces = ( + { min, max }: Solid, + albedo: Rgb, + room: number, + floor: number, + ceiling: number +): Draft[] => + ([0, 1, 2] as const).flatMap((axis) => { + const [a, b] = tangents(axis); + const span = { + min: [min[a], min[b]] as const, + max: [max[a], max[b]] as const, + }; + // The top is against the ceiling and the bottom on the floor, except + // for a lintel's underside over a door. + const top = axis !== 1 || max[1] < ceiling - FLUSH; + const bottom = axis !== 1 || min[1] > floor + FLUSH; + return [ + { axis, sign: 1, at: max[axis], ...span, albedo, room, visible: top }, + { axis, sign: -1, at: min[axis], ...span, albedo, room, visible: bottom }, + ] as const; + }); + +const solidOf = ({ center, size }: WallSegment): Solid => ({ + min: [ + center[0] - size[0] / 2, + center[1] - size[1] / 2, + center[2] - size[2] / 2, + ], + max: [ + center[0] + size[0] / 2, + center[1] + size[1] / 2, + center[2] + size[2] / 2, + ], +}); + +/** What the layout reads of an area: its built rooms. */ +type AreaRooms = { readonly built: Pick }; + +/** Every face of an area, room by room, before any chart is placed. */ +const draftFaces = (area: AreaRooms) => { + const drafts: Draft[] = []; + const roomStart: number[] = []; + const solids: Solid[][] = []; + area.built.rooms.forEach(({ room, segments }, index) => { + roomStart.push(drafts.length); + const [x, y, z] = room.position; + const top = y + WALL_HEIGHT; + const span = { + min: [x - room.width / 2, z - room.depth / 2] as const, + max: [x + room.width / 2, z + room.depth / 2] as const, + }; + drafts.push( + { + axis: 1, + sign: 1, + at: y, + ...span, + albedo: ALBEDO.carpet, + room: index, + visible: true, + }, + { + axis: 1, + sign: -1, + at: top, + ...span, + albedo: ALBEDO.ceiling, + room: index, + visible: true, + } + ); + const boxes = segments.map(solidOf); + solids.push(boxes); + segments.forEach((segment, i) => { + const albedo = times(ALBEDO.wallpaper, segmentColour(segment, room.id)); + const box = boxes[i]; + if (box !== undefined) { + drafts.push(...boxFaces(box, albedo, index, y, top)); + } + }); + }); + return { drafts, roomStart, solids }; +}; + +/** Texels a face needs at `texel` metres each: at least one per side. */ +const chartSize = (face: Draft, texel: number) => ({ + w: Math.max(1, Math.ceil((face.max[0] - face.min[0]) / texel - 1e-6)), + h: Math.max(1, Math.ceil((face.max[1] - face.min[1]) / texel - 1e-6)), +}); + +const roundUp4 = (value: number): number => Math.ceil(value / 4) * 4; + +/** + * Shelf-packs the visible faces' charts, each with a one-texel gutter, the + * tallest first: an atlas as wide as the widest chart or a bit over the + * square root of the total, whichever is more. + */ +const pack = (drafts: readonly Draft[], texel: number) => { + const sizes = drafts.map((draft) => + draft.visible ? chartSize(draft, texel) : null + ); + let total = 0; + let widest = 0; + for (const size of sizes) { + if (size !== null) { + total += (size.w + 2) * (size.h + 2); + widest = Math.max(widest, size.w + 2); + } + } + const width = roundUp4(Math.max(widest, Math.ceil(Math.sqrt(total) * 1.1))); + const order = sizes + .map((size, index) => ({ size, index })) + .filter( + (entry): entry is { size: { w: number; h: number }; index: number } => + entry.size !== null + ) + .sort((p, q) => q.size.h - p.size.h || p.index - q.index); + const charts: (Chart | null)[] = sizes.map(() => null); + let x = 0; + let y = 0; + let shelf = 0; + for (const { size, index } of order) { + if (x + size.w + 2 > width) { + x = 0; + y += shelf; + shelf = 0; + } + charts[index] = { x: x + 1, y: y + 1, w: size.w, h: size.h }; + x += size.w + 2; + shelf = Math.max(shelf, size.h + 2); + } + return { width, height: Math.max(1, y + shelf), charts }; +}; + +/** + * The area's lightmap: its faces and where each one's texels lie. An area + * too big for the texel budget is laid out again at twice the texel size, + * as often as it takes. + */ +const layoutArea = (area: AreaRooms): AreaLayout => { + const { drafts, roomStart, solids } = draftFaces(area); + let texel = TEXEL; + let packed = pack(drafts, texel); + // Every face keeps at least one texel and its gutter, so past some size + // doubling gains nothing: stop at MAX_TEXEL and take the atlas as it is. + while ( + texel < MAX_TEXEL && + (packed.width * packed.height > MAX_TEXELS || + packed.width > MAX_ATLAS || + packed.height > MAX_ATLAS) + ) { + texel *= 2; + packed = pack(drafts, texel); + } + const faces = drafts.map( + ({ visible: _visible, ...face }, index): LightmapFace => ({ + ...face, + chart: packed.charts[index] ?? null, + }) + ); + return { + width: packed.width, + height: packed.height, + texel, + faces, + rooms: roomStart.map((start, room) => + faces.slice(start, roomStart[room + 1] ?? faces.length) + ), + solids, + }; +}; + +/** Where a point on a face falls in its chart, 0..1 on each tangent axis. */ +const faceUv = ( + face: LightmapFace, + a: number, + b: number +): readonly [number, number] => [ + clamp01((a - face.min[0]) / (face.max[0] - face.min[0] || 1)), + clamp01((b - face.min[1]) / (face.max[1] - face.min[1] || 1)), +]; + +/** + * Writes a `uv1` attribute: each run of `perFace` vertices is the next face + * of `faces`, and its vertices map onto that face's chart by their world + * position. A face without a chart maps to the atlas origin; it is never + * seen. The geometry must be in world space. + */ +const writeLightmapUv = ( + geometry: BufferGeometry, + faces: readonly LightmapFace[], + layout: Pick, + perFace: number +): void => { + const position = geometry.getAttribute("position"); + const data = new Float32Array(position.count * 2); + for (let i = 0; i < position.count; i += 1) { + const face = faces[Math.floor(i / perFace)]; + if (face?.chart === null || face === undefined) { + continue; + } + const [ta, tb] = tangents(face.axis); + const point = [position.getX(i), position.getY(i), position.getZ(i)]; + const [u, v] = faceUv(face, point[ta] ?? 0, point[tb] ?? 0); + const { chart } = face; + data[i * 2] = (chart.x + u * chart.w) / layout.width; + data[i * 2 + 1] = (chart.y + v * chart.h) / layout.height; + } + geometry.setAttribute("uv1", new Float32BufferAttribute(data, 2)); +}; + +export { faceUv, layoutArea, MAX_TEXELS, tangents, TEXEL, writeLightmapUv }; +export type { AreaLayout, AreaRooms, Chart, LightmapFace, Solid }; diff --git a/packages/renderer/src/lights.tsx b/packages/renderer/src/lights.tsx index 49ce34c..d5b524a 100644 --- a/packages/renderer/src/lights.tsx +++ b/packages/renderer/src/lights.tsx @@ -1,10 +1,17 @@ import { useFrame } from "@react-three/fiber"; import { hashString } from "@repo/world-generator/random"; import { useEffect, useMemo, useRef } from "react"; -import type { PointLight } from "three"; +import type { PointLight, RectAreaLight } from "three"; +import { RectAreaLightUniformsLib } from "three/addons/lights/RectAreaLightUniformsLib.js"; import type { FixturePoint } from "./fixtures"; -import { LIGHT_DROP } from "./fixtures"; +import { + LIGHT_COLOR, + LIGHT_DISTANCE, + LIGHT_DROP, + LIGHT_INTENSITY, +} from "./fixtures"; +import { DIFFUSER, DIFFUSER_DROP } from "./troffer"; type LightPoolProps = { readonly fixtures: readonly FixturePoint[]; @@ -12,9 +19,31 @@ type LightPoolProps = { /** Lights in flight at once. Fixed, so the shaders compile exactly once. */ const POOL_SIZE = 8; -const LIGHT_INTENSITY = 14; -const LIGHT_COLOR = "#fff2cc"; -const LIGHT_DISTANCE = 9; + +/** + * Area lights on the nearest fixtures, for the diffuser's true shape in the + * floor's sheen. Always mounted, so their count, too, is fixed. + */ +const RECT_COUNT = 2; +/** + * The share of a fixture's light its area light takes; the point light at + * the same fixture keeps the rest, and with it the shadow. + */ +const RECT_SHARE = 0.6; +const DIFFUSER_AREA = DIFFUSER.length * DIFFUSER.width; + +/** An area light emits along its −z: turned to face the floor. */ +const FACING_DOWN = [-Math.PI / 2, 0, 0] as const; + +let rectAreaReady = false; + +/** The area-light lookup tables, set up once per page. */ +const prepareRectAreaLights = () => { + if (!rectAreaReady) { + RectAreaLightUniformsLib.init(); + rectAreaReady = true; + } +}; /** Slot 0 (the nearest fixture) is the one shadow caster. */ const SHADOW_SLOT = 0; @@ -65,12 +94,15 @@ const nearestFixtures = ( /** * A fixed pool of point lights that follows the player: each frame the lights - * move to the nearest fixtures. The shadow map is static (the Canvas turns + * move to the nearest fixtures, and the nearest few also get an area light + * the size of the diffuser. The shadow map is static (the Canvas turns * `shadowMap.autoUpdate` off), re-rendered only when the shadow light hops to * a new fixture or the world changes. */ const LightPool = ({ fixtures }: LightPoolProps) => { + prepareRectAreaLights(); const lights = useRef<(PointLight | null)[]>([]); + const rects = useRef<(RectAreaLight | null)[]>([]); const nearest = useRef([]); const distances = useRef([]); const shadowFixture = useRef(-1); @@ -90,6 +122,8 @@ const LightPool = ({ fixtures }: LightPoolProps) => { nearest.current, distances.current ); + const { x, z } = camera.position; + let rectsUsed = 0; for (let slot = 0; slot < POOL_SIZE; slot += 1) { const light = lights.current[slot]; const index = nearest.current[slot]; @@ -104,13 +138,45 @@ const LightPool = ({ fixtures }: LightPoolProps) => { light.position.set(fixture.x, fixture.ceiling - LIGHT_DROP, fixture.z); // The phase follows the fixture, so a light keeps its buzz when the // pool hands it to another slot. - light.intensity = + const strength = LIGHT_INTENSITY * flicker(clock.elapsedTime + (phases[index] ?? 0)); + // Area lights cast no shadow: only a fixture over the player's own + // room gets one, so none shines through a wall. The rest stay whole + // point lights. + const { room } = fixture; + const rect = + rectsUsed < RECT_COUNT && + x >= room.minX && + x <= room.maxX && + z >= room.minZ && + z <= room.maxZ + ? rects.current[rectsUsed] + : null; + if (rect) { + rectsUsed += 1; + // An area light's intensity is luminance: a fixture's on-axis + // intensity spread over the diffuser. + rect.position.set( + fixture.x, + fixture.ceiling - DIFFUSER_DROP - DIFFUSER.thickness, + fixture.z + ); + rect.intensity = (strength * RECT_SHARE) / DIFFUSER_AREA; + light.intensity = strength * (1 - RECT_SHARE); + } else { + light.intensity = strength; + } if (slot === SHADOW_SLOT && index !== shadowFixture.current) { shadowFixture.current = index; gl.shadowMap.needsUpdate = true; } } + for (let slot = rectsUsed; slot < RECT_COUNT; slot += 1) { + const rect = rects.current[slot]; + if (rect) { + rect.intensity = 0; + } + } }); return ( @@ -131,6 +197,19 @@ const LightPool = ({ fixtures }: LightPoolProps) => { shadow-camera-far={12} /> ))} + {Array.from({ length: RECT_COUNT }, (_, slot) => ( + { + rects.current[slot] = light; + }} + color={LIGHT_COLOR} + intensity={0} + width={DIFFUSER.length} + height={DIFFUSER.width} + rotation={FACING_DOWN} + /> + ))} ); }; diff --git a/packages/renderer/src/room.tsx b/packages/renderer/src/room.tsx index 098e29c..834e3e4 100644 --- a/packages/renderer/src/room.tsx +++ b/packages/renderer/src/room.tsx @@ -1,97 +1,90 @@ import type { BuiltRoom } from "@repo/types"; import { WALL_HEIGHT } from "@repo/world-generator/config"; -import { hashString } from "@repo/world-generator/random"; import { useEffect, useMemo } from "react"; -import { FIXTURE_DROP, fixturePositions } from "./fixtures"; -import { lintelTint, wallTint } from "./lane-tint"; -import type { Rgb } from "./lane-tint"; +import type { AreaLighting } from "./area-lighting"; +import { fixturePositions } from "./fixtures"; +import type { LightmapFace } from "./lightmap-layout"; +import { writeLightmapUv } from "./lightmap-layout"; import { mergeBoxes } from "./merge"; import { useSurfaces } from "./surfaces"; import { tiledPlane } from "./tiled-geometry"; +import { diffuserBox, housingBoxes } from "./troffer"; +import { segmentColour } from "./wall-colour"; type RoomProps = { readonly built: BuiltRoom; + /** Its area's lightmapped materials and layout. */ + readonly lighting: AreaLighting; + /** Its faces in the area's lightmap: floor, ceiling, then its walls. */ + readonly faces: readonly LightmapFace[]; }; -const FIXTURE_SIZE = [1.2, 0.08, 0.3] as const; +/** BoxGeometry and PlaneGeometry both give each face four vertices. */ +const PER_FACE = 4; /** - * Each room's wallpaper a shade lighter or darker (within 4 %), fixed by its - * id, so neighbouring rooms are not quite the same. + * One room (or corridor) in six draw calls: merged walls and lintels, floor, + * ceiling, merged troffer housings and diffusers. Door frames are drawn once + * per shared edge by the World, not here. Walls, floor and ceiling are built + * in world space and carry `uv1` into the area's lightmap. */ -const wallShade = (roomId: string): number => - 1 + ((hashString(roomId) % 81) - 40) / 1000; - -// Not clamped at 1: plain walls are white, and vertex colours just above 1 -// brighten the wallpaper texture, so lighter rooms differ as darker ones do. -const shaded = ([r, g, b]: Rgb, shade: number): Rgb => [ - r * shade, - g * shade, - b * shade, -]; - -/** - * One room (or corridor) in five draw calls: merged walls and lintels, floor, - * ceiling, merged fixture panels. Door frames are drawn once per shared edge - * by the World, not here. - */ -const Room = ({ built }: RoomProps) => { +const Room = ({ built, lighting, faces }: RoomProps) => { const surfaces = useSurfaces(); const { room } = built; const [x, y, z] = room.position; const top = y + WALL_HEIGHT; - // Walls take their lane's wash, lintels the full colour of their door's - // lane, both in the room's own shade. + const { layout } = lighting; const walls = useMemo(() => { - const shade = wallShade(built.room.id); - return mergeBoxes( + const merged = mergeBoxes( built.segments.map((segment) => ({ ...segment, - color: shaded( - segment.kind === "lintel" - ? lintelTint(segment.lane) - : wallTint(segment.lane), - shade - ), + color: segmentColour(segment, built.room.id), })), surfaces.tile.wallpaper ); - }, [built.room.id, built.segments, surfaces.tile.wallpaper]); - const floor = useMemo( - () => tiledPlane(room.width, room.depth, surfaces.tile.carpet), - [room.width, room.depth, surfaces.tile.carpet] - ); - const ceiling = useMemo( - () => tiledPlane(room.width, room.depth, surfaces.tile.ceiling), - [room.width, room.depth, surfaces.tile.ceiling] - ); - const fixtures = useMemo( - () => - mergeBoxes( - fixturePositions(room).map((point) => ({ - center: [point.x, point.ceiling - FIXTURE_DROP, point.z] as const, - size: FIXTURE_SIZE, - })), - 1 - ), - [room] - ); + if (merged !== null) { + writeLightmapUv(merged, faces.slice(2), layout, PER_FACE); + } + return merged; + }, [built.room.id, built.segments, surfaces.tile.wallpaper, faces, layout]); + const floor = useMemo(() => { + const plane = tiledPlane(room.width, room.depth, surfaces.tile.carpet) + .rotateX(-Math.PI / 2) + .translate(x, y, z); + writeLightmapUv(plane, faces.slice(0, 1), layout, PER_FACE); + return plane; + }, [room.width, room.depth, surfaces.tile.carpet, x, y, z, faces, layout]); + const ceiling = useMemo(() => { + const plane = tiledPlane(room.width, room.depth, surfaces.tile.ceiling) + .rotateX(Math.PI / 2) + .translate(x, top, z); + writeLightmapUv(plane, faces.slice(1, 2), layout, PER_FACE); + return plane; + }, [room.width, room.depth, surfaces.tile.ceiling, x, top, z, faces, layout]); + const troffers = useMemo(() => { + const points = fixturePositions(room); + return { + housings: mergeBoxes(points.flatMap(housingBoxes), 1), + diffusers: mergeBoxes(points.map(diffuserBox), 1), + }; + }, [room]); useEffect( () => () => { walls?.dispose(); floor.dispose(); ceiling.dispose(); - fixtures?.dispose(); + troffers.housings?.dispose(); + troffers.diffusers?.dispose(); }, - [walls, floor, ceiling, fixtures] + [walls, floor, ceiling, troffers] ); return ( {walls !== null && ( { )} - {fixtures !== null && ( - + {troffers.housings !== null && ( + + )} + {troffers.diffusers !== null && ( + )} ); diff --git a/packages/renderer/src/surfaces.ts b/packages/renderer/src/surfaces.ts index 5f84412..981b819 100644 --- a/packages/renderer/src/surfaces.ts +++ b/packages/renderer/src/surfaces.ts @@ -1,6 +1,8 @@ import { createContext, useContext } from "react"; -import { MeshStandardMaterial, Vector2 } from "three"; +import { DataUtils, MeshStandardMaterial, Vector2 } from "three"; +import { lightmapTexture } from "./area-lighting"; +import type { Rgb } from "./lane-tint"; import { createSurfaceTextures, TILE } from "./textures"; import type { SurfaceMaps } from "./textures"; @@ -12,8 +14,10 @@ type Surfaces = { readonly wall: MeshStandardMaterial; readonly floor: MeshStandardMaterial; readonly ceiling: MeshStandardMaterial; - /** The glowing fluorescent fixture body. */ + /** The glowing diffuser of a fluorescent troffer. */ readonly fixture: MeshStandardMaterial; + /** The enamelled housing around it. */ + readonly troffer: MeshStandardMaterial; /** Dark wood door frames. */ readonly frame: MeshStandardMaterial; /** The dark plane filling a call portal: a cold glow. */ @@ -62,22 +66,54 @@ const mapped = ( aoMapIntensity: occlusion, }); +/** + * The indirect light of an area not baked yet: flat, about what the bake + * gives an open stretch of wall, so the baked map arriving is no jump. + */ +const PLACEHOLDER_FILL: Rgb = [0.2, 0.16, 0.09]; + +/** A one-texel lightmap of the flat fill, read like a baked one. */ +const placeholderLightmap = () => { + const [r, g, b] = PLACEHOLDER_FILL.map((value) => + DataUtils.toHalfFloat(value) + ); + return lightmapTexture( + new Uint16Array([r ?? 0, g ?? 0, b ?? 0, DataUtils.toHalfFloat(1)]), + 1, + 1 + ); +}; + const createSurfaces = (anisotropy: number): Surfaces => { const textures = createSurfaceTextures(anisotropy); + // Walls, floor and ceiling carry a lightmap from the start, so the shader + // is the same before and after an area's bake lands; Areas use clones. + const lightMap = placeholderLightmap(); // Walls carry vertex colours: white as built, a lane's wash inside a fork // of a function. const wall = new MeshStandardMaterial({ ...mapped(textures.wallpaper, 0.7, 0.7), vertexColors: true, + lightMap, + }); + const floor = new MeshStandardMaterial({ + ...mapped(textures.carpet, 1, 0.9), + lightMap, + }); + const ceiling = new MeshStandardMaterial({ + ...mapped(textures.ceiling, 0.8, 0.8), + lightMap, }); - const floor = new MeshStandardMaterial(mapped(textures.carpet, 1, 0.9)); - const ceiling = new MeshStandardMaterial(mapped(textures.ceiling, 0.8, 0.8)); const fixture = new MeshStandardMaterial({ color: "#fff8e1", emissive: "#fff1c4", emissiveIntensity: 1.6, roughness: 0.4, }); + const troffer = new MeshStandardMaterial({ + color: "#e8e4da", + roughness: 0.45, + }); // Frames are coloured per vertex: dark wood, or a lane's colour. const frame = new MeshStandardMaterial({ color: "#ffffff", @@ -105,6 +141,7 @@ const createSurfaces = (anisotropy: number): Surfaces => { floor, ceiling, fixture, + troffer, frame, voidCall, voidReturn, @@ -118,6 +155,7 @@ const createSurfaces = (anisotropy: number): Surfaces => { floor, ceiling, fixture, + troffer, frame, voidCall, voidReturn, @@ -130,6 +168,7 @@ const createSurfaces = (anisotropy: number): Surfaces => { for (const material of materials) { material.dispose(); } + lightMap.dispose(); for (const maps of Object.values(textures)) { for (const texture of Object.values(maps)) { texture.dispose(); diff --git a/packages/renderer/src/test-rooms.ts b/packages/renderer/src/test-rooms.ts new file mode 100644 index 0000000..2f986fb --- /dev/null +++ b/packages/renderer/src/test-rooms.ts @@ -0,0 +1,89 @@ +import type { BuiltRoom, WallSegment } from "@repo/types"; +import { + DOOR_HEIGHT, + DOOR_WIDTH, + WALL_HEIGHT, + WALL_THICKNESS, +} from "@repo/world-generator/config"; + +import type { AreaRooms } from "./lightmap-layout"; + +// Hand-built rooms for the lighting tests: a rectangle walled on all four +// sides inside its own footprint, optionally with a door on its east or +// west wall at z = its centre. + +type Side = "east" | "west"; + +const wall = ( + center: readonly [number, number, number], + size: readonly [number, number, number], + kind: WallSegment["kind"] = "wall" +): WallSegment => ({ center: [...center], size: [...size], kind }); + +/** A wall along z at `x` from z0 to z1, with a door gap at `door` if given. */ +const zWall = ( + x: number, + z0: number, + z1: number, + door: number | null +): WallSegment[] => { + const h = WALL_HEIGHT / 2; + if (door === null) { + return [ + wall([x, h, (z0 + z1) / 2], [WALL_THICKNESS, WALL_HEIGHT, z1 - z0]), + ]; + } + const g0 = door - DOOR_WIDTH / 2; + const g1 = door + DOOR_WIDTH / 2; + const lintel = WALL_HEIGHT - DOOR_HEIGHT; + return [ + wall([x, h, (z0 + g0) / 2], [WALL_THICKNESS, WALL_HEIGHT, g0 - z0]), + wall([x, h, (g1 + z1) / 2], [WALL_THICKNESS, WALL_HEIGHT, z1 - g1]), + wall( + [x, DOOR_HEIGHT + lintel / 2, door], + [WALL_THICKNESS, lintel, DOOR_WIDTH], + "lintel" + ), + ]; +}; + +/** A room centred on (x, z), walled inside its footprint. */ +const testRoom = ( + id: string, + x: number, + z: number, + width: number, + depth: number, + doors: readonly Side[] = [] +): BuiltRoom => { + const t = WALL_THICKNESS / 2; + const x0 = x - width / 2; + const x1 = x + width / 2; + const z0 = z - depth / 2; + const z1 = z + depth / 2; + const h = WALL_HEIGHT / 2; + return { + room: { id, kind: "room", position: [x, 0, z], width, depth, doors: [] }, + openings: [], + segments: [ + wall([x, h, z0 + t], [width, WALL_HEIGHT, WALL_THICKNESS]), + wall([x, h, z1 - t], [width, WALL_HEIGHT, WALL_THICKNESS]), + ...zWall( + x0 + t, + z0 + WALL_THICKNESS, + z1 - WALL_THICKNESS, + doors.includes("west") ? z : null + ), + ...zWall( + x1 - t, + z0 + WALL_THICKNESS, + z1 - WALL_THICKNESS, + doors.includes("east") ? z : null + ), + ], + }; +}; + +const areaOf = (...rooms: BuiltRoom[]): AreaRooms => ({ built: { rooms } }); + +export { areaOf, testRoom }; diff --git a/packages/renderer/src/troffer.test.ts b/packages/renderer/src/troffer.test.ts new file mode 100644 index 0000000..d2c290e --- /dev/null +++ b/packages/renderer/src/troffer.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { diffuserBox, housingBoxes } from "./troffer"; + +const AT = { x: 2, z: -1, ceiling: 2.7 }; + +const extent = ( + center: readonly number[], + size: readonly number[], + axis: number +) => ({ + min: (center[axis] ?? 0) - (size[axis] ?? 0) / 2, + max: (center[axis] ?? 0) + (size[axis] ?? 0) / 2, +}); + +describe("troffer", () => { + it("hangs entirely below the ceiling plane", () => { + for (const { center, size } of [...housingBoxes(AT), diffuserBox(AT)]) { + expect(extent(center, size, 1).max).toBeLessThanOrEqual( + AT.ceiling + 1e-9 + ); + } + }); + + it("sets the diffuser inside the rim, above its lower edge", () => { + const { center, size } = diffuserBox(AT); + const rims = housingBoxes(AT).slice(1); + const rimBottom = Math.min( + ...rims.map((rim) => extent(rim.center, rim.size, 1).min) + ); + expect(extent(center, size, 1).min).toBeGreaterThan(rimBottom); + const [plate] = housingBoxes(AT); + for (const axis of [0, 2]) { + const panel = extent(center, size, axis); + const pan = extent(plate?.center ?? [], plate?.size ?? [], axis); + expect(panel.min).toBeGreaterThan(pan.min); + expect(panel.max).toBeLessThan(pan.max); + } + }); +}); diff --git a/packages/renderer/src/troffer.ts b/packages/renderer/src/troffer.ts new file mode 100644 index 0000000..8969ec3 --- /dev/null +++ b/packages/renderer/src/troffer.ts @@ -0,0 +1,53 @@ +import type { Box } from "./merge"; + +/** + * A surface-mounted 2 × 4 ft troffer: the ceiling is one uncut plane per + * room, so the fixture hangs below it rather than sitting in a recess. A + * shallow enamelled pan (back plate and four rim bars) and a diffuser set + * up inside the rim. Long side along x. + */ +const HOUSING = { + length: 1.22, + width: 0.61, + plate: 0.02, + rim: 0.06, + bar: 0.03, +}; +/** The glowing panel: just inside the rim, set up from its lower edge. */ +const DIFFUSER = { length: 1.16, width: 0.55, thickness: 0.005, inset: 0.02 }; + +type FixtureAt = { + readonly x: number; + readonly z: number; + readonly ceiling: number; +}; + +/** The pan of one troffer: back plate against the ceiling and four rim bars. */ +const housingBoxes = ({ x, z, ceiling }: FixtureAt): Box[] => { + const { length, width, plate, rim, bar } = HOUSING; + const rimY = ceiling - plate - rim / 2; + return [ + { center: [x, ceiling - plate / 2, z], size: [length, plate, width] }, + { center: [x, rimY, z - width / 2 + bar / 2], size: [length, rim, bar] }, + { center: [x, rimY, z + width / 2 - bar / 2], size: [length, rim, bar] }, + { + center: [x - length / 2 + bar / 2, rimY, z], + size: [bar, rim, width - bar * 2], + }, + { + center: [x + length / 2 - bar / 2, rimY, z], + size: [bar, rim, width - bar * 2], + }, + ]; +}; + +/** How far below the ceiling the diffuser's centre sits. */ +const DIFFUSER_DROP = HOUSING.plate + HOUSING.rim - DIFFUSER.inset; + +/** The diffuser panel of one troffer, inside the rim. */ +const diffuserBox = ({ x, z, ceiling }: FixtureAt): Box => ({ + center: [x, ceiling - DIFFUSER_DROP, z], + size: [DIFFUSER.length, DIFFUSER.thickness, DIFFUSER.width], +}); + +export { DIFFUSER, DIFFUSER_DROP, diffuserBox, housingBoxes }; diff --git a/packages/renderer/src/wall-colour.ts b/packages/renderer/src/wall-colour.ts new file mode 100644 index 0000000..01df652 --- /dev/null +++ b/packages/renderer/src/wall-colour.ts @@ -0,0 +1,29 @@ +import type { WallSegment } from "@repo/types"; +import { hashString } from "@repo/world-generator/random"; + +import { lintelTint, wallTint } from "./lane-tint"; +import type { Rgb } from "./lane-tint"; + +/** + * Each room's wallpaper a shade lighter or darker (within 4 %), fixed by its + * id, so neighbouring rooms are not quite the same. + */ +const wallShade = (roomId: string): number => + 1 + ((hashString(roomId) % 81) - 40) / 1000; + +/** + * A wall segment's vertex colour: walls take their lane's wash, lintels the + * full colour of their door's lane, both in the room's own shade. Not + * clamped at 1: plain walls are white, and vertex colours just above 1 + * brighten the wallpaper texture, so lighter rooms differ as darker ones do. + */ +const segmentColour = (segment: WallSegment, roomId: string): Rgb => { + const shade = wallShade(roomId); + const [r, g, b] = + segment.kind === "lintel" + ? lintelTint(segment.lane) + : wallTint(segment.lane); + return [r * shade, g * shade, b * shade]; +}; + +export { segmentColour }; diff --git a/packages/renderer/src/world.tsx b/packages/renderer/src/world.tsx index af83b30..fb0f0eb 100644 --- a/packages/renderer/src/world.tsx +++ b/packages/renderer/src/world.tsx @@ -6,6 +6,8 @@ import { BackSide } from "three"; import type { Mesh } from "three"; import { Area } from "./area"; +import { createAreaLighting } from "./area-lighting"; +import { createBakeQueue, createBakeWorker } from "./bake-client"; import { areaFixtures } from "./fixtures"; import { LightPool } from "./lights"; import { createSurfaces, SurfacesContext } from "./surfaces"; @@ -16,6 +18,14 @@ type WorldProps = { const HAZE = "#4d441f"; +/** + * The fill light: warm from the ceiling, mustard off the carpet below, so + * an unlightmapped underside (a door frame's head) is never black. + */ +const SKY = "#ffe9b3"; +const GROUND = "#d9c27a"; +const FILL_INTENSITY = 0.3; + /** Past full fog (28 m) and inside the camera's far plane (32 m). */ const HAZE_RADIUS = 30; @@ -43,7 +53,10 @@ const Haze = () => { ); }; -/** Scene-wide setup: haze, fog, ambient light, shared materials, areas. */ +/** + * Scene-wide setup: haze, fog, a faint fill light, shared materials, each + * area's baked lighting, the areas. + */ const World = ({ world }: WorldProps) => { const gl = useThree((state) => state.gl); const scene = useThree((state) => state.scene); @@ -53,7 +66,16 @@ const World = ({ world }: WorldProps) => { [gl] ); useEffect(() => () => surfaces.dispose(), [surfaces]); + const lighting = useMemo( + () => createAreaLighting(surfaces, createBakeQueue(createBakeWorker)), + [surfaces] + ); const { areas } = world; + // Bake every area that joined; let go of the ones that left. + useEffect(() => { + lighting.sync(areas); + }, [lighting, areas]); + useEffect(() => () => lighting.dispose(), [lighting]); const fixtures = useMemo(() => areas.flatMap(areaFixtures), [areas]); // Compile every shader up front: with a fixed light count there is one // variant per material, so this is a one-off. Run again when an area @@ -67,9 +89,17 @@ const World = ({ world }: WorldProps) => { - + {/* A faint flat fill on everything, under the lightmap's bounce and + occluded fill on walls, floor and ceiling; the only indirect light + frames, fixtures and portals get. */} + {areas.map((area) => ( - + ))} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 074c4c5..40fabff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: three: specifier: 'catalog:' version: 0.186.0 + three-mesh-bvh: + specifier: 0.8.3 + version: 0.8.3(three@0.186.0) devDependencies: '@repo/prettier': specifier: workspace:*