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):