From 345357fddea2c4f70035707736c22b4a408b7191 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:36:03 +0300 Subject: [PATCH 1/5] feat: add PBR surfaces, AgX tone mapping and post-processing The wallpaper, carpet and ceiling tile are seeded, periodic height fields that give each surface a colour, normal and occlusion-roughness map, so the rooms have relief without fetching any assets. Both Canvases now share one AgX colour pipeline at one exposure: the renderer tone-maps at the low quality, and at high a post-processing chain (n8ao ambient occlusion, bloom off the fixtures, grain, vignette, lens fringing) tone-maps last. The haze past the fog is a sphere around the camera, tone-mapped like the walls at either quality. Q toggles the quality, kept in localStorage. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- apps/web/src/hud.test.tsx | 37 +++ apps/web/src/hud.tsx | 7 +- apps/web/src/landing-backdrop.tsx | 4 + apps/web/src/landing.tsx | 2 +- apps/web/src/quality.test.ts | 82 ++++++ apps/web/src/quality.ts | 82 ++++++ apps/web/src/self-files.ts | 1 + apps/web/src/world-app.tsx | 16 +- apps/web/tsconfig.json | 6 +- packages/renderer/README.md | 12 + packages/renderer/package.json | 9 +- packages/renderer/src/effects.tsx | 84 ++++++ packages/renderer/src/height-field.test.ts | 171 ++++++++++++ packages/renderer/src/height-field.ts | 304 +++++++++++++++++++++ packages/renderer/src/n8ao.d.ts | 32 +++ packages/renderer/src/surfaces.ts | 41 ++- packages/renderer/src/textures.ts | 169 ++++++++++-- packages/renderer/src/world.tsx | 36 ++- pnpm-lock.yaml | 51 ++++ 20 files changed, 1094 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/hud.test.tsx create mode 100644 apps/web/src/quality.test.ts create mode 100644 apps/web/src/quality.ts create mode 100644 packages/renderer/src/effects.tsx create mode 100644 packages/renderer/src/height-field.test.ts create mode 100644 packages/renderer/src/height-field.ts create mode 100644 packages/renderer/src/n8ao.d.ts 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..24838f3 --- /dev/null +++ b/apps/web/src/quality.ts @@ -0,0 +1,82 @@ +import type { Quality } from "@repo/renderer/effects"; +import { useSyncExternalStore } from "react"; + +// The rendering quality: one value for the whole page, kept in localStorage +// so a browser that cannot afford the post-processing stays on `low`. + +const KEY = "quality"; + +const DEFAULT_QUALITY: Quality = "high"; + +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..83b2915 --- /dev/null +++ b/packages/renderer/src/effects.tsx @@ -0,0 +1,84 @@ +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, useMemo } from "react"; +import { AgXToneMapping, 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; + +/** + * 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 composer takes `toneMapping` over while mounted and gives AgX + * back when it unmounts, so switching quality never changes the brightness. + */ +const Effects = ({ quality }: { readonly quality: Quality }) => { + const get = useThree((state) => state.get); + useEffect(() => { + const { gl } = get(); + gl.toneMapping = AgXToneMapping; + gl.toneMappingExposure = EXPOSURE; + }, [get]); + const high = quality === "high"; + // The composer sizes its passes on every resize; the constructor only + // needs a first size, so the pass is not rebuilt when the window changes. + const occlusion = useMemo(() => { + if (!high) { + return null; + } + 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 = 12; + configuration.denoiseSamples = 6; + configuration.denoiseRadius = 8; + // Tone mapping follows, so the pass must hand on linear light. + configuration.gammaCorrection = false; + return pass; + }, [high, get]); + useEffect(() => () => occlusion?.dispose(), [occlusion]); + if (occlusion === null) { + return null; + } + return ( + + + + + + + + + ); +}; + +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..4bd5101 --- /dev/null +++ b/packages/renderer/src/height-field.ts @@ -0,0 +1,304 @@ +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. + */ + +/** 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(); + } + const at = (cx: number, cy: number): number => + lattice[wrap(cy, cells) * cells + wrap(cx, cells)] ?? 0; + const span = size / cells; + const out = new Float32Array(size * size); + for (let y = 0; y < size; y += 1) { + const fy = y / span; + const cy = Math.floor(fy); + const ty = smoothstep(fy - cy); + for (let x = 0; x < size; x += 1) { + const fx = x / span; + const cx = Math.floor(fx); + const tx = smoothstep(fx - cx); + const top = at(cx, cy) + (at(cx + 1, cy) - at(cx, cy)) * tx; + const bottom = + at(cx, cy + 1) + (at(cx + 1, cy + 1) - at(cx, cy + 1)) * tx; + out[y * size + 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]. */ +const fbm = ( + seed: number, + size: number, + { octaves, cells, gain = 0.5 }: FbmOptions +): Float32Array => { + const out = new Float32Array(size * size); + let amplitude = 1; + let total = 0; + for (let octave = 0; octave < octaves; octave += 1) { + const noise = latticeNoise(seed + octave * 7919, size, cells * 2 ** octave); + 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; +}; + +/** Box blur of radius `radius`, wrapping, two separable passes. */ +const blur = ( + height: Float32Array, + size: number, + radius: number +): Float32Array => { + const horizontal = new Float32Array(size * size); + const window = 2 * radius + 1; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + let sum = 0; + for (let d = -radius; d <= radius; d += 1) { + sum += height[y * size + wrap(x + d, size)] ?? 0; + } + horizontal[y * size + x] = sum / window; + } + } + const out = new Float32Array(size * size); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + let sum = 0; + for (let d = -radius; d <= radius; d += 1) { + sum += horizontal[wrap(y + d, size) * size + x] ?? 0; + } + out[y * size + x] = sum / window; + } + } + return out; +}; + +/** + * 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) { + for (let x = 0; x < size; x += 1) { + const left = height[y * size + wrap(x - 1, size)] ?? 0; + const right = height[y * size + wrap(x + 1, size)] ?? 0; + const up = height[wrap(y - 1, size) * size + x] ?? 0; + const down = height[wrap(y + 1, size) * size + x] ?? 0; + const dx = ((right - left) / 2) * strength; + const dy = ((down - up) / 2) * strength; + const length = Math.hypot(dx, dy, 1); + const i = (y * size + 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: 4, cells: 32 }); + const out = new Float32Array(size * size); + const stripes = 16; + const diamonds = 4; + const cell = size / diamonds; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + // Soft-edged stripes, as flocked paper. + const phase = ((x * stripes) / size) % 1; + const stripe = phase < 0.45 ? smoothstep(Math.min(1, phase / 0.08)) : 0; + // The diamond outline: an L1 ring around each cell's centre. + const dx = Math.abs(((x % cell) + cell) % cell) / cell - 0.5; + const dy = Math.abs(((y % cell) + cell) % cell) / cell - 0.5; + const ring = Math.abs(Math.abs(dx) / 0.45 + Math.abs(dy) / 0.7 - 0.5); + const diamond = ring < 0.04 ? 1 - ring / 0.04 : 0; + const i = y * size + x; + out[i] = clamp01( + 0.45 + 0.1 * stripe + 0.12 * diamond + 0.25 * ((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: 5, cells: 64, 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: 4, 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) { + for (let x = 0; x < size; x += 1) { + const i = y * size + x; + const fissure = (fissures[i] ?? 0) > 0.62 ? 0.3 : 0; + let h = 0.75 + 0.08 * ((texture[i] ?? 0) - 0.5) - fissure; + if (x < seam || x >= size - seam || y < seam || y >= size - seam) { + h = 0.2; + } + out[i] = clamp01(h); + } + } + // 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..9480fe9 100644 --- a/packages/renderer/src/textures.ts +++ b/packages/renderer/src/textures.ts @@ -1,16 +1,35 @@ 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; -const SIZE = 512; +/** Texels across the colour and normal maps; the ORM map is half that. */ +const SIZE = 1024; + +/** The painters below were drawn for a 512 canvas; their measures scale. */ +const S = SIZE / 512; -const createCanvas = () => { +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 +45,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 +73,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 +112,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.3, + 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.4, + 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): From 7477c56330f10f1e495c508d2d7a20184e43671a Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:47:23 +0300 Subject: [PATCH 2/5] perf: generate the surface maps in under 200 ms and tune their look The lattice noise precomputes its per-axis cells and blends, the blur runs with a running sum, and the maps are 512 texels across (2 mm on a 1 m tile): the nine maps now take about 190 ms instead of 7 s. Finer wallpaper grain and sparse, shallow ceiling fissures keep the stripe and diamond motif and the tile readable; exposure 1.15 brings AgX level with the previous look. Co-Authored-By: Claude Fable 5.1 --- packages/renderer/src/effects.tsx | 2 +- packages/renderer/src/height-field.ts | 163 ++++++++++++++++---------- packages/renderer/src/textures.ts | 12 +- 3 files changed, 113 insertions(+), 64 deletions(-) diff --git a/packages/renderer/src/effects.tsx b/packages/renderer/src/effects.tsx index 83b2915..5dff7f9 100644 --- a/packages/renderer/src/effects.tsx +++ b/packages/renderer/src/effects.tsx @@ -19,7 +19,7 @@ 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; +const EXPOSURE = 1.15; /** * The frame's look, the same at every quality: AgX tone mapping, with the diff --git a/packages/renderer/src/height-field.ts b/packages/renderer/src/height-field.ts index 4bd5101..0af5fd9 100644 --- a/packages/renderer/src/height-field.ts +++ b/packages/renderer/src/height-field.ts @@ -7,6 +7,8 @@ import { createRng } from "@repo/world-generator/random"; * * 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. */ @@ -33,22 +35,34 @@ const latticeNoise = ( for (let i = 0; i < lattice.length; i += 1) { lattice[i] = rng(); } - const at = (cx: number, cy: number): number => - lattice[wrap(cy, cells) * cells + wrap(cx, cells)] ?? 0; + // 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 fy = y / span; - const cy = Math.floor(fy); - const ty = smoothstep(fy - cy); + 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 fx = x / span; - const cx = Math.floor(fx); - const tx = smoothstep(fx - cx); - const top = at(cx, cy) + (at(cx + 1, cy) - at(cx, cy)) * tx; - const bottom = - at(cx, cy + 1) + (at(cx + 1, cy + 1) - at(cx, cy + 1)) * tx; - out[y * size + x] = top + (bottom - top) * ty; + 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; @@ -61,17 +75,27 @@ type FbmOptions = { readonly gain?: number; }; -/** Fractal sum of periodic value noise, normalised to [0, 1]. */ +/** + * 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 noise = latticeNoise(seed + octave * 7919, size, cells * 2 ** octave); + 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; } @@ -84,36 +108,39 @@ const fbm = ( return out; }; -/** Box blur of radius `radius`, wrapping, two separable passes. */ -const blur = ( - height: Float32Array, +/** One axis of a wrapping box blur with a running sum: O(n), any radius. */ +const blurAxis = ( + input: Float32Array, size: number, - radius: number + radius: number, + stepX: number, + stepY: number ): Float32Array => { - const horizontal = new Float32Array(size * size); + const out = new Float32Array(size * size); const window = 2 * radius + 1; - for (let y = 0; y < size; y += 1) { - for (let x = 0; x < size; x += 1) { - let sum = 0; - for (let d = -radius; d <= radius; d += 1) { - sum += height[y * size + wrap(x + d, size)] ?? 0; - } - horizontal[y * size + x] = sum / window; + 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; } - } - const out = new Float32Array(size * size); - for (let y = 0; y < size; y += 1) { - for (let x = 0; x < size; x += 1) { - let sum = 0; - for (let d = -radius; d <= radius; d += 1) { - sum += horizontal[wrap(y + d, size) * size + x] ?? 0; - } - out[y * size + x] = sum / window; + 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 @@ -127,15 +154,18 @@ const heightToNormal = ( ): 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[y * size + wrap(x - 1, size)] ?? 0; - const right = height[y * size + wrap(x + 1, size)] ?? 0; - const up = height[wrap(y - 1, size) * size + x] ?? 0; - const down = height[wrap(y + 1, size) * size + x] ?? 0; + 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.hypot(dx, dy, 1); - const i = (y * size + x) * 4; + 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; @@ -217,24 +247,36 @@ const downsample = (rgba: Rgba, size: number): Rgba => { /** Mustard wallpaper: embossed stripes, the diamond motif and paper grain. */ const wallpaperHeight = (size: number): Float32Array => { - const grain = fbm(11, size, { octaves: 4, cells: 32 }); - const out = new Float32Array(size * size); + 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) { - // Soft-edged stripes, as flocked paper. - const phase = ((x * stripes) / size) % 1; - const stripe = phase < 0.45 ? smoothstep(Math.min(1, phase / 0.08)) : 0; // The diamond outline: an L1 ring around each cell's centre. - const dx = Math.abs(((x % cell) + cell) % cell) / cell - 0.5; - const dy = Math.abs(((y % cell) + cell) % cell) / cell - 0.5; - const ring = Math.abs(Math.abs(dx) / 0.45 + Math.abs(dy) / 0.7 - 0.5); + const ring = Math.abs((across[x] ?? 0) + dy - 0.5); const diamond = ring < 0.04 ? 1 - ring / 0.04 : 0; - const i = y * size + x; + const i = row + x; out[i] = clamp01( - 0.45 + 0.1 * stripe + 0.12 * diamond + 0.25 * ((grain[i] ?? 0) - 0.5) + 0.45 + + 0.12 * (stripe[x] ?? 0) + + 0.15 * diamond + + 0.1 * ((grain[i] ?? 0) - 0.5) ); } } @@ -243,7 +285,7 @@ const wallpaperHeight = (size: number): Float32Array => { /** Low-pile carpet: dense fibre noise over a looser weave. */ const carpetHeight = (size: number): Float32Array => { - const fibres = fbm(23, size, { octaves: 5, cells: 64, gain: 0.6 }); + 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); @@ -258,18 +300,21 @@ const carpetHeight = (size: number): Float32Array => { /** 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: 4, cells: 24, gain: 0.55 }); + 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 = y * size + x; - const fissure = (fissures[i] ?? 0) > 0.62 ? 0.3 : 0; - let h = 0.75 + 0.08 * ((texture[i] ?? 0) - 0.5) - fissure; - if (x < seam || x >= size - seam || y < seam || y >= size - seam) { - h = 0.2; + const i = row + x; + if (edgeRow || x < seam || x >= size - seam) { + out[i] = 0.2; + continue; } - out[i] = clamp01(h); + // 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. diff --git a/packages/renderer/src/textures.ts b/packages/renderer/src/textures.ts index 9480fe9..c5825be 100644 --- a/packages/renderer/src/textures.ts +++ b/packages/renderer/src/textures.ts @@ -20,8 +20,12 @@ 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; the ORM map is half that. */ -const SIZE = 1024; +/** + * 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; /** The painters below were drawn for a 512 canvas; their measures scale. */ const S = SIZE / 512; @@ -186,7 +190,7 @@ const WALLPAPER: SurfaceLook = { draw: drawWallpaper, height: wallpaperHeight, relief: 6, - shade: 0.3, + shade: 0.15, roughness: 0.85, spread: 0.1, cavity: 2, @@ -206,7 +210,7 @@ const CEILING: SurfaceLook = { draw: drawCeiling, height: ceilingHeight, relief: 8, - shade: 0.4, + shade: 0.2, roughness: 0.92, spread: 0.06, cavity: 3, From 141abaa2c82b83effcf5b3dd42a2bb11448745eb Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:53:47 +0300 Subject: [PATCH 3/5] fix: address review findings Set AgX on the renderer only at the low quality: the composer, a child, turns tone mapping off before the parent effect ran, so re-applying AgX there mapped the frame twice. Build the n8ao pass once per Canvas and keep it across quality changes, as Pass.dispose does not reach its fullscreen objects and every rebuild leaked them. Co-Authored-By: Claude Fable 5.1 --- packages/renderer/src/effects.tsx | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/renderer/src/effects.tsx b/packages/renderer/src/effects.tsx index 5dff7f9..b0bef81 100644 --- a/packages/renderer/src/effects.tsx +++ b/packages/renderer/src/effects.tsx @@ -27,23 +27,27 @@ const EXPOSURE = 1.15; * * 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 composer takes `toneMapping` over while mounted and gives AgX - * back when it unmounts, so switching quality never changes the brightness. + * last. The composer turns the renderer's own tone mapping off while it is + * mounted (its effect runs before this component's, being a child) and + * gives the previous mapping back when it unmounts; so AgX is set on the + * renderer only at `low`, where nothing else maps, and never over the + * composer's `NoToneMapping`. */ const Effects = ({ quality }: { readonly quality: Quality }) => { const get = useThree((state) => state.get); + const high = quality === "high"; useEffect(() => { const { gl } = get(); - gl.toneMapping = AgXToneMapping; gl.toneMappingExposure = EXPOSURE; - }, [get]); - const high = quality === "high"; - // The composer sizes its passes on every resize; the constructor only - // needs a first size, so the pass is not rebuilt when the window changes. - const occlusion = useMemo(() => { if (!high) { - return null; + gl.toneMapping = 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); @@ -59,9 +63,9 @@ const Effects = ({ quality }: { readonly quality: Quality }) => { // Tone mapping follows, so the pass must hand on linear light. configuration.gammaCorrection = false; return pass; - }, [high, get]); - useEffect(() => () => occlusion?.dispose(), [occlusion]); - if (occlusion === null) { + }, [get]); + useEffect(() => () => occlusion.dispose(), [occlusion]); + if (!high) { return null; } return ( From 4616a9692072935807de1af081174539662d44cd Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:03:50 +0300 Subject: [PATCH 4/5] fix: default to the low quality and calm the high chain On a real screen the grain read as noise and the post chain cost a frame on top of the frame, so the default is now low (the old single pass with the new materials) and high is opted into with Q: its grain drops from 3.5 % to 1 % and the ambient occlusion takes 8 samples and 4 denoise taps. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/quality.ts | 7 ++++--- packages/renderer/src/effects.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/quality.ts b/apps/web/src/quality.ts index 24838f3..3888e78 100644 --- a/apps/web/src/quality.ts +++ b/apps/web/src/quality.ts @@ -1,12 +1,13 @@ import type { Quality } from "@repo/renderer/effects"; import { useSyncExternalStore } from "react"; -// The rendering quality: one value for the whole page, kept in localStorage -// so a browser that cannot afford the post-processing stays on `low`. +// 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 = "high"; +const DEFAULT_QUALITY: Quality = "low"; type QualityStorage = Pick; diff --git a/packages/renderer/src/effects.tsx b/packages/renderer/src/effects.tsx index b0bef81..cf5a607 100644 --- a/packages/renderer/src/effects.tsx +++ b/packages/renderer/src/effects.tsx @@ -57,8 +57,8 @@ const Effects = ({ quality }: { readonly quality: Quality }) => { configuration.intensity = 2.5; configuration.halfRes = true; configuration.depthAwareUpsampling = true; - configuration.aoSamples = 12; - configuration.denoiseSamples = 6; + configuration.aoSamples = 8; + configuration.denoiseSamples = 4; configuration.denoiseRadius = 8; // Tone mapping follows, so the pass must hand on linear light. configuration.gammaCorrection = false; @@ -72,7 +72,8 @@ const Effects = ({ quality }: { readonly quality: Quality }) => { - + + Date: Tue, 13 Oct 2026 10:17:16 +0300 Subject: [PATCH 5/5] fix: address PR review feedback Set the renderer's tone mapping in a layout effect, before the World's passive shader precompile, so it warms the variants the first frame uses: AgX at low, none at high where the composer maps. Multisample the composer's off-screen buffer (4 samples): the Canvas's own antialiasing does not reach it, so high rendered aliased edges. Co-Authored-By: Claude Opus 5.5 --- packages/renderer/src/effects.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/renderer/src/effects.tsx b/packages/renderer/src/effects.tsx index cf5a607..1bf51ed 100644 --- a/packages/renderer/src/effects.tsx +++ b/packages/renderer/src/effects.tsx @@ -9,8 +9,8 @@ import { } from "@react-three/postprocessing"; import { N8AOPostPass } from "n8ao"; import { ToneMappingMode } from "postprocessing"; -import { useEffect, useMemo } from "react"; -import { AgXToneMapping, Vector2 } from "three"; +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"; @@ -27,21 +27,21 @@ const EXPOSURE = 1.15; * * 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 composer turns the renderer's own tone mapping off while it is - * mounted (its effect runs before this component's, being a child) and - * gives the previous mapping back when it unmounts; so AgX is set on the - * renderer only at `low`, where nothing else maps, and never over the - * composer's `NoToneMapping`. + * 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"; - useEffect(() => { + useLayoutEffect(() => { const { gl } = get(); gl.toneMappingExposure = EXPOSURE; - if (!high) { - gl.toneMapping = AgXToneMapping; - } + 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 @@ -69,7 +69,9 @@ const Effects = ({ quality }: { readonly quality: Quality }) => { return null; } return ( - + // The composer draws off screen, where the Canvas's own antialiasing + // does not reach: it multisamples instead. +