diff --git a/AGENTS.md b/AGENTS.md
index 885fbcc..dce361d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,7 @@ Guidelines for AI agents and contributors working in this Turborepo monorepo.
| logger | `@repo/logger` | pino logger emitting Cloud Logging shaped JSON |
| types | `@repo/types` | Shared world types: graph, layout and built geometry, no three.js |
| world-generator | `@repo/world-generator` | Graph → layout → walls, doors, corridors and colliders, seeded and three-free |
-| renderer | `@repo/renderer` | React Three Fiber components, player and lights for a built world |
+| renderer | `@repo/renderer` | React Three Fiber components, player, lights and post-processing for a built world |
| code-graph | `@repo/code-graph` | Language-independent CodeGraph types and the CodeGraph to WorldGraph spatial grammar |
| parser | `@repo/parser` | TypeScript and JavaScript source to CodeGraph via @babel/parser, browser-safe |
diff --git a/apps/web/src/hud.test.tsx b/apps/web/src/hud.test.tsx
new file mode 100644
index 0000000..aeb1047
--- /dev/null
+++ b/apps/web/src/hud.test.tsx
@@ -0,0 +1,37 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import { Hud } from "./hud";
+
+const render = (locked: boolean): string =>
+ renderToStaticMarkup(
+
+ );
+
+describe("Hud", () => {
+ it("lists the quality key and the current level before walking", () => {
+ const html = render(false);
+ expect(html).toContain("toggle quality");
+ expect(html).toContain("seed 7 · 20 rooms · quality low");
+ });
+
+ it("shows the current level while walking", () => {
+ expect(render(true)).toContain("seed 7 · 20 rooms · quality low");
+ });
+});
diff --git a/apps/web/src/hud.tsx b/apps/web/src/hud.tsx
index da562bb..896cfa8 100644
--- a/apps/web/src/hud.tsx
+++ b/apps/web/src/hud.tsx
@@ -1,9 +1,12 @@
+import type { Quality } from "@repo/renderer/effects";
+
import type { ZoneProgress } from "~/use-area-world";
type HudProps = {
readonly locked: boolean;
readonly seed: number;
readonly rooms: number;
+ readonly quality: Quality;
/** Modules of a code world generated so far and in all, each its own area. */
readonly modules: { readonly built: number; readonly total: number };
/** The zone the player is in, with its modules generated so far and in all. */
@@ -32,6 +35,7 @@ const hints: readonly (readonly [string, string])[] = [
["Backspace", "return to the caller"],
["R", "back to the entrance"],
["N", "next seed"],
+ ["Q", "toggle quality"],
["M", "local map"],
["Tab", "world map"],
["Esc", "release the mouse"],
@@ -93,9 +97,10 @@ const Hud = ({
warnings,
notices,
error,
+ quality,
...rest
}: HudProps) => {
- const where = whereOf(rest);
+ const where = `${whereOf(rest)} · quality ${quality}`;
const keys = rest.label === null ? hints : [...hints, ...codeHints];
if (locked) {
return (
diff --git a/apps/web/src/landing-backdrop.tsx b/apps/web/src/landing-backdrop.tsx
index 6c95033..f31251f 100644
--- a/apps/web/src/landing-backdrop.tsx
+++ b/apps/web/src/landing-backdrop.tsx
@@ -1,10 +1,12 @@
import { Canvas, useFrame } from "@react-three/fiber";
+import { Effects } from "@repo/renderer/effects";
import { World } from "@repo/renderer/world";
import type { Placement } from "@repo/types";
import { useMemo } from "react";
import { driftPose } from "~/backdrop";
import { backdropWorld } from "~/backdrop-world";
+import { useQuality } from "~/quality";
import { startPlacement } from "~/world-map-model";
// Loaded lazily by the landing page: the demo world, seen through a camera
@@ -25,6 +27,7 @@ const DriftCamera = ({ start }: { readonly start: Placement }) => {
/** The demo world filling its parent, or nothing when it cannot be built. */
const LandingBackdrop = () => {
const world = useMemo(() => backdropWorld(), []);
+ const quality = useQuality();
const start = useMemo(
() => (world === null ? null : startPlacement(world)),
[world]
@@ -47,6 +50,7 @@ const LandingBackdrop = () => {
>
+
);
};
diff --git a/apps/web/src/landing.tsx b/apps/web/src/landing.tsx
index 7db957c..2f2678c 100644
--- a/apps/web/src/landing.tsx
+++ b/apps/web/src/landing.tsx
@@ -29,7 +29,7 @@ import type { LocalFiles } from "~/zip-files";
const EXAMPLES: readonly string[] = ["demo", "loops", "switches", "repo"];
const HINTS =
- "WASD move · Shift sprint · mouse look · E source · Tab world map · Backspace back · Esc release";
+ "WASD move · Shift sprint · mouse look · E source · Tab world map · Backspace back · Q quality · Esc release";
// The demo world drifting behind the page: its own chunk, fetched after the
// page has painted, so the pickers never wait for three.js.
diff --git a/apps/web/src/quality.test.ts b/apps/web/src/quality.test.ts
new file mode 100644
index 0000000..db4a811
--- /dev/null
+++ b/apps/web/src/quality.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ DEFAULT_QUALITY,
+ parseQuality,
+ readQuality,
+ toggleQuality,
+ writeQuality,
+} from "./quality";
+
+const memoryStorage = () => {
+ const items = new Map();
+ return {
+ items,
+ getItem: (key: string) => items.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ items.set(key, value);
+ },
+ };
+};
+
+const throwingStorage = () => ({
+ getItem: () => {
+ throw new Error("blocked");
+ },
+ setItem: () => {
+ throw new Error("full");
+ },
+});
+
+describe("parseQuality", () => {
+ it("accepts only the two levels", () => {
+ expect(parseQuality("low")).toBe("low");
+ expect(parseQuality("high")).toBe("high");
+ expect(parseQuality("ultra")).toBeNull();
+ expect(parseQuality("")).toBeNull();
+ expect(parseQuality(null)).toBeNull();
+ });
+});
+
+describe("readQuality", () => {
+ it("falls back to the default without a stored value", () => {
+ expect(readQuality(memoryStorage())).toBe(DEFAULT_QUALITY);
+ expect(readQuality(null)).toBe(DEFAULT_QUALITY);
+ });
+
+ it("ignores garbage", () => {
+ const storage = memoryStorage();
+ storage.setItem("quality", "medium");
+ expect(readQuality(storage)).toBe(DEFAULT_QUALITY);
+ });
+
+ it("reads a stored level", () => {
+ const storage = memoryStorage();
+ storage.setItem("quality", "low");
+ expect(readQuality(storage)).toBe("low");
+ });
+
+ it("falls back when storage throws", () => {
+ expect(readQuality(throwingStorage())).toBe(DEFAULT_QUALITY);
+ });
+});
+
+describe("writeQuality", () => {
+ it("round-trips through storage", () => {
+ const storage = memoryStorage();
+ writeQuality(storage, "low");
+ expect(readQuality(storage)).toBe("low");
+ });
+
+ it("swallows a throwing storage", () => {
+ expect(() => writeQuality(throwingStorage(), "low")).not.toThrow();
+ expect(() => writeQuality(null, "low")).not.toThrow();
+ });
+});
+
+describe("toggleQuality", () => {
+ it("flips between the two levels", () => {
+ expect(toggleQuality("high")).toBe("low");
+ expect(toggleQuality("low")).toBe("high");
+ });
+});
diff --git a/apps/web/src/quality.ts b/apps/web/src/quality.ts
new file mode 100644
index 0000000..3888e78
--- /dev/null
+++ b/apps/web/src/quality.ts
@@ -0,0 +1,83 @@
+import type { Quality } from "@repo/renderer/effects";
+import { useSyncExternalStore } from "react";
+
+// The rendering quality: one value for the whole page, kept in localStorage.
+// `low` by default: the post-processing chain costs roughly a frame on top
+// of the frame, so it is opted into with Q rather than paid by everyone.
+
+const KEY = "quality";
+
+const DEFAULT_QUALITY: Quality = "low";
+
+type QualityStorage = Pick;
+
+const parseQuality = (raw: string | null): Quality | null =>
+ raw === "low" || raw === "high" ? raw : null;
+
+/** The stored quality, or the default when there is none or storage throws. */
+const readQuality = (storage: QualityStorage | null): Quality => {
+ try {
+ return parseQuality(storage?.getItem(KEY) ?? null) ?? DEFAULT_QUALITY;
+ } catch {
+ return DEFAULT_QUALITY;
+ }
+};
+
+/** Stores the quality; a full or blocked storage keeps it for this page only. */
+const writeQuality = (storage: QualityStorage | null, quality: Quality) => {
+ try {
+ storage?.setItem(KEY, quality);
+ } catch {
+ // Nothing to do: the in-memory value still drives the page.
+ }
+};
+
+const toggleQuality = (quality: Quality): Quality =>
+ quality === "high" ? "low" : "high";
+
+/** `localStorage`, or null where even reading it throws (a blocked origin). */
+const localStorageOf = (): QualityStorage | null => {
+ try {
+ return globalThis.localStorage;
+ } catch {
+ return null;
+ }
+};
+
+let current: Quality | null = null;
+const listeners = new Set<() => void>();
+
+const getQuality = (): Quality => {
+ current ??= readQuality(localStorageOf());
+ return current;
+};
+
+const setQuality = (quality: Quality) => {
+ current = quality;
+ writeQuality(localStorageOf(), quality);
+ for (const listener of listeners) {
+ listener();
+ }
+};
+
+const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+/** The page's quality, re-rendering on every change. */
+const useQuality = (): Quality =>
+ useSyncExternalStore(subscribe, getQuality, () => DEFAULT_QUALITY);
+
+export {
+ DEFAULT_QUALITY,
+ getQuality,
+ parseQuality,
+ readQuality,
+ setQuality,
+ toggleQuality,
+ useQuality,
+ writeQuality,
+};
diff --git a/apps/web/src/self-files.ts b/apps/web/src/self-files.ts
index ac77e15..612266f 100644
--- a/apps/web/src/self-files.ts
+++ b/apps/web/src/self-files.ts
@@ -21,6 +21,7 @@ const raw: Readonly> = import.meta.glob(
"./packages/*/tsconfig.json",
"./tooling/*/tsconfig.json",
"!**/*.test.*",
+ "!**/*.d.ts",
"!**/node_modules/**",
],
{ base: "../../../", query: "?raw", import: "default", eager: true }
diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx
index a53d970..434a419 100644
--- a/apps/web/src/world-app.tsx
+++ b/apps/web/src/world-app.tsx
@@ -1,5 +1,6 @@
import { Canvas } from "@react-three/fiber";
import { PointerLook } from "@repo/renderer/controls";
+import { Effects } from "@repo/renderer/effects";
import { Player } from "@repo/renderer/player";
import { World } from "@repo/renderer/world";
import type { Target } from "@repo/world-generator/interaction";
@@ -11,6 +12,7 @@ import { EMPTY_VISITS, visit, visitedIn } from "~/map-model";
import { OverviewMap } from "~/overview-map";
import { nextSeed, withSeed } from "~/params";
import { createPoseFeed } from "~/pose-feed";
+import { getQuality, setQuality, toggleQuality, useQuality } from "~/quality";
import { SourcePanel } from "~/source-panel";
import { sourceView } from "~/source-view";
import { useAreaWorld, zoneProgress } from "~/use-area-world";
@@ -43,6 +45,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
const [mapOpen, setMapOpen] = useState(false);
const [worldMapOpen, setWorldMapOpen] = useState(false);
const [visits, setVisits] = useState(EMPTY_VISITS);
+ const quality = useQuality();
// Generated once per seed, then grown an area at a time: it never
// regenerates while the player moves.
const generated = useAreaWorld(seed, worldOptions);
@@ -78,10 +81,11 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
}
}, [target, ensurePortal, generated.world]);
- // N: next seed (the only thing that regenerates the world). Backspace and
- // R navigate, E shows the source and M the local map, only while walking,
- // so a free pointer leaves them alone. Tab shows the world map while
- // walking and, like Esc once the pointer is free, closes it (`map-keys`).
+ // N: next seed (the only thing that regenerates the world) and Q: the
+ // rendering quality, in any pointer state. Backspace and R navigate, E
+ // shows the source and M the local map, only while walking, so a free
+ // pointer leaves them alone. Tab shows the world map while walking and,
+ // like Esc once the pointer is free, closes it (`map-keys`).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
// Ctrl/Cmd+R and friends belong to the browser.
@@ -104,6 +108,8 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
}
if (event.code === "KeyN") {
setSeed(nextSeed);
+ } else if (event.code === "KeyQ") {
+ setQuality(toggleQuality(getQuality()));
} else if (locked && event.code === "Backspace") {
event.preventDefault();
back();
@@ -184,6 +190,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
onMove={feed.publish}
/>
+
)}
{locked && mapOpen && generated.world !== null && (
@@ -206,6 +213,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
lastMs={generated.lastMs}
preset={initial.preset}
label={label}
+ quality={quality}
place={place}
breadcrumb={breadcrumbOf(
codeGraph,
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index ce15f92..3ad089e 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -5,8 +5,10 @@
"compilerOptions": {
// `vite/client` declares the `*.css` (and other asset) modules that
// `import "./globals.css"` relies on. Without it TypeScript 7 rejects the
- // side-effect import with TS2882.
- "types": ["vite/client"],
+ // side-effect import with TS2882. `@repo/renderer/n8ao` declares the
+ // untyped `n8ao` module the renderer's effects import: an ambient
+ // declaration has to be in this program too, and `types` is how.
+ "types": ["vite/client", "@repo/renderer/n8ao"],
// `~/` is the only way up the tree: oxlint bans `../` imports. Vite and
// Vitest read this via `resolve.tsconfigPaths`.
"paths": { "~/*": ["./src/*"] }
diff --git a/packages/renderer/README.md b/packages/renderer/README.md
index 5e2b379..f898d27 100644
--- a/packages/renderer/README.md
+++ b/packages/renderer/README.md
@@ -4,6 +4,7 @@ React Three Fiber components, player and lights for a built world.
```tsx
import { PointerLook } from "@repo/renderer/controls";
+import { Effects } from "@repo/renderer/effects";
import { Player } from "@repo/renderer/player";
import { World } from "@repo/renderer/world";
@@ -11,6 +12,7 @@ import { World } from "@repo/renderer/world";
+
;
```
@@ -49,3 +51,13 @@ shaders compile once (and again only for a material an area brings first),
and a single static shadow map that re-renders only
when the shadow light hops to another fixture or an area joins. Every light buzzes with a
phase fixed by its fixture's position, so neighbours never flicker in step.
+
+The wallpaper, carpet and ceiling tile are generated on the fly
+(`textures.ts`): each is a seeded, periodic height field (`height-field.ts`)
+that gives a colour map, a normal map and one occlusion-roughness map, so
+nothing is fetched and the maps tile without a seam. `Effects` sets the
+frame's look at either quality, AgX tone mapping at one exposure; at `high`
+it adds ambient occlusion (n8ao), bloom off the fixtures, grain, a vignette
+and a touch of lens fringing through `@react-three/postprocessing`, tone
+mapped last. The haze past the fog is a sphere around the camera rather than
+the clear colour, so it is tone mapped like the walls at both qualities.
diff --git a/packages/renderer/package.json b/packages/renderer/package.json
index 6219052..6bd92ff 100644
--- a/packages/renderer/package.json
+++ b/packages/renderer/package.json
@@ -4,19 +4,25 @@
"type": "module",
"exports": {
"./controls": "./src/controls.tsx",
+ "./effects": "./src/effects.tsx",
"./lane-tint": "./src/lane-tint.ts",
+ "./n8ao": "./src/n8ao.d.ts",
"./player": "./src/player.tsx",
"./world": "./src/world.tsx"
},
"scripts": {
"typecheck": "tsc",
+ "test": "vitest run",
"clean": "rm -rf .turbo node_modules .cache"
},
"dependencies": {
"@react-three/drei": "10.7.8",
"@react-three/fiber": "catalog:",
+ "@react-three/postprocessing": "3.1.3",
"@repo/types": "workspace:*",
"@repo/world-generator": "workspace:*",
+ "n8ao": "2.0.1",
+ "postprocessing": "6.39.5",
"react": "catalog:",
"three": "catalog:"
},
@@ -25,7 +31,8 @@
"@repo/typescript": "workspace:*",
"@types/react": "catalog:",
"@types/three": "catalog:",
- "typescript": "catalog:"
+ "typescript": "catalog:",
+ "vitest": "catalog:"
},
"prettier": "@repo/prettier"
}
diff --git a/packages/renderer/src/effects.tsx b/packages/renderer/src/effects.tsx
new file mode 100644
index 0000000..1bf51ed
--- /dev/null
+++ b/packages/renderer/src/effects.tsx
@@ -0,0 +1,91 @@
+import { useThree } from "@react-three/fiber";
+import {
+ Bloom,
+ ChromaticAberration,
+ EffectComposer,
+ Noise,
+ ToneMapping,
+ Vignette,
+} from "@react-three/postprocessing";
+import { N8AOPostPass } from "n8ao";
+import { ToneMappingMode } from "postprocessing";
+import { useEffect, useLayoutEffect, useMemo } from "react";
+import { AgXToneMapping, NoToneMapping, Vector2 } from "three";
+
+/** How much the scene is drawn for: nothing at `low`, the full chain at `high`. */
+type Quality = "low" | "high";
+
+/**
+ * Shared by both paths: the renderer tone-maps at `low`, and at `high` the
+ * composer's AgX pass reads the renderer's exposure too.
+ */
+const EXPOSURE = 1.15;
+
+/**
+ * The frame's look, the same at every quality: AgX tone mapping, with the
+ * exposure the lights are tuned against.
+ *
+ * At `high` the frame goes through a composer: ambient occlusion, bloom off
+ * the fixtures, grain, a vignette and a touch of lens fringing, tone-mapped
+ * last. The renderer maps only at `low`; at `high` it is set to no mapping
+ * here, which is what the composer holds it to while mounted (and hands
+ * back on unmount, by which time the next quality's setting is in place).
+ *
+ * A layout effect, so the mapping is set before any passive effect of the
+ * same commit: the World's shader precompile runs there, and tone mapping
+ * picks the shader variant it would otherwise warm wrongly.
+ */
+const Effects = ({ quality }: { readonly quality: Quality }) => {
+ const get = useThree((state) => state.get);
+ const high = quality === "high";
+ useLayoutEffect(() => {
+ const { gl } = get();
+ gl.toneMappingExposure = EXPOSURE;
+ gl.toneMapping = high ? NoToneMapping : AgXToneMapping;
+ }, [get, high]);
+ // One pass per Canvas, kept across quality changes: `Pass.dispose` does
+ // not reach the pass's own fullscreen objects, so rebuilding it on every
+ // toggle would leak them. The composer sizes it on every resize; the
+ // constructor only needs a first size.
+ const occlusion = useMemo(() => {
+ const { gl, scene, camera } = get();
+ const { width, height } = gl.getDrawingBufferSize(new Vector2());
+ const pass = new N8AOPostPass(scene, camera, width, height);
+ const { configuration } = pass;
+ configuration.aoRadius = 1.2;
+ configuration.distanceFalloff = 0.6;
+ configuration.intensity = 2.5;
+ configuration.halfRes = true;
+ configuration.depthAwareUpsampling = true;
+ configuration.aoSamples = 8;
+ configuration.denoiseSamples = 4;
+ configuration.denoiseRadius = 8;
+ // Tone mapping follows, so the pass must hand on linear light.
+ configuration.gammaCorrection = false;
+ return pass;
+ }, [get]);
+ useEffect(() => () => occlusion.dispose(), [occlusion]);
+ if (!high) {
+ return null;
+ }
+ return (
+ // The composer draws off screen, where the Canvas's own antialiasing
+ // does not reach: it multisamples instead.
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export { Effects };
+export type { Quality };
diff --git a/packages/renderer/src/height-field.test.ts b/packages/renderer/src/height-field.test.ts
new file mode 100644
index 0000000..5fdcf5d
--- /dev/null
+++ b/packages/renderer/src/height-field.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ carpetHeight,
+ ceilingHeight,
+ downsample,
+ fbm,
+ heightToNormal,
+ heightToOrm,
+ wallpaperHeight,
+} from "./height-field";
+
+const SIZE = 64;
+
+/** The largest step between neighbours inside the field, per axis. */
+const interiorSteps = (
+ height: Float32Array,
+ size: number
+): { x: number; y: number } => {
+ let x = 0;
+ let y = 0;
+ for (let row = 0; row < size; row += 1) {
+ for (let col = 0; col < size; col += 1) {
+ const h = height[row * size + col] ?? 0;
+ if (col < size - 1) {
+ x = Math.max(x, Math.abs((height[row * size + col + 1] ?? 0) - h));
+ }
+ if (row < size - 1) {
+ y = Math.max(y, Math.abs((height[(row + 1) * size + col] ?? 0) - h));
+ }
+ }
+ }
+ return { x, y };
+};
+
+/** The largest step across the wrap edge, last column → first, last row → first. */
+const wrapSteps = (
+ height: Float32Array,
+ size: number
+): { x: number; y: number } => {
+ let x = 0;
+ let y = 0;
+ for (let i = 0; i < size; i += 1) {
+ x = Math.max(
+ x,
+ Math.abs((height[i * size] ?? 0) - (height[i * size + size - 1] ?? 0))
+ );
+ y = Math.max(
+ y,
+ Math.abs((height[i] ?? 0) - (height[(size - 1) * size + i] ?? 0))
+ );
+ }
+ return { x, y };
+};
+
+describe("heightToNormal", () => {
+ it("maps a flat field to straight-up normals", () => {
+ const normal = heightToNormal(new Float32Array(16).fill(0.5), 4, 8);
+ for (let i = 0; i < 16; i += 1) {
+ expect([...normal.slice(i * 4, i * 4 + 4)]).toEqual([128, 128, 255, 255]);
+ }
+ });
+
+ it("tilts only R for a slope along x, away from the rise", () => {
+ const size = 8;
+ const height = new Float32Array(size * size);
+ for (let y = 0; y < size; y += 1) {
+ for (let x = 1; x < size - 1; x += 1) {
+ height[y * size + x] = x / size;
+ }
+ }
+ const normal = heightToNormal(height, size, 4);
+ const i = (3 * size + 3) * 4;
+ expect(normal[i]).toBeLessThan(128);
+ expect(normal[i + 1]).toBe(128);
+ expect(normal[i + 2]).toBeGreaterThan(200);
+ });
+
+ it("wraps at the edges, so a step at column 0 shows at the last column", () => {
+ const size = 8;
+ const height = new Float32Array(size * size);
+ for (let y = 0; y < size; y += 1) {
+ height[y * size] = 1;
+ }
+ const normal = heightToNormal(height, size, 1);
+ const last = (2 * size + size - 1) * 4;
+ const middle = (2 * size + 4) * 4;
+ expect(normal[last]).not.toBe(128);
+ expect(normal[middle]).toBe(128);
+ });
+});
+
+describe("heightToOrm", () => {
+ it("keeps roughness inside the spread and has no metalness", () => {
+ const height = fbm(1, SIZE, { octaves: 3, cells: 8 });
+ const orm = heightToOrm(height, SIZE, {
+ roughness: 0.8,
+ spread: 0.1,
+ cavity: 2,
+ });
+ for (let i = 0; i < SIZE * SIZE; i += 1) {
+ expect(orm[i * 4 + 1]).toBeGreaterThanOrEqual(Math.floor(0.7 * 255));
+ expect(orm[i * 4 + 1]).toBeLessThanOrEqual(Math.ceil(0.9 * 255));
+ expect(orm[i * 4 + 2]).toBe(255);
+ }
+ });
+
+ it("occludes a pit and leaves a plain around it lit", () => {
+ const height = new Float32Array(SIZE * SIZE).fill(0.8);
+ height[32 * SIZE + 32] = 0;
+ const orm = heightToOrm(height, SIZE, {
+ roughness: 0.5,
+ spread: 0,
+ cavity: 2,
+ });
+ expect(orm[(32 * SIZE + 32) * 4]).toBeLessThan(
+ orm[(2 * SIZE + 2) * 4] ?? 0
+ );
+ expect(orm[(2 * SIZE + 2) * 4]).toBe(255);
+ });
+});
+
+describe("downsample", () => {
+ it("averages each 2 × 2 block", () => {
+ const rgba = new Uint8ClampedArray(2 * 2 * 4);
+ [0, 100, 200, 100].forEach((value, texel) => {
+ rgba[texel * 4] = value;
+ rgba[texel * 4 + 3] = 255;
+ });
+ const half = downsample(rgba, 2);
+ expect([...half]).toEqual([100, 0, 0, 255]);
+ });
+});
+
+describe("surface height fields", () => {
+ const surfaces = {
+ wallpaper: wallpaperHeight,
+ carpet: carpetHeight,
+ ceiling: ceilingHeight,
+ };
+
+ for (const [name, generate] of Object.entries(surfaces)) {
+ it(`${name} is deterministic`, () => {
+ expect(generate(SIZE)).toEqual(generate(SIZE));
+ });
+
+ it(`${name} stays in [0, 1]`, () => {
+ const height = generate(SIZE);
+ for (const value of height) {
+ expect(value).toBeGreaterThanOrEqual(0);
+ expect(value).toBeLessThanOrEqual(1);
+ }
+ });
+
+ it(`${name} is seamless across the wrap edge`, () => {
+ const height = generate(SIZE);
+ const inside = interiorSteps(height, SIZE);
+ const across = wrapSteps(height, SIZE);
+ expect(across.x).toBeLessThanOrEqual(inside.x);
+ expect(across.y).toBeLessThanOrEqual(inside.y);
+ });
+ }
+
+ it("fbm tiles: the step across the edge is no larger than inside", () => {
+ const height = fbm(5, SIZE, { octaves: 4, cells: 4 });
+ const inside = interiorSteps(height, SIZE);
+ const across = wrapSteps(height, SIZE);
+ expect(across.x).toBeLessThanOrEqual(inside.x);
+ expect(across.y).toBeLessThanOrEqual(inside.y);
+ });
+});
diff --git a/packages/renderer/src/height-field.ts b/packages/renderer/src/height-field.ts
new file mode 100644
index 0000000..0af5fd9
--- /dev/null
+++ b/packages/renderer/src/height-field.ts
@@ -0,0 +1,349 @@
+import { createRng } from "@repo/world-generator/random";
+
+/**
+ * Seeded, periodic height fields and the PBR data maps derived from them.
+ * Everything here is pure (no DOM, no three.js), so the maps are testable
+ * and deterministic: the same seed draws the same surface on every machine.
+ *
+ * A height field is `size × size` values in [0, 1], row-major, row 0 at the
+ * top of the texture. Every generator wraps modulo `size`, so the maps tile.
+ * The loops run once per Canvas over a few hundred thousand texels each,
+ * so they index typed arrays directly rather than call per texel.
+ */
+
+/** RGBA texels over a plain buffer, as `ImageData` takes them. */
+type Rgba = Uint8ClampedArray;
+
+const wrap = (index: number, size: number): number =>
+ ((index % size) + size) % size;
+
+const smoothstep = (t: number): number => t * t * (3 - 2 * t);
+
+const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
+
+/**
+ * Value noise on a lattice of `cells × cells` random values that wraps, so
+ * the result is periodic in `size`. `cells` must divide `size`.
+ */
+const latticeNoise = (
+ seed: number,
+ size: number,
+ cells: number
+): Float32Array => {
+ const rng = createRng(seed);
+ const lattice = new Float32Array(cells * cells);
+ for (let i = 0; i < lattice.length; i += 1) {
+ lattice[i] = rng();
+ }
+ // Per axis: the cell before and after each texel and the blend between.
+ const span = size / cells;
+ const before = new Int32Array(size);
+ const after = new Int32Array(size);
+ const blend = new Float32Array(size);
+ for (let i = 0; i < size; i += 1) {
+ const cell = Math.floor(i / span);
+ before[i] = cell % cells;
+ after[i] = (cell + 1) % cells;
+ blend[i] = smoothstep(i / span - cell);
+ }
+ const out = new Float32Array(size * size);
+ for (let y = 0; y < size; y += 1) {
+ const row0 = (before[y] ?? 0) * cells;
+ const row1 = (after[y] ?? 0) * cells;
+ const ty = blend[y] ?? 0;
+ const base = y * size;
+ for (let x = 0; x < size; x += 1) {
+ const x0 = before[x] ?? 0;
+ const x1 = after[x] ?? 0;
+ const tx = blend[x] ?? 0;
+ const a = lattice[row0 + x0] ?? 0;
+ const b = lattice[row0 + x1] ?? 0;
+ const c = lattice[row1 + x0] ?? 0;
+ const d = lattice[row1 + x1] ?? 0;
+ const top = a + (b - a) * tx;
+ const bottom = c + (d - c) * tx;
+ out[base + x] = top + (bottom - top) * ty;
+ }
+ }
+ return out;
+};
+
+type FbmOptions = {
+ readonly octaves: number;
+ /** Lattice cells per axis of the first octave; doubles each octave. */
+ readonly cells: number;
+ readonly gain?: number;
+};
+
+/**
+ * Fractal sum of periodic value noise, normalised to [0, 1]. No octave is
+ * finer than two texels per cell: that would only add per-texel noise,
+ * which the surfaces add themselves where they want it. So a small field
+ * (a test's) gets coarser noise than a large one, never none.
+ */
+const fbm = (
+ seed: number,
+ size: number,
+ { octaves, cells, gain = 0.5 }: FbmOptions
+): Float32Array => {
+ const out = new Float32Array(size * size);
+ const first = Math.min(cells, size / 2);
+ let amplitude = 1;
+ let total = 0;
+ for (let octave = 0; octave < octaves; octave += 1) {
+ const octaveCells = first * 2 ** octave;
+ if (octaveCells * 2 > size) {
+ break;
+ }
+ const noise = latticeNoise(seed + octave * 7919, size, octaveCells);
+ for (let i = 0; i < out.length; i += 1) {
+ out[i] = (out[i] ?? 0) + (noise[i] ?? 0) * amplitude;
+ }
+ total += amplitude;
+ amplitude *= gain;
+ }
+ for (let i = 0; i < out.length; i += 1) {
+ out[i] = (out[i] ?? 0) / total;
+ }
+ return out;
+};
+
+/** One axis of a wrapping box blur with a running sum: O(n), any radius. */
+const blurAxis = (
+ input: Float32Array,
+ size: number,
+ radius: number,
+ stepX: number,
+ stepY: number
+): Float32Array => {
+ const out = new Float32Array(size * size);
+ const window = 2 * radius + 1;
+ for (let line = 0; line < size; line += 1) {
+ const start = line * stepY;
+ let sum = 0;
+ for (let d = -radius; d <= radius; d += 1) {
+ sum += input[start + wrap(d, size) * stepX] ?? 0;
+ }
+ for (let i = 0; i < size; i += 1) {
+ out[start + i * stepX] = sum / window;
+ sum -= input[start + wrap(i - radius, size) * stepX] ?? 0;
+ sum += input[start + wrap(i + radius + 1, size) * stepX] ?? 0;
+ }
+ }
+ return out;
+};
+
+/** Box blur of radius `radius`, wrapping, two separable passes. */
+const blur = (
+ height: Float32Array,
+ size: number,
+ radius: number
+): Float32Array =>
+ blurAxis(blurAxis(height, size, radius, 1, size), size, radius, size, 1);
+
+/**
+ * Tangent-space normal map from a height field: central differences that
+ * wrap at the edges. Flat is (128, 128, 255). The texture is uploaded with
+ * row 0 at v = 1, so a slope down the rows is a slope down v, which is why
+ * G follows +dh/drow.
+ */
+const heightToNormal = (
+ height: Float32Array,
+ size: number,
+ strength: number
+): Rgba => {
+ const out = new Uint8ClampedArray(size * size * 4);
+ for (let y = 0; y < size; y += 1) {
+ const row = y * size;
+ const rowUp = wrap(y - 1, size) * size;
+ const rowDown = wrap(y + 1, size) * size;
+ for (let x = 0; x < size; x += 1) {
+ const left = height[row + wrap(x - 1, size)] ?? 0;
+ const right = height[row + wrap(x + 1, size)] ?? 0;
+ const up = height[rowUp + x] ?? 0;
+ const down = height[rowDown + x] ?? 0;
+ const dx = ((right - left) / 2) * strength;
+ const dy = ((down - up) / 2) * strength;
+ const length = Math.sqrt(dx * dx + dy * dy + 1);
+ const i = (row + x) * 4;
+ out[i] = 128 + (-dx / length) * 127;
+ out[i + 1] = 128 + (dy / length) * 127;
+ out[i + 2] = 128 + (1 / length) * 127;
+ out[i + 3] = 255;
+ }
+ }
+ return out;
+};
+
+type OrmOptions = {
+ /** Mid roughness, in [0, 1]. */
+ readonly roughness: number;
+ /** How far the roughness swings around the mid: valleys rougher, peaks smoother. */
+ readonly spread: number;
+ /** How dark a cavity gets; 0 for no occlusion. */
+ readonly cavity: number;
+};
+
+/**
+ * One RGBA texture in three's glTF layout: R occlusion, G roughness, B 255
+ * (no metalness map). Occlusion is a cheap cavity term, how far a texel
+ * lies below its blurred surroundings.
+ */
+const heightToOrm = (
+ height: Float32Array,
+ size: number,
+ { roughness, spread, cavity }: OrmOptions
+): Rgba => {
+ const mean = blur(height, size, Math.max(1, Math.round(size / 128)));
+ const out = new Uint8ClampedArray(size * size * 4);
+ for (let i = 0; i < height.length; i += 1) {
+ const h = height[i] ?? 0;
+ const below = Math.max(0, (mean[i] ?? 0) - h);
+ const occlusion = clamp01(1 - below * cavity);
+ const rough = clamp01(roughness + spread * (1 - 2 * h));
+ out[i * 4] = occlusion * 255;
+ out[i * 4 + 1] = rough * 255;
+ out[i * 4 + 2] = 255;
+ out[i * 4 + 3] = 255;
+ }
+ return out;
+};
+
+/** Averages 2 × 2 values, halving a height field's size. */
+const halveHeight = (height: Float32Array, size: number): Float32Array => {
+ const half = size / 2;
+ const out = new Float32Array(half * half);
+ for (let y = 0; y < half; y += 1) {
+ for (let x = 0; x < half; x += 1) {
+ const row = 2 * y * size + 2 * x;
+ out[y * half + x] =
+ ((height[row] ?? 0) +
+ (height[row + 1] ?? 0) +
+ (height[row + size] ?? 0) +
+ (height[row + size + 1] ?? 0)) /
+ 4;
+ }
+ }
+ return out;
+};
+
+/** Averages 2 × 2 texels, halving an RGBA map's size. */
+const downsample = (rgba: Rgba, size: number): Rgba => {
+ const half = size / 2;
+ const out = new Uint8ClampedArray(half * half * 4);
+ for (let y = 0; y < half; y += 1) {
+ for (let x = 0; x < half; x += 1) {
+ for (let c = 0; c < 4; c += 1) {
+ const a = rgba[(2 * y * size + 2 * x) * 4 + c] ?? 0;
+ const b = rgba[(2 * y * size + 2 * x + 1) * 4 + c] ?? 0;
+ const d = rgba[((2 * y + 1) * size + 2 * x) * 4 + c] ?? 0;
+ const e = rgba[((2 * y + 1) * size + 2 * x + 1) * 4 + c] ?? 0;
+ out[(y * half + x) * 4 + c] = (a + b + d + e) / 4;
+ }
+ }
+ }
+ return out;
+};
+
+/** Mustard wallpaper: embossed stripes, the diamond motif and paper grain. */
+const wallpaperHeight = (size: number): Float32Array => {
+ const grain = fbm(11, size, { octaves: 2, cells: 128 });
+ const stripes = 16;
+ const diamonds = 4;
+ const cell = size / diamonds;
+ // Per axis: the soft-edged stripe, as flocked paper, and each texel's
+ // distance from its diamond cell's centre, scaled to the motif's shape.
+ const stripe = new Float32Array(size);
+ const across = new Float32Array(size);
+ const along = new Float32Array(size);
+ for (let i = 0; i < size; i += 1) {
+ const phase = ((i * stripes) / size) % 1;
+ stripe[i] = phase < 0.45 ? smoothstep(Math.min(1, phase / 0.08)) : 0;
+ const offset = Math.abs((i % cell) / cell - 0.5);
+ across[i] = offset / 0.45;
+ along[i] = offset / 0.7;
+ }
+ const out = new Float32Array(size * size);
+ for (let y = 0; y < size; y += 1) {
+ const dy = along[y] ?? 0;
+ const row = y * size;
+ for (let x = 0; x < size; x += 1) {
+ // The diamond outline: an L1 ring around each cell's centre.
+ const ring = Math.abs((across[x] ?? 0) + dy - 0.5);
+ const diamond = ring < 0.04 ? 1 - ring / 0.04 : 0;
+ const i = row + x;
+ out[i] = clamp01(
+ 0.45 +
+ 0.12 * (stripe[x] ?? 0) +
+ 0.15 * diamond +
+ 0.1 * ((grain[i] ?? 0) - 0.5)
+ );
+ }
+ }
+ return out;
+};
+
+/** Low-pile carpet: dense fibre noise over a looser weave. */
+const carpetHeight = (size: number): Float32Array => {
+ const fibres = fbm(23, size, { octaves: 4, cells: 32, gain: 0.6 });
+ const weave = fbm(29, size, { octaves: 2, cells: 8 });
+ const rng = createRng(31);
+ const out = new Float32Array(size * size);
+ for (let i = 0; i < out.length; i += 1) {
+ out[i] = clamp01(
+ 0.35 + 0.5 * (fibres[i] ?? 0) + 0.1 * (weave[i] ?? 0) + 0.12 * rng()
+ );
+ }
+ return out;
+};
+
+/** Drop-ceiling tile: fissures, pin holes and the seam around the edge. */
+const ceilingHeight = (size: number): Float32Array => {
+ const texture = fbm(41, size, { octaves: 3, cells: 16 });
+ const fissures = fbm(43, size, { octaves: 3, cells: 24, gain: 0.55 });
+ const out = new Float32Array(size * size);
+ const seam = Math.max(2, Math.round(size / 170));
+ for (let y = 0; y < size; y += 1) {
+ const edgeRow = y < seam || y >= size - seam;
+ const row = y * size;
+ for (let x = 0; x < size; x += 1) {
+ const i = row + x;
+ if (edgeRow || x < seam || x >= size - seam) {
+ out[i] = 0.2;
+ continue;
+ }
+ // Sparse, shallow fissures: the tops of the finer noise only.
+ const fissure = (fissures[i] ?? 0) > 0.7 ? 0.12 : 0;
+ out[i] = clamp01(0.75 + 0.05 * ((texture[i] ?? 0) - 0.5) - fissure);
+ }
+ }
+ // Pin holes, placed modulo the size so they tile.
+ const rng = createRng(47);
+ const holes = Math.round((size * size) / 1500);
+ for (let n = 0; n < holes; n += 1) {
+ const hx = Math.floor(rng() * size);
+ const hy = Math.floor(rng() * size);
+ const r = 1 + Math.floor(rng() * 2);
+ for (let dy = -r; dy <= r; dy += 1) {
+ for (let dx = -r; dx <= r; dx += 1) {
+ if (dx * dx + dy * dy <= r * r) {
+ const i = wrap(hy + dy, size) * size + wrap(hx + dx, size);
+ out[i] = Math.min(out[i] ?? 0, 0.35);
+ }
+ }
+ }
+ }
+ return out;
+};
+
+export {
+ carpetHeight,
+ ceilingHeight,
+ downsample,
+ fbm,
+ halveHeight,
+ heightToNormal,
+ heightToOrm,
+ wallpaperHeight,
+};
+export type { Rgba };
diff --git a/packages/renderer/src/n8ao.d.ts b/packages/renderer/src/n8ao.d.ts
new file mode 100644
index 0000000..c8cbed8
--- /dev/null
+++ b/packages/renderer/src/n8ao.d.ts
@@ -0,0 +1,32 @@
+// n8ao ships no types: the part of its post-processing pass this package uses.
+declare module "n8ao" {
+ import type { Pass } from "postprocessing";
+ import type { Camera, Color, Scene } from "three";
+
+ type N8AOConfiguration = {
+ /** Occlusion radius in world units. */
+ aoRadius: number;
+ /** World units over which the effect fades out with distance. */
+ distanceFalloff: number;
+ intensity: number;
+ aoSamples: number;
+ denoiseSamples: number;
+ denoiseRadius: number;
+ /** Compute the occlusion at half the frame's resolution. */
+ halfRes: boolean;
+ depthAwareUpsampling: boolean;
+ screenSpaceRadius: boolean;
+ color: Color;
+ /** Apply gamma correction: only when this pass draws to the screen. */
+ gammaCorrection: boolean;
+ renderMode: 0 | 1 | 2 | 3 | 4;
+ transparencyAware: boolean;
+ };
+
+ class N8AOPostPass extends Pass {
+ constructor(scene: Scene, camera: Camera, width?: number, height?: number);
+ readonly configuration: N8AOConfiguration;
+ }
+
+ export { N8AOPostPass };
+}
diff --git a/packages/renderer/src/surfaces.ts b/packages/renderer/src/surfaces.ts
index 07b3ec2..5f84412 100644
--- a/packages/renderer/src/surfaces.ts
+++ b/packages/renderer/src/surfaces.ts
@@ -1,7 +1,8 @@
import { createContext, useContext } from "react";
-import { MeshStandardMaterial } from "three";
+import { MeshStandardMaterial, Vector2 } from "three";
import { createSurfaceTextures, TILE } from "./textures";
+import type { SurfaceMaps } from "./textures";
/**
* One material per surface type, shared by every mesh. Meshes that use them
@@ -42,23 +43,35 @@ const voidMaterial = (emissive: string): MeshStandardMaterial =>
polygonOffsetFactor: -1,
});
+/**
+ * A surface's maps on a material. The ORM texture is read for roughness
+ * (G) and occlusion (R) through the tiled `uv`, the only channel the room
+ * geometry has; `roughness` stays 1 so the map alone sets it.
+ */
+const mapped = (
+ { map, normalMap, orm }: SurfaceMaps,
+ relief: number,
+ occlusion: number
+) => ({
+ map,
+ normalMap,
+ normalScale: new Vector2(relief, relief),
+ roughnessMap: orm,
+ roughness: 1,
+ aoMap: orm,
+ aoMapIntensity: occlusion,
+});
+
const createSurfaces = (anisotropy: number): Surfaces => {
const textures = createSurfaceTextures(anisotropy);
// Walls carry vertex colours: white as built, a lane's wash inside a fork
// of a function.
const wall = new MeshStandardMaterial({
- map: textures.wallpaper,
- roughness: 0.85,
+ ...mapped(textures.wallpaper, 0.7, 0.7),
vertexColors: true,
});
- const floor = new MeshStandardMaterial({
- map: textures.carpet,
- roughness: 1,
- });
- const ceiling = new MeshStandardMaterial({
- map: textures.ceiling,
- roughness: 0.95,
- });
+ 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",
@@ -117,8 +130,10 @@ const createSurfaces = (anisotropy: number): Surfaces => {
for (const material of materials) {
material.dispose();
}
- for (const texture of Object.values(textures)) {
- texture.dispose();
+ for (const maps of Object.values(textures)) {
+ for (const texture of Object.values(maps)) {
+ texture.dispose();
+ }
}
},
};
diff --git a/packages/renderer/src/textures.ts b/packages/renderer/src/textures.ts
index ba332b9..c5825be 100644
--- a/packages/renderer/src/textures.ts
+++ b/packages/renderer/src/textures.ts
@@ -1,16 +1,39 @@
import { createRng } from "@repo/world-generator/random";
import type { Rng } from "@repo/world-generator/random";
-import { CanvasTexture, RepeatWrapping, SRGBColorSpace } from "three";
+import {
+ CanvasTexture,
+ NoColorSpace,
+ RepeatWrapping,
+ SRGBColorSpace,
+} from "three";
+
+import {
+ carpetHeight,
+ ceilingHeight,
+ halveHeight,
+ heightToNormal,
+ heightToOrm,
+ wallpaperHeight,
+} from "./height-field";
+import type { Rgba } from "./height-field";
/** Metres covered by one repeat of each texture. */
const TILE = { wallpaper: 1, carpet: 1, ceiling: 0.6 } as const;
+/**
+ * Texels across the colour and normal maps, 2 mm each on a 1 m tile; the
+ * ORM map is half that. Generation is synchronous at Canvas mount, so the
+ * size is bounded by its cost as much as by sharpness.
+ */
const SIZE = 512;
-const createCanvas = () => {
+/** The painters below were drawn for a 512 canvas; their measures scale. */
+const S = SIZE / 512;
+
+const createCanvas = (size: number) => {
const canvas = document.createElement("canvas");
- canvas.width = SIZE;
- canvas.height = SIZE;
+ canvas.width = size;
+ canvas.height = size;
const ctx = canvas.getContext("2d");
if (ctx === null) {
throw new Error("2D canvas context is unavailable");
@@ -26,23 +49,24 @@ const speckle = (
alpha: number,
dotSize = 1
) => {
- for (let i = 0; i < count; i += 1) {
+ for (let i = 0; i < count * S * S; i += 1) {
const light = rng() > 0.5;
ctx.fillStyle = light
? `rgba(255,255,255,${alpha})`
: `rgba(0,0,0,${alpha})`;
- ctx.fillRect(rng() * SIZE, rng() * SIZE, dotSize, dotSize);
+ ctx.fillRect(rng() * SIZE, rng() * SIZE, dotSize * S, dotSize * S);
}
};
const toTexture = (
canvas: HTMLCanvasElement,
- anisotropy: number
+ anisotropy: number,
+ colorSpace: typeof SRGBColorSpace | typeof NoColorSpace
): CanvasTexture => {
const texture = new CanvasTexture(canvas);
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
- texture.colorSpace = SRGBColorSpace;
+ texture.colorSpace = colorSpace;
texture.anisotropy = anisotropy;
return texture;
};
@@ -53,13 +77,13 @@ const drawWallpaper = (ctx: CanvasRenderingContext2D) => {
ctx.fillRect(0, 0, SIZE, SIZE);
ctx.fillStyle = "rgba(105,85,25,0.08)";
- for (let x = 0; x < SIZE; x += 32) {
- ctx.fillRect(x, 0, 14, SIZE);
+ for (let x = 0; x < SIZE; x += 32 * S) {
+ ctx.fillRect(x, 0, 14 * S, SIZE);
}
ctx.strokeStyle = "rgba(120,95,30,0.22)";
- ctx.lineWidth = 2;
- const cell = 128;
+ ctx.lineWidth = 2 * S;
+ const cell = 128 * S;
const half = cell / 2;
for (let row = 0; row < SIZE / cell; row += 1) {
for (let col = 0; col < SIZE / cell; col += 1) {
@@ -92,28 +116,121 @@ const drawCeiling = (ctx: CanvasRenderingContext2D) => {
ctx.fillRect(0, 0, SIZE, SIZE);
speckle(ctx, createRng(4), 5000, 0.05, 2);
ctx.strokeStyle = "#a59d86";
- ctx.lineWidth = 6;
+ ctx.lineWidth = 6 * S;
ctx.strokeRect(0, 0, SIZE, SIZE);
};
-type SurfaceTextures = {
- readonly wallpaper: CanvasTexture;
- readonly carpet: CanvasTexture;
- readonly ceiling: CanvasTexture;
+/**
+ * Lightens the colour on the height field's peaks and darkens its valleys,
+ * so the relief reads even where the light falls flat on the surface.
+ */
+const shadeByHeight = (
+ ctx: CanvasRenderingContext2D,
+ height: Float32Array,
+ amount: number
+) => {
+ const image = ctx.getImageData(0, 0, SIZE, SIZE);
+ const { data } = image;
+ for (let i = 0; i < height.length; i += 1) {
+ const factor = 1 + amount * ((height[i] ?? 0.5) - 0.5);
+ data[i * 4] = (data[i * 4] ?? 0) * factor;
+ data[i * 4 + 1] = (data[i * 4 + 1] ?? 0) * factor;
+ data[i * 4 + 2] = (data[i * 4 + 2] ?? 0) * factor;
+ }
+ ctx.putImageData(image, 0, 0);
+};
+
+const dataCanvas = (rgba: Rgba, size: number): HTMLCanvasElement => {
+ const { canvas, ctx } = createCanvas(size);
+ ctx.putImageData(new ImageData(rgba, size, size), 0, 0);
+ return canvas;
+};
+
+type SurfaceMaps = {
+ readonly map: CanvasTexture;
+ readonly normalMap: CanvasTexture;
+ /** R occlusion, G roughness: one texture serves `aoMap` and `roughnessMap`. */
+ readonly orm: CanvasTexture;
+};
+
+type SurfaceLook = {
+ readonly draw: (ctx: CanvasRenderingContext2D) => void;
+ readonly height: (size: number) => Float32Array;
+ /** How steep the normal map reads the height field. */
+ readonly relief: number;
+ /** How much the height field shades the colour map. */
+ readonly shade: number;
+ readonly roughness: number;
+ readonly spread: number;
+ readonly cavity: number;
};
-const createSurfaceTextures = (anisotropy: number): SurfaceTextures => {
- const wallpaper = createCanvas();
- drawWallpaper(wallpaper.ctx);
- const carpet = createCanvas();
- drawCarpet(carpet.ctx);
- const ceiling = createCanvas();
- drawCeiling(ceiling.ctx);
+const surfaceMaps = (
+ { draw, height, relief, shade, roughness, spread, cavity }: SurfaceLook,
+ anisotropy: number
+): SurfaceMaps => {
+ const field = height(SIZE);
+ const colour = createCanvas(SIZE);
+ draw(colour.ctx);
+ shadeByHeight(colour.ctx, field, shade);
+ const normal = dataCanvas(heightToNormal(field, SIZE, relief), SIZE);
+ const half = SIZE / 2;
+ const orm = dataCanvas(
+ heightToOrm(halveHeight(field, SIZE), half, { roughness, spread, cavity }),
+ half
+ );
return {
- wallpaper: toTexture(wallpaper.canvas, anisotropy),
- carpet: toTexture(carpet.canvas, anisotropy),
- ceiling: toTexture(ceiling.canvas, anisotropy),
+ map: toTexture(colour.canvas, anisotropy, SRGBColorSpace),
+ normalMap: toTexture(normal, anisotropy, NoColorSpace),
+ orm: toTexture(orm, anisotropy, NoColorSpace),
};
};
+const WALLPAPER: SurfaceLook = {
+ draw: drawWallpaper,
+ height: wallpaperHeight,
+ relief: 6,
+ shade: 0.15,
+ roughness: 0.85,
+ spread: 0.1,
+ cavity: 2,
+};
+
+const CARPET: SurfaceLook = {
+ draw: drawCarpet,
+ height: carpetHeight,
+ relief: 10,
+ shade: 0.5,
+ roughness: 0.97,
+ spread: 0.03,
+ cavity: 3,
+};
+
+const CEILING: SurfaceLook = {
+ draw: drawCeiling,
+ height: ceilingHeight,
+ relief: 8,
+ shade: 0.2,
+ roughness: 0.92,
+ spread: 0.06,
+ cavity: 3,
+};
+
+type SurfaceTextures = {
+ readonly wallpaper: SurfaceMaps;
+ readonly carpet: SurfaceMaps;
+ readonly ceiling: SurfaceMaps;
+};
+
+/**
+ * Colour, normal and occlusion-roughness maps for each surface, generated
+ * once per Canvas from seeded height fields: nothing is fetched.
+ */
+const createSurfaceTextures = (anisotropy: number): SurfaceTextures => ({
+ wallpaper: surfaceMaps(WALLPAPER, anisotropy),
+ carpet: surfaceMaps(CARPET, anisotropy),
+ ceiling: surfaceMaps(CEILING, anisotropy),
+});
+
export { createSurfaceTextures, TILE };
+export type { SurfaceMaps };
diff --git a/packages/renderer/src/world.tsx b/packages/renderer/src/world.tsx
index 07e79a7..af83b30 100644
--- a/packages/renderer/src/world.tsx
+++ b/packages/renderer/src/world.tsx
@@ -1,7 +1,9 @@
-import { useThree } from "@react-three/fiber";
+import { useFrame, useThree } from "@react-three/fiber";
import type { AreaWorld } from "@repo/types";
import { pendingPortals } from "@repo/world-generator/areas";
-import { useEffect, useMemo } from "react";
+import { useEffect, useMemo, useRef } from "react";
+import { BackSide } from "three";
+import type { Mesh } from "three";
import { Area } from "./area";
import { areaFixtures } from "./fixtures";
@@ -14,7 +16,34 @@ type WorldProps = {
const HAZE = "#4d441f";
-/** Scene-wide setup: background, fog, ambient light, shared materials, areas. */
+/** Past full fog (28 m) and inside the camera's far plane (32 m). */
+const HAZE_RADIUS = 30;
+
+/**
+ * The haze beyond the fog, drawn as a sphere around the camera rather than
+ * as the clear colour: a mesh is tone-mapped like the fogged walls are,
+ * whether the renderer or a post-processing pass does the mapping, so the
+ * horizon matches the walls at every quality.
+ */
+const Haze = () => {
+ const mesh = useRef(null);
+ useFrame(({ camera }) => {
+ mesh.current?.position.copy(camera.position);
+ });
+ return (
+
+
+
+
+ );
+};
+
+/** Scene-wide setup: haze, fog, ambient light, shared materials, areas. */
const World = ({ world }: WorldProps) => {
const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene);
@@ -37,6 +66,7 @@ const World = ({ world }: WorldProps) => {
+
{areas.map((area) => (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f330aef..074c4c5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -383,12 +383,21 @@ importers:
'@react-three/fiber':
specifier: 'catalog:'
version: 9.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(three@0.186.0)
+ '@react-three/postprocessing':
+ specifier: 3.1.3
+ version: 3.1.3(@react-three/fiber@9.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(three@0.186.0))(@types/three@0.186.0)(postprocessing@6.39.5(three@0.186.0))(react@19.3.0)(three@0.186.0)
'@repo/types':
specifier: workspace:*
version: link:../types
'@repo/world-generator':
specifier: workspace:*
version: link:../world-generator
+ n8ao:
+ specifier: 2.0.1
+ version: 2.0.1(postprocessing@6.39.5(three@0.186.0))(three@0.186.0)
+ postprocessing:
+ specifier: 6.39.5
+ version: 6.39.5(three@0.186.0)
react:
specifier: 'catalog:'
version: 19.3.0
@@ -411,6 +420,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 7.0.2
+ vitest:
+ specifier: 'catalog:'
+ version: 5.0.1(@types/node@26.5.1)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
packages/types:
devDependencies:
@@ -1551,6 +1563,14 @@ packages:
react-native:
optional: true
+ '@react-three/postprocessing@3.1.3':
+ resolution: {integrity: sha512-LhCTSC+nli1agIIIgrhJalvJcdSy7/DOXG5EDGxKVlVsrqUxMPYlMnRN/QqgSAjGPKmnnuiEmpVQtOkTKwiidw==}
+ peerDependencies:
+ '@react-three/fiber': '>=9.7.0'
+ postprocessing: ^6.36.0
+ react: ^19.0.0
+ three: '>= 0.156.0'
+
'@rolldown/binding-android-arm-eabi@1.2.8':
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2331,6 +2351,12 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ n8ao@2.0.1:
+ resolution: {integrity: sha512-MoP1QKUdj4/3cKEnhFl917t0eQwWGER1l43ckz5mNOPyYWGRDGQbVoTOQz/VBvy01in6QVNG+AL6Xxx5AjZBYA==}
+ peerDependencies:
+ postprocessing: '>=6.30.0'
+ three: '>=0.137'
+
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2402,6 +2428,11 @@ packages:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
+ postprocessing@6.39.5:
+ resolution: {integrity: sha512-YmH+t74J2X1lwYjKzK/tgs9C9kqWWPB9JwZiDF5mEf4BdCEAmEFbO8lXBA7NM0/j4g+x6FgD/Tt5/kT7ogbEDA==}
+ peerDependencies:
+ three: '>= 0.168.0 < 0.187.0'
+
potpack@1.0.2:
resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
@@ -3542,6 +3573,17 @@ snapshots:
- '@types/react'
- immer
+ '@react-three/postprocessing@3.1.3(@react-three/fiber@9.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(three@0.186.0))(@types/three@0.186.0)(postprocessing@6.39.5(three@0.186.0))(react@19.3.0)(three@0.186.0)':
+ dependencies:
+ '@react-three/fiber': 9.8.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(three@0.186.0)
+ maath: 0.10.8(@types/three@0.186.0)(three@0.186.0)
+ n8ao: 2.0.1(postprocessing@6.39.5(three@0.186.0))(three@0.186.0)
+ postprocessing: 6.39.5(three@0.186.0)
+ react: 19.3.0
+ three: 0.186.0
+ transitivePeerDependencies:
+ - '@types/three'
+
'@rolldown/binding-android-arm-eabi@1.2.8':
optional: true
@@ -4142,6 +4184,11 @@ snapshots:
ms@2.1.3: {}
+ n8ao@2.0.1(postprocessing@6.39.5(three@0.186.0))(three@0.186.0):
+ dependencies:
+ postprocessing: 6.39.5(three@0.186.0)
+ three: 0.186.0
+
nanoid@3.3.19: {}
obug@2.2.1: {}
@@ -4264,6 +4311,10 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postprocessing@6.39.5(three@0.186.0):
+ dependencies:
+ three: 0.186.0
+
potpack@1.0.2: {}
prettier-plugin-tailwindcss@0.8.1(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.9.6)(supports-color@10.2.2))(prettier@3.9.6):