diff --git a/docs/prp-docs/cod-game-toolkit-prp.md b/docs/prp-docs/cod-game-toolkit-prp.md new file mode 100644 index 00000000..4679fce3 --- /dev/null +++ b/docs/prp-docs/cod-game-toolkit-prp.md @@ -0,0 +1,103 @@ +# PRP — CoD Game Toolkit + Gauntlet-Loop Demo Generator + +**Status:** Phase 2a (toolkit foundation) implemented on `spike/cod-walking-skeleton`; +Phase 2b (the generator skill) designed here, not yet built. +**Category:** enhancements · **Feature:** `features/enhancements/051-cod-game-toolkit/` +**Branch:** `spike/cod-walking-skeleton` + +## Summary + +Harvest Matt Shumer's MIT [Claude-of-Duty](https://github.com/mshumer/Claude-of-Duty) +procedural primitives into a **reusable, asset-free R3F game toolkit** for the +ScriptHammer family, then build a **gauntlet-loop generator** that scaffolds a +playable game demo from a short spec. The toolkit is the "variable function"; each +game is a parameterization of it. + +Nine slices (proven on Three r184) already harvested the primitives: physics +(swept-capsule + BVH), a procedural PBR material forge, an atmospheric sky + IBL, +procedural audio (surface-keyed footsteps), a GPU particle system, camera-feel +springs, a crouch/sprint/prone locomotion layer, and the two core gems (event bus + +quality tiers). All are 100% procedural — zero art/audio assets — which is exactly +what makes a browser 3D prototype hard, gift-wrapped. + +## Why "harvest, not embed" + +CoD runs its own imperative render loop + service-locator kernel; **R3F owns the +`` renderer and loop**. Two loops can't share one canvas, so we do not lift +the kernel — we vendor the framework-agnostic primitives under `src/lib/cod/` and +adapt each to R3F (materials bake off-screen; physics/particles tick in `useFrame`; +audio/springs are hooks; the OVERWATCH `ctx` becomes plain modules — an event bus, +a quality store, injected renderers). The kernel (engine/registry/prewarm/main) is +never vendored. + +## Phase 2a — the packaged toolkit (this pass) + +Public API: **`@/lib/cod`** (barrel). See `src/lib/cod/README.md`. + +- **Core gems** (`src/lib/cod/core/`): `EventBus`/`bus` (game events without React + re-renders; `on` returns an unsubscribe closure) and `QUALITY_PRESETS` + + `useQuality()` (low/medium/high/ultra tiers; the renderer-generic fields only — + CoD's post-chain flags dropped). Both typed TS, ported from `registry.js:86-122` + and `config.js:21`. +- **Typed public surface**: a barrel (`index.ts`) + hand-written `.d.ts` for the + primary classes (`CharacterController`, `StaticWorld`); the material/particle/sky + classes are reached via the already-typed hooks. +- **Gems wired into the demo** (proof they're live, not dead): `useQuality` drives + the Canvas `dpr` (`renderScale`), texture `anisotropy`, and the dust particle pool + (`particleBudget`), with a HUD ` setTier(e.target.value as QualityTier)} + data-quality={tier} + className="bg-base-300/70 text-base-content absolute top-2 right-2 rounded px-2 py-1 text-xs capitalize" + > + {QUALITY_TIERS.map((t) => ( + + ))} + + + ); +} diff --git a/src/components/game/CodSkeleton/index.tsx b/src/components/game/CodSkeleton/index.tsx new file mode 100644 index 00000000..6ea84714 --- /dev/null +++ b/src/components/game/CodSkeleton/index.tsx @@ -0,0 +1,2 @@ +export { default } from './CodSkeleton'; +export type { CodSkeletonProps } from './CodSkeleton'; diff --git a/src/components/game/ProceduralSky/ProceduralSky.accessibility.test.tsx b/src/components/game/ProceduralSky/ProceduralSky.accessibility.test.tsx new file mode 100644 index 00000000..94960489 --- /dev/null +++ b/src/components/game/ProceduralSky/ProceduralSky.accessibility.test.tsx @@ -0,0 +1,28 @@ +/** + * ProceduralSky — Accessibility Tests + * + * ProceduralSky renders no DOM of its own (it only mutates the Three.js scene), + * so there is no chrome to audit — the test simply asserts axe finds no + * violations in the empty container. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import React from 'react'; + +expect.extend(toHaveNoViolations); + +vi.mock('@react-three/fiber', () => ({ + useThree: () => ({}), +})); + +import ProceduralSky from './ProceduralSky'; + +describe('ProceduralSky Accessibility', () => { + it('renders no DOM chrome, so has no accessibility violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/src/components/game/ProceduralSky/ProceduralSky.stories.tsx b/src/components/game/ProceduralSky/ProceduralSky.stories.tsx new file mode 100644 index 00000000..0339b78c --- /dev/null +++ b/src/components/game/ProceduralSky/ProceduralSky.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { ReactNode } from 'react'; +import { Canvas } from '@react-three/fiber'; +import ProceduralSky from './ProceduralSky'; + +// ProceduralSky only renders meaningfully inside a . The decorator wraps +// every story in a minimal R3F scene with a chrome sphere so the baked sky IBL +// shows up as reflections (proving scene.environment was set). +function CanvasWrapper({ children }: { children: ReactNode }) { + return ( +
+ + + + + + {children} + +
+ ); +} + +const meta: Meta = { + title: 'Features/Game/ProceduralSky', + component: ProceduralSky, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Harvested Claude-of-Duty procedural sky. As a child of an R3F , it bakes an atmospheric sky dome (drawn as the background) and an IBL environment map (assigned to scene.environment) at mount — zero assets, no HDRI — so MeshStandardMaterials gain real specular/reflections. Static (fixed hour); the component itself renders null.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + hour: { + control: { type: 'range', min: 0, max: 24, step: 0.5 }, + description: 'Time of day (0–24) for the static sky bake. Default 16.5.', + }, + }, + decorators: [ + (StoryFn) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { args: { hour: 16.5 } }; + +export const Morning: Story = { + args: { hour: 8 }, + parameters: { + docs: { + description: { + story: 'Lower sun angle — the IBL on the chrome sphere shifts warmer/lower.', + }, + }, + }, +}; diff --git a/src/components/game/ProceduralSky/ProceduralSky.test.tsx b/src/components/game/ProceduralSky/ProceduralSky.test.tsx new file mode 100644 index 00000000..0f376b0b --- /dev/null +++ b/src/components/game/ProceduralSky/ProceduralSky.test.tsx @@ -0,0 +1,33 @@ +/** + * ProceduralSky — Unit Tests + * + * ProceduralSky is a side-effect R3F component (renders null): inside a + * `` it bakes the vendored Claude-of-Duty procedural sky into + * `scene.environment` (IBL) and adds a background dome mesh. `useThree()` throws + * outside a Canvas, so we mock it to return an empty object — the effect's + * `if (!gl || !scene) return` guard then makes it a safe no-op (the same guard + * that protects SSR / the mocked-Canvas path). Real bake correctness is a + * Playwright concern (real WebGL). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; + +vi.mock('@react-three/fiber', () => ({ + useThree: () => ({}), +})); + +import ProceduralSky from './ProceduralSky'; + +describe('ProceduralSky', () => { + it('renders nothing and does not crash without a renderer (mocked useThree)', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('accepts an hour prop without crashing', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/game/ProceduralSky/ProceduralSky.tsx b/src/components/game/ProceduralSky/ProceduralSky.tsx new file mode 100644 index 00000000..719667db --- /dev/null +++ b/src/components/game/ProceduralSky/ProceduralSky.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useEffect } from 'react'; +import * as THREE from 'three'; +import { useThree } from '@react-three/fiber'; +// Vendored, framework-agnostic Claude-of-Duty procedural sky (MIT — see +// src/lib/cod/sky/NOTICE.md). driver.js replaces index.js's OVERWATCH ctx wiring. +import { Celestial } from '@/lib/cod/sky/celestial'; +import { SkyLuts } from '@/lib/cod/sky/luts'; +import { SkyDome } from '@/lib/cod/sky/dome'; +import { hdrTarget, blit } from '@/lib/cod/sky/fullscreen'; +import { buildSharedUniforms, updateCelestial } from '@/lib/cod/sky/driver'; + +export interface ProceduralSkyProps { + /** Static time of day, 0–24 (local solar). Default 16.5 (hard afternoon key). */ + hour?: number; + /** + * Also add the visible sky-dome background mesh. The dome is a raw HDR + * `ShaderMaterial` (`toneMapped: false`), so it paints the background + * un-tone-mapped; the IBL env map (always set) is unaffected. Default true. + */ + showDome?: boolean; +} + +/** + * ProceduralSky — harvested Claude-of-Duty atmospheric sky + IBL (MIT). + * + * As a child of an R3F ``, at mount it bakes CoD's procedural sky once + * (zero assets, no HDRI) and: + * - sets `scene.environment` to the PMREM'd env map → existing + * `MeshStandardMaterial`s gain real specular/reflections; + * - optionally adds the sky-dome mesh (renderOrder −10000, self-tracks the + * camera) as the visible background. + * + * Harvest-not-embed: R3F owns the renderer + loop; the bake runs off-screen in + * `useEffect` with the render target saved/restored, and everything it owns is + * disposed on unmount. Static (fixed `hour`); the component itself renders null. + * Bake correctness is a Playwright concern (real WebGL); the unit test guards on + * a missing renderer. + * + * @category game + */ +export default function ProceduralSky({ + hour = 16.5, + showDome = true, +}: ProceduralSkyProps = {}): null { + const { gl, scene } = useThree(); + + useEffect(() => { + if (!gl || !scene) return; // mocked-Canvas / SSR guard → no-op + + const prevTarget = gl.getRenderTarget(); + const prevEnv = scene.environment; + + // Dependency order (CoD index.js): shared uniforms → SkyLuts (adds the 4 LUT + // textures to `shared`) → Celestial → SkyDome → bakeStatic → celestial solve + // → bakeSkyView → equirect blit → PMREM → scene.environment. + const shared = buildSharedUniforms(); + const luts = new SkyLuts(gl, shared); + const celestial = new Celestial(); + const dome = new SkyDome(shared); + const envEquirect = hdrTarget(512, 256, { name: 'sky-equirect' }); + envEquirect.texture.mapping = THREE.EquirectangularReflectionMapping; + const pmrem = new THREE.PMREMGenerator(gl); + let pmremRT: THREE.WebGLRenderTarget | null = null; + let added = false; + + try { + luts.bakeStatic(); // transmittance + multiscatter (altitude/aerosol) + updateCelestial(celestial, shared, hour); // fill sun/moon uniforms (before bakeSkyView) + luts.bakeSkyView(); // sky-view + ambient probe (needs LUTs + sun uniforms) + pmrem.compileEquirectangularShader(); + blit(gl, dome.envMaterial, envEquirect); // draw the sky into the equirect RT + pmremRT = pmrem.fromEquirectangular(envEquirect.texture); + pmremRT.texture.name = 'sky-env'; + scene.environment = pmremRT.texture; + if (showDome) { + scene.add(dome.mesh); // background painter, renderOrder −10000 + added = true; + } + } catch (err) { + console.warn('[cod-skeleton] procedural sky bake failed', err); + } finally { + gl.setRenderTarget(prevTarget); // restore for R3F's render loop + } + + return () => { + if (added) scene.remove(dome.mesh); + scene.environment = prevEnv; + dome.dispose(); // .material + .envMaterial (NOT the module-shared geometry) + luts.dispose(); // 4 RTs + 4 passes + envEquirect.dispose(); + pmremRT?.dispose(); + pmrem.dispose(); + }; + }, [gl, scene, hour, showDome]); + + return null; +} diff --git a/src/components/game/ProceduralSky/index.tsx b/src/components/game/ProceduralSky/index.tsx new file mode 100644 index 00000000..2e66cf49 --- /dev/null +++ b/src/components/game/ProceduralSky/index.tsx @@ -0,0 +1,2 @@ +export { default } from './ProceduralSky'; +export type { ProceduralSkyProps } from './ProceduralSky'; diff --git a/src/lib/cod/LICENSE b/src/lib/cod/LICENSE new file mode 100644 index 00000000..5cea86b9 --- /dev/null +++ b/src/lib/cod/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 mshumer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lib/cod/NOTICE.md b/src/lib/cod/NOTICE.md new file mode 100644 index 00000000..4ad7abd5 --- /dev/null +++ b/src/lib/cod/NOTICE.md @@ -0,0 +1,12 @@ +# Vendored from Claude-of-Duty (MIT) + +Source: https://github.com/mshumer/Claude-of-Duty (Matt Shumer), MIT license (see LICENSE). + +Verbatim-vendored subsystem primitives (framework-agnostic, `three`-only, 100% procedural): +- `math.js`, `surfaces.js` — pure scalar/geometry kernel + surface vocabulary +- `character.js` — swept-capsule character controller (no THREE import; queries a `world` handle) +- `bvh.js` — StaticWorld: binned-SAH BVH over triangle soup + raycast/capsuleCast queries (plain THREE) +- `springs.js` — Spring/RecoilAxis camera-feel helpers (zero imports) + +Ported r180 → r184: these import `three` (r184 in this repo). Runtime-verified against r184 by `scripts/cod-physics-smoke.mjs`. +Only the OVERWATCH `ctx` wiring was dropped; the primitives run standalone. diff --git a/src/lib/cod/README.md b/src/lib/cod/README.md new file mode 100644 index 00000000..61085707 --- /dev/null +++ b/src/lib/cod/README.md @@ -0,0 +1,57 @@ +# CoD Game Toolkit + +A procedural, **asset-free** game toolkit for React-Three-Fiber (Three.js r184, +Next static export), harvested from Matt Shumer's MIT +[Claude-of-Duty](https://github.com/mshumer/Claude-of-Duty). Everything is +generated on the GPU/CPU at load — zero textures, models, or audio files. + +Import the public API from **`@/lib/cod`** (this dir's `index.ts`). + +## Paradigm — harvest, not embed + +CoD ships an imperative render loop + service-locator kernel. **R3F owns the +`` renderer and loop**, so we do not lift the kernel — we harvest the +framework-agnostic primitives and adapt each to R3F (bake off-screen, tick in +`useFrame`, subscribe in `useEffect`). The OVERWATCH `ctx` service-locator is +replaced by plain modules (an event bus, a quality store, injected renderers). + +## What's inside + +| Primitive | Public API | Notes | +|---|---|---| +| **Physics** | `StaticWorld`, `CharacterController` | Binned-SAH BVH + swept-capsule collide-and-slide (no tunnelling). Typed (`.d.ts`). | +| **Materials** | `MaterialSystem` | Procedural PBR forge → `THREE` textures; triplanar, no UVs. Needs a renderer at bake time. | +| **Sky + IBL** | `SkyDome` (+ `useProceduralSky` via `ProceduralSky` component) | Atmospheric sky + PMREM env map, no HDRI. | +| **Audio** | `useFootsteps()` | Surface-keyed procedural footsteps (Web Audio). Resume on a user gesture. | +| **Particles** | `ParticleLayer`, `resetSpawn`, `useFootstepDust()` | Deterministic GPU particle system, one instanced draw. | +| **Camera feel** | `useCameraFeel()`, `Spring`, `RecoilAxis` | Head-bob + landing punch; damped-oscillator springs. | +| **Core gems** | `EventBus`, `bus`, `useQuality()`, `QUALITY_PRESETS` | Game events without React re-renders; low/medium/high/ultra tiers. | +| **Surfaces** | `MASK`, `SURFACE`, `LAYER`, `SURFACE_NAMES`, `guessSurface` | Shared 12-surface vocabulary (physics ↔ audio ↔ dust all key off it). | + +## Quick shape + +```tsx +import { StaticWorld, CharacterController, MASK, useFootsteps, useQuality, bus } from '@/lib/cod'; + +// physics (once): build a world from meshes, drive a capsule controller +const world = new StaticWorld(); +world.addMesh(floorMesh, 'dirt'); +world.build(); +const cc = new CharacterController(world, { radius: 0.32, height: 1.75, mask: MASK.CHARACTER }); + +// each fixed step: caller owns velocity; move() clips it and returns distance +const dist = cc.move(vx * dt, vy * dt, vz * dt); + +// events + quality +bus.emit('player:footstep', { surface: cc.groundSurfaceName, position: cc.position }); +const { preset } = useQuality(); // preset.particleBudget, preset.renderScale, … +``` + +See `src/components/game/CodSkeleton/` + `src/app/game/cod-skeleton/page.tsx` for a +full first-person reference demo (physics + materials + sky + audio + dust + +camera-feel + a crouch/sprint/prone locomotion layer). + +## Licensing + +All vendored code is MIT (Matt Shumer). Each subdir carries a `NOTICE.md`; the +root `LICENSE` is the MIT text. Keep them with any repackage. diff --git a/src/lib/cod/audio/NOTICE.md b/src/lib/cod/audio/NOTICE.md new file mode 100644 index 00000000..535f8f7b --- /dev/null +++ b/src/lib/cod/audio/NOTICE.md @@ -0,0 +1,10 @@ +# Vendored from Claude-of-Duty (MIT) — procedural audio (footsteps) + +Source: https://github.com/mshumer/Claude-of-Duty (Matt Shumer), MIT (see ../LICENSE). + +Minimal set for surface-keyed procedural footsteps (pure Web Audio, zero assets, +three-free, no ctx): rng.js (seedable PRNG, copied from src/core/rng.js), +dsp.js (NoiseBank + synth primitives), foley.js (footstep() + its STEP table, +imports only ./dsp.js). Not vendored: mixer/spatial/ir/ambience/index/weapons/vox. +foley's STEP surface keys are byte-identical to the physics SURFACE_NAMES, so +CharacterController.groundSurfaceName passes straight through (fallback: concrete). diff --git a/src/lib/cod/audio/ambientCity.test.ts b/src/lib/cod/audio/ambientCity.test.ts new file mode 100644 index 00000000..4db383c6 --- /dev/null +++ b/src/lib/cod/audio/ambientCity.test.ts @@ -0,0 +1,80 @@ +/** + * useAmbientCity — unit test. + * + * jsdom has no Web Audio (`window.AudioContext` is undefined), so the hook's + * guards make `resume()`/`start()`/`stop()` safe no-ops — the same "no-op, don't + * throw" contract useFootsteps relies on. Audible correctness (wind + distant + * traffic + birds) is verified in a real browser (Playwright). + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useAmbientCity, planVehiclePass, VEHICLE_PEAK_CEIL } from './ambientCity'; +import { Rng } from './rng'; + +describe('planVehiclePass — passing-vehicle recipe (pure, deterministic)', () => { + it('is deterministic for a given seed', () => { + const a = planVehiclePass(new Rng(0x1234)); + const b = planVehiclePass(new Rng(0x1234)); + expect(a).toEqual(b); + }); + + it('stays subtle — peak is a positive gain at or under the bed ceiling', () => { + // The audible guarantee that vehicles never mask the footsteps. + for (let seed = 1; seed <= 200; seed++) { + const p = planVehiclePass(new Rng(seed)); + expect(p.peak).toBeGreaterThan(0); + expect(p.peak).toBeLessThanOrEqual(VEHICLE_PEAK_CEIL); + } + }); + + it('sweeps fully across the stereo field (panFrom = −panTo, |pan| = 1)', () => { + for (let seed = 1; seed <= 200; seed++) { + const p = planVehiclePass(new Rng(seed)); + expect(Math.abs(p.panFrom)).toBe(1); + expect(Math.abs(p.panTo)).toBe(1); + expect(p.panFrom).toBe(-p.panTo); + expect(p.panTo).toBe(p.dir); + } + }); + + it('Doppler-shifts downward (approaching rate > receding rate), both near unity', () => { + for (let seed = 1; seed <= 200; seed++) { + const p = planVehiclePass(new Rng(seed)); + expect(p.rateFrom).toBeGreaterThan(p.rateTo); + // Subtle — a pass, not a race car. + expect(p.rateFrom).toBeLessThan(1.2); + expect(p.rateTo).toBeGreaterThan(0.85); + } + }); + + it('has a plausible pass duration and engine-body band', () => { + for (let seed = 1; seed <= 50; seed++) { + const p = planVehiclePass(new Rng(seed)); + expect(p.dur).toBeGreaterThanOrEqual(2.6); + expect(p.dur).toBeLessThanOrEqual(4.0); + expect(p.band).toBeGreaterThanOrEqual(150); + expect(p.band).toBeLessThanOrEqual(260); + } + }); +}); + +describe('useAmbientCity', () => { + it('returns resume + start + stop callbacks', () => { + const { result } = renderHook(() => useAmbientCity()); + expect(typeof result.current.resume).toBe('function'); + expect(typeof result.current.start).toBe('function'); + expect(typeof result.current.stop).toBe('function'); + }); + + it('is a silent no-op without Web Audio (jsdom) — never throws', () => { + const { result, unmount } = renderHook(() => useAmbientCity()); + expect(() => { + result.current.start(); // start before resume must not throw + result.current.resume(); + result.current.stop(); + result.current.stop(); // idempotent + unmount(); + }).not.toThrow(); + }); +}); diff --git a/src/lib/cod/audio/ambientCity.ts b/src/lib/cod/audio/ambientCity.ts new file mode 100644 index 00000000..21e47014 --- /dev/null +++ b/src/lib/cod/audio/ambientCity.ts @@ -0,0 +1,375 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +// Vendored, framework-agnostic Claude-of-Duty procedural audio (MIT — see +// src/lib/cod/audio/NOTICE.md). Pure Web Audio, zero assets. `ambience` was one +// of the modules NOT vendored upstream (NOTICE.md), so this is a clean addition +// built from the same DSP layer footsteps use: looping NoiseBank beds + biquad + +// gain + slow LFOs, plus a sparse lookahead scheduler for two transient voices — +// tonal bird chirps and stereo-panned passing vehicles (a Doppler-shifted rumble +// that sweeps across the field) — layered UNDER the wind/traffic bed for depth. +import { Rng } from './rng'; +import { NoiseBank, biquad, gain, osc, series, sweep, ad, semis } from './dsp'; + +interface AmbientState { + actx: AudioContext | null; + master: GainNode | null; + bank: NoiseBank | null; + rng: Rng | null; + /** The looping beds are live (built + playing). */ + running: boolean; + /** Continuous filter/gain nodes to disconnect on stop. */ + nodes: AudioNode[]; + /** Sources (bed loops + LFOs) needing an explicit .stop(). */ + sources: AudioScheduledSourceNode[]; + /** Bird + passing-vehicle lookahead scheduler. */ + birdTimer: ReturnType | null; + /** AudioContext time of the next scheduled bird chirp. */ + nextChirp: number; + /** AudioContext time of the next scheduled passing vehicle. */ + nextVehicle: number; +} + +/** A passing vehicle's tunable parameters, derived deterministically from the + * seeded Rng. Split out as a pure function so the "stays subtle, sits under the + * bed, sweeps fully across, Doppler-shifts down" contract is unit-testable + * without Web Audio (jsdom has none). See ambientCity.test.ts. */ +export interface VehiclePlan { + /** +1 pans left→right, −1 right→left. */ + dir: 1 | -1; + /** Pass duration (s) — approach through recede. */ + dur: number; + /** Peak gain at closest approach; capped so it never masks footsteps. */ + peak: number; + /** Playback rate approaching (Doppler up) — always > `rateTo`. */ + rateFrom: number; + /** Playback rate receding (Doppler down). */ + rateTo: number; + /** Engine/tire body centre frequency (Hz). */ + band: number; + /** Stereo pan at t0 (= −dir) and at the end (= dir). */ + panFrom: -1 | 1; + panTo: -1 | 1; +} + +/** Upper bound on a passing vehicle's peak gain — the audible guarantee that the + * layer stays UNDER the wind/traffic/bird bed and never muddies the footsteps. */ +export const VEHICLE_PEAK_CEIL = 0.2; + +/** Seconds between passing vehicles (sparse, so each pass reads as an event). */ +const VEHICLE_MIN_GAP = 7; +const VEHICLE_MAX_GAP = 16; + +/** Pure, deterministic recipe for one passing vehicle. Invariants (guarded by + * the unit test): peak ∈ (0, VEHICLE_PEAK_CEIL]; the pan sweeps fully across + * (panFrom = −panTo, |pan| = 1); and rateFrom > rateTo (a Doppler drop). */ +export function planVehiclePass(rng: Rng): VehiclePlan { + const dir: 1 | -1 = rng.float() < 0.5 ? 1 : -1; + return { + dir, + dur: rng.range(2.6, 4.0), + peak: rng.range(0.08, 0.16), // ≤ VEHICLE_PEAK_CEIL by construction + rateFrom: rng.range(1.03, 1.08), // approaching + rateTo: rng.range(0.92, 0.97), // receding — always below rateFrom + band: rng.range(150, 260), + panFrom: (-dir) as -1 | 1, + panTo: dir, + }; +} + +export interface UseAmbientCity { + /** + * Create (lazily) + resume the AudioContext. MUST be called from inside a user + * gesture — the autoplay policy leaves a fresh context 'suspended' until then. + * Shares the SAME gesture as footsteps (the caller wires both into one kick). + */ + resume: () => void; + /** + * Build + play the looping ambient beds (wind + distant traffic) and start the + * bird + passing-vehicle scheduler. Idempotent; safe to call before `resume()` + * — the graph is built on the (possibly still-suspended) context and sounds + * once resumed. + */ + start: () => void; + /** Fade out + stop the beds and the bird scheduler. Keeps the context alive so + * re-entering Walk can `start()` again. */ + stop: () => void; +} + +/** One tonal bird call: 2–4 rising sine syllables through a shared high-pass, so + * they sit above the low rumble. Nodes are short-lived and self-terminating. */ +function chirp(actx: AudioContext, rng: Rng, dest: AudioNode, t0: number): void { + const out = gain(actx, 0.5); + const hp = biquad(actx, 'highpass', 1500, 0.7); + out.connect(hp); + hp.connect(dest); + const syllables = 2 + (rng.u32() % 3); + const base = rng.range(2600, 4200); + let t = t0; + for (let i = 0; i < syllables; i++) { + const o = osc(actx, 'sine', base); + const g = gain(actx, 0); + const f0 = base * semis(rng.range(-2, 3)); + const f1 = f0 * semis(rng.range(2, 7)); // upward chirp + sweep(o.frequency, t, f0, f1, 0.06); + ad(g.gain, t, rng.range(0.12, 0.22), 0.006, 0.07); + o.connect(g); + g.connect(out); + o.start(t); + o.stop(t + 0.14); + t += rng.range(0.08, 0.18); + } +} + +/** One passing vehicle: a looping brown-noise rumble band-limited to an engine/ + * tire body, panned across the stereo field while its gain swells to a peak at + * closest approach, its pitch Doppler-drops, and its low-pass opens then closes. + * Short-lived and self-terminating (like `chirp`); connects to the shared master + * so `stop()`'s master fade silences a pass already in flight. */ +function vehiclePass( + actx: AudioContext, + bank: NoiseBank, + rng: Rng, + dest: AudioNode, + t0: number +): void { + const p = planVehiclePass(rng); + const half = p.dur * 0.5; + const panner = actx.createStereoPanner(); + const src = bank.source('brown', rng, p.rateFrom, true); // rolling rumble + const bp = biquad(actx, 'bandpass', p.band, 0.8); + const lp = biquad(actx, 'lowpass', 700, 0.7); + const g = gain(actx, 0.0001); + series(src, bp, lp, g).connect(panner); + panner.connect(dest); + + // Pan sweep across the field — the primary "passing" cue. + panner.pan.setValueAtTime(p.panFrom, t0); + panner.pan.linearRampToValueAtTime(p.panTo, t0 + p.dur); + // Doppler: approaching pitch → receding pitch. + src.playbackRate.setValueAtTime(p.rateFrom, t0); + src.playbackRate.linearRampToValueAtTime(p.rateTo, t0 + p.dur); + // Gain swell — rise to peak at closest approach, fade out. + g.gain.setValueAtTime(0.0001, t0); + g.gain.linearRampToValueAtTime(p.peak, t0 + half); + g.gain.linearRampToValueAtTime(0.0001, t0 + p.dur); + // Brightness opens at closest approach then closes (subtle). + lp.frequency.setValueAtTime(650, t0); + lp.frequency.linearRampToValueAtTime(1400, t0 + half); + lp.frequency.linearRampToValueAtTime(600, t0 + p.dur); + + src.start(t0, src._offset ?? 0); + src.stop(t0 + p.dur + 0.15); + // Release the (short) node chain once the source ends; nothing else refs it. + src.onended = () => { + try { + panner.disconnect(); + g.disconnect(); + lp.disconnect(); + bp.disconnect(); + } catch { + /* already gone */ + } + }; +} + +/** + * Procedural ambient-city bed for first-person Walk mode (Web Audio, zero + * assets). Harvest-not-embed: no engine, the caller drives resume/start/stop + * from the R3F composition root. Its own AudioContext + master gain (like + * useFootsteps) so it can sit UNDER the footsteps mix; SSR/jsdom-safe (no context + * until `resume()`/`start()` runs in a browser), and the context is closed on + * unmount. + */ +export function useAmbientCity(): UseAmbientCity { + const s = useRef({ + actx: null, + master: null, + bank: null, + rng: null, + running: false, + nodes: [], + sources: [], + birdTimer: null, + nextChirp: 0, + nextVehicle: 0, + }).current; + + // Lazily construct the context/master/bank. Returns false when Web Audio is + // unavailable (SSR / jsdom), so every public method degrades to a no-op. + const ensure = useCallback((): boolean => { + if (typeof window === 'undefined') return false; + const AC = + window.AudioContext ?? + (window as Window & { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!AC) return false; + if (!s.actx) { + const actx = new AC({ latencyHint: 'interactive' }); + const master = gain(actx, 0) as GainNode; // silent until start() fades in + master.connect(actx.destination); + const rng = new Rng(0x0cea9b1d); + // A longer bed (3.2 s) makes the loop point less obvious than footsteps'. + const bank = new NoiseBank(actx, rng.fork(), 3.2); + s.actx = actx; + s.master = master; + s.rng = rng; + s.bank = bank; + } + return true; + }, [s]); + + const resume = useCallback(() => { + if (!ensure()) return; + if (s.actx && s.actx.state === 'suspended') void s.actx.resume(); + }, [s, ensure]); + + const start = useCallback(() => { + if (!ensure() || s.running) return; + const actx = s.actx!; + const bank = s.bank!; + const rng = s.rng!; + const master = s.master!; + s.running = true; + const now = actx.currentTime; + + // Wind — looping brown noise, low-passed, with a slow gust LFO on its gain. + const wind = bank.source('brown', rng, 0.8, true); + const windLP = biquad(actx, 'lowpass', 520, 0.6); + const windGain = gain(actx, 0.16); + series(wind, windLP, windGain).connect(master); + const gust = osc(actx, 'sine', 0.07); + const gustDepth = gain(actx, 0.09); + gust.connect(gustDepth); + gustDepth.connect(windGain.gain); // modulate 0.07..0.25 + wind.start(now); + gust.start(now); + + // Distant traffic — looping pink noise, band-limited to a low rumble, with a + // gentler swell LFO. + const traf = bank.source('pink', rng, 0.6, true); + const trafHP = biquad(actx, 'highpass', 45, 0.7); + const trafLP = biquad(actx, 'lowpass', 340, 0.7); + const trafGain = gain(actx, 0.13); + series(traf, trafHP, trafLP, trafGain).connect(master); + const swell = osc(actx, 'sine', 0.043); + const swellDepth = gain(actx, 0.05); + swell.connect(swellDepth); + swellDepth.connect(trafGain.gain); // modulate 0.08..0.18 + traf.start(now); + swell.start(now); + + // Master fade-in so entering Walk doesn't pop. + master.gain.cancelScheduledValues(now); + master.gain.setValueAtTime(Math.max(master.gain.value, 0.0001), now); + master.gain.linearRampToValueAtTime(0.85, now + 2.5); + + s.sources.push(wind, gust, traf, swell); + s.nodes.push(windLP, windGain, gustDepth, trafHP, trafLP, trafGain, swellDepth); + + // Sparse lookahead scheduler (standard Web Audio pattern): schedule the two + // transient voices up to 1.5 s ahead, only while the context is running — + // birds 4–11 s apart, passing vehicles 7–16 s apart. Each guarded so a paused + // tab that resumes with a large clock jump can't spawn an unbounded burst. + s.nextChirp = now + rng.range(2, 6); + s.nextVehicle = now + rng.range(4, 9); + s.birdTimer = setInterval(() => { + const a = s.actx; + if (!a || a.state !== 'running' || !s.rng || !s.master || !s.bank) return; + const ahead = a.currentTime + 1.5; + let guard = 0; + while (s.nextChirp < ahead && guard++ < 8) { + chirp(a, s.rng, s.master, s.nextChirp); + s.nextChirp += s.rng.range(4, 11); + } + guard = 0; + while (s.nextVehicle < ahead && guard++ < 4) { + vehiclePass(a, s.bank, s.rng, s.master, s.nextVehicle); + s.nextVehicle += s.rng.range(VEHICLE_MIN_GAP, VEHICLE_MAX_GAP); + } + }, 500); + }, [s, ensure]); + + const stop = useCallback(() => { + if (s.birdTimer) { + clearInterval(s.birdTimer); + s.birdTimer = null; + } + const actx = s.actx; + if (!actx || !s.running) return; + s.running = false; + const now = actx.currentTime; + if (s.master) { + try { + s.master.gain.cancelScheduledValues(now); + s.master.gain.setValueAtTime(Math.max(s.master.gain.value, 0.0001), now); + s.master.gain.linearRampToValueAtTime(0.0001, now + 0.5); + } catch { + /* param already detached */ + } + } + const nodes = s.nodes; + const sources = s.sources; + s.nodes = []; + s.sources = []; + for (const src of sources) { + try { + src.stop(now + 0.55); + } catch { + /* already stopped */ + } + } + // Disconnect the graph after the fade completes. + setTimeout(() => { + for (const src of sources) { + try { + src.disconnect(); + } catch { + /* noop */ + } + } + for (const n of nodes) { + try { + n.disconnect(); + } catch { + /* noop */ + } + } + }, 700); + }, [s]); + + useEffect( + () => () => { + if (s.birdTimer) { + clearInterval(s.birdTimer); + s.birdTimer = null; + } + for (const src of s.sources) { + try { + src.stop(); + } catch { + /* noop */ + } + } + for (const n of s.nodes) { + try { + n.disconnect(); + } catch { + /* noop */ + } + } + s.nodes = []; + s.sources = []; + s.bank?.dispose(); + if (s.actx && s.actx.state !== 'closed') void s.actx.close(); + s.actx = null; + s.master = null; + s.bank = null; + s.rng = null; + s.running = false; + }, + [s] + ); + + return { resume, start, stop }; +} diff --git a/src/lib/cod/audio/dsp.js b/src/lib/cod/audio/dsp.js new file mode 100644 index 00000000..a15b142b --- /dev/null +++ b/src/lib/cod/audio/dsp.js @@ -0,0 +1,330 @@ +/** + * AUDIO / DSP TOOLKIT + * + * Low-level Web Audio helpers shared by every synthesis voice in this + * directory. Everything here is written against `BaseAudioContext` so the exact + * same code path renders in an `OfflineAudioContext` (see selftest.js) as in + * the live `AudioContext` — that is how this subsystem is verified without a + * user gesture or a speaker. + * + * Rules honoured here: + * - no randomness except through an injected `Rng` (ctx.rng.fork()) + * - buffers and curve tables are built once and shared + * - every node a voice creates hangs off a single top gain so the caller can + * disconnect the whole voice in one call when its tail has decayed + */ + +export const SPEED_OF_SOUND = 343; // m/s, 20 C dry air + +/* ------------------------------------------------------------------ */ +/* Noise */ +/* ------------------------------------------------------------------ */ + +/** + * Fill a Float32Array with one of the classic noise colours. + * white — flat spectrum, the raw material of cracks and hiss + * pink — -3 dB/oct (Paul Kellet's economy filter), city beds, tails + * brown — -6 dB/oct leaky integrator, wind and rumble + * crackle— sparse impulsive grains, debris and foliage + */ +export function fillNoise(out, kind, rng) { + const n = out.length; + switch (kind) { + case 'pink': { + let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0; + for (let i = 0; i < n; i++) { + const w = rng.signed(); + b0 = 0.99886 * b0 + w * 0.0555179; + b1 = 0.99332 * b1 + w * 0.0750759; + b2 = 0.969 * b2 + w * 0.153852; + b3 = 0.8665 * b3 + w * 0.3104856; + b4 = 0.55 * b4 + w * 0.5329522; + b5 = -0.7616 * b5 - w * 0.016898; + out[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11; + b6 = w * 0.115926; + } + break; + } + case 'brown': { + let last = 0; + for (let i = 0; i < n; i++) { + const w = rng.signed(); + last = (last + 0.019 * w) * 0.9985; + out[i] = last * 5.2; + } + break; + } + case 'crackle': { + out.fill(0); + // Poisson-ish grain train; each grain is a decaying two-pole ping so the + // buffer already has material character rather than pure clicks. + let i = 0; + while (i < n) { + i += 12 + ((rng.u32() % 260) | 0); + if (i >= n) break; + const amp = rng.range(0.25, 1) * (rng.float() < 0.12 ? 1.8 : 0.7); + const w = rng.range(0.05, 0.45); // radians/sample + const dec = Math.exp(-rng.range(0.004, 0.05)); + let a = amp; + for (let k = 0; k < 220 && i + k < n; k++) { + out[i + k] += Math.sin(w * k) * a; + a *= dec; + if (a < 1e-4) break; + } + } + // Keep the peak sane; grains overlap. + let peak = 1e-6; + for (let k = 0; k < n; k++) peak = Math.max(peak, Math.abs(out[k])); + const g = 0.9 / peak; + for (let k = 0; k < n; k++) out[k] *= g; + break; + } + default: + for (let i = 0; i < n; i++) out[i] = rng.signed(); + } + return out; +} + +/** + * A small library of long noise buffers. Voices take a random slice at a random + * playback rate, which is what keeps automatic fire from sounding like a loop + * while costing nothing at runtime. + */ +export class NoiseBank { + constructor(actx, rng, seconds = 2.2) { + this.actx = actx; + this.buffers = {}; + for (const kind of ['white', 'pink', 'brown', 'crackle']) { + const len = Math.max(1, Math.floor(actx.sampleRate * seconds)); + const buf = actx.createBuffer(2, len, actx.sampleRate); + // Two decorrelated channels so wide beds get real stereo width. + fillNoise(buf.getChannelData(0), kind, rng); + fillNoise(buf.getChannelData(1), kind, rng); + this.buffers[kind] = buf; + } + } + + /** A one-shot source reading from a random offset. Caller starts/stops it. */ + source(kind, rng, rate = 1, loop = false) { + const src = this.actx.createBufferSource(); + const buf = this.buffers[kind] ?? this.buffers.white; + src.buffer = buf; + src.playbackRate.value = rate; + src.loop = loop; + if (loop) { + src.loopStart = 0; + src.loopEnd = buf.duration; + } + src._offset = rng ? rng.range(0, buf.duration * 0.7) : 0; + return src; + } + + dispose() { + this.buffers = {}; + } +} + +/* ------------------------------------------------------------------ */ +/* Envelopes */ +/* ------------------------------------------------------------------ */ + +const FLOOR = 1e-4; + +/** + * Guard: eleven subsystems can reach audio, and one NaN position turns into a + * non-finite schedule time that throws inside Web Audio. Envelopes refuse + * garbage instead of taking the whole frame down with them. + */ +function ok(t0, peak) { + return Number.isFinite(t0) && Number.isFinite(peak) && t0 >= 0; +} + +/** Instant-attack exponential decay — the workhorse for transients. */ +export function hit(param, t0, peak, decay) { + if (!ok(t0, peak)) return t0; + const p = Math.max(peak, FLOOR * 4); + param.setValueAtTime(p, t0); + param.exponentialRampToValueAtTime(FLOOR, t0 + decay); + param.setValueAtTime(0, t0 + decay + 0.002); + return t0 + decay + 0.002; +} + +/** Attack/decay with an exponential contour on both halves. */ +export function ad(param, t0, peak, attack, decay) { + if (!ok(t0, peak)) return t0; + const p = Math.max(peak, FLOOR * 4); + param.setValueAtTime(FLOOR, t0); + if (attack > 0.0008) param.exponentialRampToValueAtTime(p, t0 + attack); + else param.setValueAtTime(p, t0 + 0.0004); + param.exponentialRampToValueAtTime(FLOOR, t0 + attack + decay); + param.setValueAtTime(0, t0 + attack + decay + 0.002); + return t0 + attack + decay + 0.002; +} + +/** Full ADSR for sustained material (voices, wind gusts). */ +export function adsr(param, t0, peak, a, d, s, sustainLevel, r) { + if (!ok(t0, peak)) return t0; + const p = Math.max(peak, FLOOR * 4); + const sl = Math.max(p * sustainLevel, FLOOR * 4); + param.setValueAtTime(FLOOR, t0); + param.exponentialRampToValueAtTime(p, t0 + a); + param.exponentialRampToValueAtTime(sl, t0 + a + d); + param.setValueAtTime(sl, t0 + a + d + s); + param.exponentialRampToValueAtTime(FLOOR, t0 + a + d + s + r); + param.setValueAtTime(0, t0 + a + d + s + r + 0.002); + return t0 + a + d + s + r + 0.002; +} + +/** Exponential parameter sweep, guarded against zero/negative targets. */ +export function sweep(param, t0, from, to, dur) { + if (!ok(t0, from) || !Number.isFinite(to) || !Number.isFinite(dur)) return t0; + param.setValueAtTime(Math.max(from, 1e-3), t0); + param.exponentialRampToValueAtTime(Math.max(to, 1e-3), t0 + Math.max(dur, 0.001)); + return t0 + dur; +} + +/* ------------------------------------------------------------------ */ +/* Nodes */ +/* ------------------------------------------------------------------ */ + +export function biquad(actx, type, freq, Q = 0.7071, gainDb = 0) { + const f = actx.createBiquadFilter(); + f.type = type; + f.frequency.value = clamp(freq, 10, Math.min(20000, actx.sampleRate * 0.48)); + f.Q.value = Q; + f.gain.value = gainDb; + return f; +} + +export function gain(actx, value = 1) { + const g = actx.createGain(); + g.gain.value = value; + return g; +} + +export function osc(actx, type, freq, detune = 0) { + const o = actx.createOscillator(); + o.type = type; + o.frequency.value = freq; + o.detune.value = detune; + return o; +} + +/** Connect a list of nodes head-to-tail; returns the last one. */ +export function series(...nodes) { + for (let i = 0; i < nodes.length - 1; i++) nodes[i].connect(nodes[i + 1]); + return nodes[nodes.length - 1]; +} + +/* ------------------------------------------------------------------ */ +/* Waveshaping */ +/* ------------------------------------------------------------------ */ + +const CURVE_CACHE = new Map(); + +/** + * tanh-style saturation. `drive` 0 is nearly clean, 20 is aggressive. + * `asym` adds even harmonics — that is what gives a muzzle blast its "chuff" + * rather than a symmetric fuzz-pedal buzz. + */ +export function saturationCurve(drive = 4, asym = 0) { + const key = `${drive.toFixed(2)}:${asym.toFixed(2)}`; + let c = CURVE_CACHE.get(key); + if (c) return c; + const n = 2048; + c = new Float32Array(n); + const k = 1 + drive; + const norm = Math.tanh(k); + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * 2 - 1; + const xa = x + asym * x * x * (x < 0 ? -1 : 1) * 0.5; + c[i] = Math.tanh(k * xa) / norm; + } + CURVE_CACHE.set(key, c); + return c; +} + +/** Hard-knee-free soft clip for the very last stage of the master bus. */ +export function limiterCurve() { + let c = CURVE_CACHE.get('__limit'); + if (c) return c; + const n = 4096; + c = new Float32Array(n); + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * 2 - 1; + // Cubic soft clip up to 0.66, then tanh — transparent below -6 dBFS. + const a = Math.abs(x); + let y; + if (a < 0.66) y = x; + else y = Math.sign(x) * (0.66 + (1 - 0.66) * Math.tanh((a - 0.66) / (1 - 0.66))); + c[i] = y * 0.985; + } + CURVE_CACHE.set('__limit', c); + return c; +} + +export function shaper(actx, curve, oversample = '2x') { + const w = actx.createWaveShaper(); + w.curve = curve; + w.oversample = oversample; + return w; +} + +/* ------------------------------------------------------------------ */ +/* Resonators */ +/* ------------------------------------------------------------------ */ + +/** + * Excite a bank of high-Q bandpasses with a short noise burst: the cheapest + * convincing model of a struck metal/glass/wood object. Returns the sum node. + * `partials` = [{ f, q, g, decay }] + */ +export function struckResonator(actx, bank, rng, t0, partials, exciteDur = 0.004, exciteKind = 'white') { + const out = gain(actx, 1); + const src = bank.source(exciteKind, rng, rng.range(0.85, 1.2)); + const exc = gain(actx, 0); + hit(exc.gain, t0, 1, exciteDur); + src.connect(exc); + for (const p of partials) { + const q = p.q ?? 22; + const bp = biquad(actx, 'bandpass', p.f, q); + const vg = gain(actx, 0); + // A bandpass only passes f/Q of the excitation's bandwidth, so a high-Q + // partial fed a 2 ms noise burst is ~20 dB quieter than a low-Q one. Without + // this makeup every metallic sound in the game sits inaudibly low in the mix. + hit(vg.gain, t0, (p.g ?? 0.5) * Math.sqrt(q) * 0.85, p.decay ?? 0.12); + exc.connect(bp); + bp.connect(vg); + vg.connect(out); + } + src.start(t0, src._offset, exciteDur + 0.02); + return out; +} + +/* ------------------------------------------------------------------ */ +/* Misc */ +/* ------------------------------------------------------------------ */ + +export function clamp(v, lo, hi) { + return v < lo ? lo : v > hi ? hi : v; +} + +export function lerp(a, b, t) { + return a + (b - a) * t; +} + +export function dbToGain(db) { + return Math.pow(10, db / 20); +} + +/** Semitone ratio — pitch jitter is expressed musically, not as a raw factor. */ +export function semis(n) { + return Math.pow(2, n / 12); +} + +/** Air absorption: how much high end survives `dist` metres of atmosphere. */ +export function airCutoff(dist) { + // ~ -1.5 dB/100 m at 1 kHz, far more at 8 kHz. Tuned by ear against real + // long-range gunfire recordings: 50 m still bright, 300 m is all boom. + return clamp(20500 / (1 + dist * 0.055), 260, 20000); +} diff --git a/src/lib/cod/audio/foley.js b/src/lib/cod/audio/foley.js new file mode 100644 index 00000000..acdc134c --- /dev/null +++ b/src/lib/cod/audio/foley.js @@ -0,0 +1,789 @@ +/** + * AUDIO / FOLEY + * + * Impacts, footsteps, shell casings, reload mechanics, explosions, body falls + * and UI. Everything is keyed off the twelve surface names in ARCHITECTURE.md + * so physics, FX, decals and audio always agree about what was hit. + * + * The recurring recipe for a physical impact is: + * transient (contact) + body (mass) + texture (material) + debris + * Which of those four dominates is what makes concrete sound like concrete and + * flesh sound like flesh; the envelope shapes matter far more than the exact + * filter frequencies. + */ + +import { + ad, biquad, clamp, gain, hit, lerp, osc, saturationCurve, semis, series, shaper, + struckResonator, sweep, +} from './dsp.js'; + +/** + * Per-surface impact recipe. + * bodyF/bodyDecay the mass thump + * ring high-Q partials (metal, glass, wood) or null + * tex { kind, f, q, decay, level } the material texture burst + * grains number of debris grains + * bright transient level 0..1 + * wet reverb send + */ +const IMPACT = { + concrete: { + bright: 0.85, bodyF: 180, bodyDecay: 0.05, ring: null, + tex: { kind: 'white', f: 2600, q: 0.9, decay: 0.075, level: 0.75 }, + dust: { f: 1200, decay: 0.3, level: 0.16 }, grains: 5, wet: 0.4, + }, + plaster: { + bright: 0.7, bodyF: 220, bodyDecay: 0.035, ring: null, + tex: { kind: 'white', f: 1900, q: 0.8, decay: 0.05, level: 0.6 }, + dust: { f: 900, decay: 0.42, level: 0.26 }, grains: 6, wet: 0.42, + }, + metal: { + bright: 1.0, bodyF: 150, bodyDecay: 0.035, + ring: [{ f: 1750, q: 34, g: 0.42, decay: 0.28 }, { f: 3120, q: 26, g: 0.3, decay: 0.17 }, + { f: 5400, q: 18, g: 0.18, decay: 0.09 }, { f: 8100, q: 12, g: 0.09, decay: 0.05 }], + tex: { kind: 'white', f: 5200, q: 1.2, decay: 0.03, level: 0.5 }, + dust: null, grains: 3, wet: 0.5, + }, + wood: { + bright: 0.6, bodyF: 320, bodyDecay: 0.055, + ring: [{ f: 420, q: 14, g: 0.35, decay: 0.11 }, { f: 780, q: 11, g: 0.2, decay: 0.07 }, + { f: 1520, q: 8, g: 0.1, decay: 0.04 }], + tex: { kind: 'white', f: 1500, q: 1.0, decay: 0.045, level: 0.45 }, + dust: null, grains: 5, wet: 0.32, + }, + dirt: { + bright: 0.25, bodyF: 120, bodyDecay: 0.07, ring: null, + tex: { kind: 'brown', f: 700, q: 0.7, decay: 0.09, level: 0.7 }, + dust: { f: 600, decay: 0.34, level: 0.2 }, grains: 4, wet: 0.2, + }, + sand: { + bright: 0.18, bodyF: 105, bodyDecay: 0.055, ring: null, + tex: { kind: 'white', f: 1500, q: 0.5, decay: 0.13, level: 0.5 }, + dust: { f: 1000, decay: 0.4, level: 0.24 }, grains: 3, wet: 0.16, + }, + glass: { + bright: 1.0, bodyF: 500, bodyDecay: 0.02, + ring: [{ f: 3400, q: 40, g: 0.34, decay: 0.13 }, { f: 5300, q: 34, g: 0.26, decay: 0.1 }, + { f: 7900, q: 26, g: 0.2, decay: 0.07 }, { f: 11200, q: 18, g: 0.12, decay: 0.05 }], + tex: { kind: 'crackle', f: 6000, q: 0.9, decay: 0.28, level: 0.6 }, + dust: null, grains: 11, wet: 0.46, + }, + water: { + bright: 0.3, bodyF: 260, bodyDecay: 0.03, ring: null, + tex: { kind: 'white', f: 1800, q: 0.8, decay: 0.14, level: 0.75, rise: true }, + dust: null, grains: 4, wet: 0.3, bubbles: true, + }, + foliage: { + bright: 0.25, bodyF: 380, bodyDecay: 0.02, ring: null, + tex: { kind: 'crackle', f: 2600, q: 0.8, decay: 0.16, level: 0.6 }, + dust: null, grains: 7, wet: 0.22, + }, + fabric: { + bright: 0.2, bodyF: 150, bodyDecay: 0.045, ring: null, + tex: { kind: 'white', f: 900, q: 0.6, decay: 0.06, level: 0.4 }, + dust: { f: 700, decay: 0.2, level: 0.1 }, grains: 2, wet: 0.18, + }, + flesh: { + bright: 0.35, bodyF: 128, bodyDecay: 0.06, ring: null, + tex: { kind: 'white', f: 620, q: 1.4, decay: 0.055, level: 0.62 }, + dust: null, grains: 3, wet: 0.24, wet_squelch: true, + }, + rubber: { + bright: 0.3, bodyF: 190, bodyDecay: 0.04, + ring: [{ f: 260, q: 9, g: 0.2, decay: 0.06 }], + tex: { kind: 'white', f: 1100, q: 0.9, decay: 0.03, level: 0.3 }, + dust: null, grains: 1, wet: 0.2, + }, +}; + +/* ------------------------------------------------------------------ */ +/* Bullet impacts */ +/* ------------------------------------------------------------------ */ + +/** + * @param {object} o { when, surface, energy (0..1.5), distance } + */ +export function surfaceImpact(actx, bank, rng, o = {}) { + const t0 = o.when ?? actx.currentTime; + const s = IMPACT[o.surface] ?? IMPACT.concrete; + const e = clamp(o.energy ?? 1, 0.15, 1.6); + const jit = semis(rng.range(-2.5, 2.5)); + const out = gain(actx, 0.22); // VOICE TRIM + let end = t0 + 0.2; + + /* transient */ + if (s.bright > 0.05) { + const src = bank.source('white', rng, rng.range(0.9, 1.35)); + const hp = biquad(actx, 'highpass', 3000 * jit, 0.7); + const g = gain(actx, 0); + series(src, hp, g).connect(out); + hit(g.gain, t0, 0.55 * s.bright * e, rng.range(0.003, 0.008)); + src.start(t0, src._offset, 0.04); + } + + /* body */ + { + const b = osc(actx, 'sine', s.bodyF * jit); + const g = gain(actx, 0); + const drv = shaper(actx, saturationCurve(2.5, 0.4), '2x'); + b.connect(g); series(g, drv).connect(out); + sweep(b.frequency, t0, s.bodyF * jit * 1.6, s.bodyF * jit * 0.7, s.bodyDecay * 1.5); + ad(g.gain, t0, 0.5 * e, 0.0015, s.bodyDecay * rng.range(0.85, 1.2)); + b.start(t0); b.stop(t0 + s.bodyDecay * 2.2 + 0.02); + end = Math.max(end, t0 + s.bodyDecay * 2.2); + } + + /* material texture */ + { + const tx = s.tex; + const src = bank.source(tx.kind, rng, rng.range(0.8, 1.3)); + const bp = biquad(actx, 'bandpass', tx.f * jit, tx.q); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + if (tx.rise) sweep(bp.frequency, t0, tx.f * 0.4, tx.f * 2.2, tx.decay); + else sweep(bp.frequency, t0, tx.f * 1.5 * jit, tx.f * 0.6 * jit, tx.decay * 1.6); + ad(g.gain, t0, tx.level * e, tx.rise ? 0.008 : 0.0015, tx.decay * rng.range(0.85, 1.25)); + src.start(t0, src._offset, tx.decay * 3 + 0.05); + end = Math.max(end, t0 + tx.decay * 3); + } + + /* resonant ring (metal / glass / wood) */ + if (s.ring) { + const parts = []; + for (const p of s.ring) { + parts.push({ + f: p.f * semis(rng.range(-3, 3)), + q: p.q * rng.range(0.8, 1.25), + g: p.g * e, + decay: p.decay * rng.range(0.75, 1.3), + }); + } + const r = struckResonator(actx, bank, rng, t0, parts, 0.0035); + r.connect(out); + end = Math.max(end, t0 + 0.4); + } + + /* dust / powder cloud */ + if (s.dust) { + const src = bank.source('white', rng, rng.range(0.7, 1.1)); + const lp = biquad(actx, 'lowpass', s.dust.f, 0.8); + const g = gain(actx, 0); + series(src, lp, g).connect(out); + sweep(lp.frequency, t0, s.dust.f * 1.4, s.dust.f * 0.5, s.dust.decay); + ad(g.gain, t0, s.dust.level * e, 0.02, s.dust.decay * rng.range(0.8, 1.3)); + src.start(t0, src._offset, s.dust.decay * 2 + 0.05); + end = Math.max(end, t0 + s.dust.decay * 2); + } + + /* debris grains — chips, splinters, glass shards landing */ + const grains = Math.round(s.grains * clamp(e, 0.3, 1.4)); + for (let i = 0; i < grains; i++) { + const gt = t0 + rng.range(0.015, 0.06) + i * rng.range(0.01, 0.055); + const r = struckResonator(actx, bank, rng, gt, [ + { f: rng.range(1800, 9000), q: rng.range(12, 30), g: rng.range(0.02, 0.05) * e, decay: rng.range(0.01, 0.05) }, + ], 0.0018); + r.connect(out); + end = Math.max(end, gt + 0.08); + } + + /* water bubbles */ + if (s.bubbles) { + for (let i = 0; i < 4; i++) { + const bt = t0 + rng.range(0.02, 0.18); + const b = osc(actx, 'sine', rng.range(400, 1400)); + const g = gain(actx, 0); + b.connect(g); g.connect(out); + sweep(b.frequency, bt, rng.range(350, 700), rng.range(900, 2200), 0.05); + hit(g.gain, bt, rng.range(0.04, 0.1) * e, 0.05); + b.start(bt); b.stop(bt + 0.12); + end = Math.max(end, bt + 0.14); + } + } + + /* flesh squelch */ + if (s.wet_squelch) { + const src = bank.source('pink', rng, rng.range(0.7, 1.1)); + const bp = biquad(actx, 'bandpass', 380, 2.2); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + sweep(bp.frequency, t0, 260, 900, 0.09); + ad(g.gain, t0 + 0.004, 0.4 * e, 0.006, 0.1); + src.start(t0, src._offset, 0.25); + } + + return { node: out, end: end + 0.05, send: s.wet }; +} + +/* ------------------------------------------------------------------ */ +/* Footsteps */ +/* ------------------------------------------------------------------ */ + +/** Per-surface footstep character. */ +const STEP = { + concrete: { bodyF: 92, bodyDecay: 0.055, texKind: 'white', texF: 2100, texQ: 0.7, texDecay: 0.045, texLevel: 0.5, scuff: 0.35, grit: 4 }, + plaster: { bodyF: 100, bodyDecay: 0.05, texKind: 'white', texF: 1800, texQ: 0.7, texDecay: 0.05, texLevel: 0.45, scuff: 0.3, grit: 4 }, + metal: { bodyF: 120, bodyDecay: 0.05, texKind: 'white', texF: 3200, texQ: 1.0, texDecay: 0.04, texLevel: 0.5, scuff: 0.3, grit: 2, + ring: [{ f: 620, q: 16, g: 0.24, decay: 0.16 }, { f: 1480, q: 20, g: 0.16, decay: 0.11 }, { f: 2900, q: 14, g: 0.08, decay: 0.06 }] }, + wood: { bodyF: 110, bodyDecay: 0.06, texKind: 'white', texF: 1300, texQ: 0.8, texDecay: 0.04, texLevel: 0.4, scuff: 0.28, grit: 2, + ring: [{ f: 260, q: 12, g: 0.26, decay: 0.09 }, { f: 540, q: 9, g: 0.14, decay: 0.05 }] }, + dirt: { bodyF: 78, bodyDecay: 0.07, texKind: 'brown', texF: 620, texQ: 0.6, texDecay: 0.075, texLevel: 0.62, scuff: 0.45, grit: 6 }, + sand: { bodyF: 70, bodyDecay: 0.06, texKind: 'white', texF: 1500, texQ: 0.45, texDecay: 0.14, texLevel: 0.6, scuff: 0.7, grit: 3 }, + glass: { bodyF: 96, bodyDecay: 0.04, texKind: 'crackle', texF: 5200, texQ: 0.8, texDecay: 0.2, texLevel: 0.6, scuff: 0.3, grit: 9 }, + water: { bodyF: 88, bodyDecay: 0.045, texKind: 'white', texF: 1600, texQ: 0.7, texDecay: 0.17, texLevel: 0.8, scuff: 0.5, grit: 3, splash: true }, + foliage: { bodyF: 84, bodyDecay: 0.05, texKind: 'crackle', texF: 2400, texQ: 0.7, texDecay: 0.18, texLevel: 0.7, scuff: 0.5, grit: 6 }, + fabric: { bodyF: 82, bodyDecay: 0.05, texKind: 'white', texF: 800, texQ: 0.6, texDecay: 0.05, texLevel: 0.3, scuff: 0.35, grit: 0 }, + flesh: { bodyF: 86, bodyDecay: 0.055, texKind: 'white', texF: 520, texQ: 1.2, texDecay: 0.05, texLevel: 0.35, scuff: 0.2, grit: 0 }, + rubber: { bodyF: 96, bodyDecay: 0.04, texKind: 'white', texF: 1000, texQ: 0.8, texDecay: 0.03, texLevel: 0.28, scuff: 0.2, grit: 0 }, +}; + +/** + * @param {object} o { when, surface, gait: 'walk'|'run'|'sprint'|'crouch'|'land', + * level, gear (0..1), distance } + */ +export function footstep(actx, bank, rng, o = {}) { + const t0 = o.when ?? actx.currentTime; + const s = STEP[o.surface] ?? STEP.concrete; + const gait = o.gait ?? 'walk'; + const weight = gait === 'sprint' ? 1.25 : gait === 'run' ? 1.0 : gait === 'land' ? 1.7 : gait === 'crouch' ? 0.42 : 0.62; + const lvl = (o.level ?? 1) * weight; + const jit = semis(rng.range(-3, 3)); + const out = gain(actx, 0.32); // VOICE TRIM + let end = t0 + 0.3; + + /* heel/toe transient — two contacts, milliseconds apart, is what reads as a + foot rather than a hammer. */ + const contacts = gait === 'land' ? 1 : 2; + for (let c = 0; c < contacts; c++) { + const ct = t0 + (c === 0 ? 0 : rng.range(0.012, 0.032)); + const cl = c === 0 ? 1 : rng.range(0.35, 0.6); + + const b = osc(actx, 'sine', s.bodyF * jit); + const bg = gain(actx, 0); + const drv = shaper(actx, saturationCurve(1.8, 0.5), '2x'); + b.connect(bg); series(bg, drv).connect(out); + sweep(b.frequency, ct, s.bodyF * jit * 1.7, s.bodyF * jit * 0.75, s.bodyDecay * 1.4); + ad(bg.gain, ct, 0.42 * lvl * cl, 0.0025, s.bodyDecay * rng.range(0.85, 1.2)); + b.start(ct); b.stop(ct + s.bodyDecay * 2.4 + 0.02); + + const src = bank.source(s.texKind, rng, rng.range(0.8, 1.25)); + const bp = biquad(actx, 'bandpass', s.texF * jit, s.texQ); + const tg = gain(actx, 0); + series(src, bp, tg).connect(out); + sweep(bp.frequency, ct, s.texF * 1.4 * jit, s.texF * 0.55 * jit, s.texDecay * 2); + ad(tg.gain, ct, s.texLevel * lvl * cl, 0.002, s.texDecay * rng.range(0.8, 1.3)); + src.start(ct, src._offset, s.texDecay * 3 + 0.05); + end = Math.max(end, ct + s.texDecay * 3); + + if (s.ring && c === 0) { + const parts = s.ring.map((p) => ({ + f: p.f * semis(rng.range(-2, 2)), q: p.q * rng.range(0.85, 1.2), + g: p.g * lvl, decay: p.decay * rng.range(0.8, 1.25), + })); + struckResonator(actx, bank, rng, ct, parts, 0.003).connect(out); + end = Math.max(end, ct + 0.3); + } + } + + /* scuff — the slide of the sole, longer when running */ + if (s.scuff > 0.05) { + const st = t0 + rng.range(0.01, 0.04); + const dur = (gait === 'sprint' ? 0.13 : gait === 'run' ? 0.1 : 0.07) * rng.range(0.8, 1.3); + const src = bank.source('white', rng, rng.range(0.85, 1.2)); + const bp = biquad(actx, 'bandpass', rng.range(2200, 4200), 0.8); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + sweep(bp.frequency, st, rng.range(2800, 4600), rng.range(1200, 2000), dur); + ad(g.gain, st, s.scuff * lvl * 0.5, 0.012, dur); + src.start(st, src._offset, dur * 2); + end = Math.max(end, st + dur * 2); + } + + /* grit grains */ + for (let i = 0; i < s.grit; i++) { + if (rng.float() > 0.55) continue; + const gt = t0 + rng.range(0.004, 0.09); + struckResonator(actx, bank, rng, gt, [ + { f: rng.range(2400, 9000), q: rng.range(10, 26), g: rng.range(0.015, 0.05) * lvl, decay: rng.range(0.008, 0.03) }, + ], 0.0015).connect(out); + } + + /* water splash */ + if (s.splash) { + const src = bank.source('white', rng, rng.range(0.9, 1.2)); + const bp = biquad(actx, 'bandpass', 900, 0.7); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + sweep(bp.frequency, t0, 700, 3400, 0.16); + ad(g.gain, t0 + 0.006, 0.45 * lvl, 0.01, 0.2); + src.start(t0, src._offset, 0.4); + end = Math.max(end, t0 + 0.42); + } + + /* gear: sling swivels, mag pouches, buckles — only when moving fast */ + const gear = (o.gear ?? (gait === 'sprint' ? 1 : gait === 'run' ? 0.7 : gait === 'land' ? 0.9 : 0.25)); + if (gear > 0.05) { + const n = 1 + ((rng.u32() % 3) | 0); + for (let i = 0; i < n; i++) { + const gt = t0 + rng.range(0.005, 0.11); + struckResonator(actx, bank, rng, gt, [ + { f: rng.range(1600, 4200), q: rng.range(18, 40), g: rng.range(0.03, 0.1) * gear * lvl, decay: rng.range(0.03, 0.12) }, + { f: rng.range(4200, 8000), q: rng.range(12, 26), g: rng.range(0.01, 0.04) * gear * lvl, decay: rng.range(0.01, 0.05) }, + ], 0.002).connect(out); + end = Math.max(end, gt + 0.18); + } + // Cloth/webbing rustle. + const src = bank.source('white', rng, rng.range(0.7, 1.1)); + const bp = biquad(actx, 'bandpass', rng.range(1400, 2600), 0.6); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + ad(g.gain, t0, 0.09 * gear * lvl, 0.02, 0.13); + src.start(t0, src._offset, 0.3); + } + + return { node: out, end: end + 0.05, send: 0.3 }; +} + +/** Cloth movement, used for stance changes and ADS. */ +export function cloth(actx, bank, rng, o = {}) { + const t0 = o.when ?? actx.currentTime; + const lvl = o.level ?? 1; + const out = gain(actx, 1); + const dur = rng.range(0.13, 0.26); + const src = bank.source('white', rng, rng.range(0.7, 1.15)); + const bp = biquad(actx, 'bandpass', rng.range(1300, 2400), 0.55); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + sweep(bp.frequency, t0, rng.range(1000, 1600), rng.range(2200, 3400), dur); + ad(g.gain, t0, 0.3 * lvl, 0.03, dur); + src.start(t0, src._offset, dur * 2); + if (rng.float() < 0.6) { + struckResonator(actx, bank, rng, t0 + rng.range(0.02, 0.1), [ + { f: rng.range(2200, 5200), q: 26, g: 0.05 * lvl, decay: rng.range(0.03, 0.09) }, + ], 0.002).connect(out); + } + return { node: out, end: t0 + dur * 2 + 0.15, send: 0.2 }; +} + +/* ------------------------------------------------------------------ */ +/* Shell casings */ +/* ------------------------------------------------------------------ */ + +/** + * A casing bounces 2–4 times with shortening intervals and then rolls. Brass on + * concrete is one of the most recognisable sounds in a shooter; the trick is + * that each bounce is a *different* set of partials because the shell lands on a + * different part of itself. + */ +export function shellCasing(actx, bank, rng, o = {}) { + const t0 = (o.when ?? actx.currentTime) + (o.flight ?? rng.range(0.28, 0.52)); + const surface = o.surface ?? 'concrete'; + const hard = surface === 'metal' || surface === 'concrete' || surface === 'glass' || surface === 'plaster'; + const soft = surface === 'dirt' || surface === 'sand' || surface === 'foliage' || surface === 'fabric'; + const out = gain(actx, 1); + const base = rng.range(2650, 4200); + let t = t0; + let amp = (o.level ?? 1) * (soft ? 0.35 : 1) * rng.range(0.8, 1.1); + const bounces = soft ? 1 : 2 + ((rng.u32() % 3) | 0); + let end = t0; + for (let i = 0; i < bounces; i++) { + const detune = semis(rng.range(-4, 4)); + struckResonator(actx, bank, rng, t, [ + { f: base * detune, q: rng.range(30, 60), g: 0.95 * amp, decay: rng.range(0.05, 0.13) * (hard ? 1 : 0.5) }, + { f: base * detune * 1.87, q: rng.range(24, 44), g: 0.58 * amp, decay: rng.range(0.03, 0.08) }, + { f: base * detune * 3.1, q: rng.range(16, 30), g: 0.3 * amp, decay: rng.range(0.015, 0.04) }, + { f: base * detune * 0.42, q: 12, g: 0.2 * amp, decay: 0.03 }, + ], 0.0015).connect(out); + end = t + 0.2; + t += rng.range(0.045, 0.13) * (i === 0 ? 1 : 0.6); + amp *= rng.range(0.38, 0.62); + if (amp < 0.03) break; + } + // Roll: a stream of very quiet, very short pings. + if (hard && rng.float() < 0.55) { + const rolls = 3 + ((rng.u32() % 5) | 0); + for (let i = 0; i < rolls; i++) { + const rt = t + i * rng.range(0.018, 0.05); + struckResonator(actx, bank, rng, rt, [ + { f: base * semis(rng.range(-5, 5)), q: rng.range(20, 44), g: rng.range(0.03, 0.09) * (o.level ?? 1), decay: rng.range(0.01, 0.03) }, + ], 0.0012).connect(out); + end = Math.max(end, rt + 0.06); + } + } + return { node: out, end: end + 0.05, send: 0.42 }; +} + +/* ------------------------------------------------------------------ */ +/* Reload foley */ +/* ------------------------------------------------------------------ */ + +/** + * Reload mechanics, one call per `weapon:reload` phase. Keeping the phases as + * separate one-shots (instead of one long sound) is what lets the audio stay + * locked to the animation whatever its length. + */ +/** The four phases are wildly different in energy; level them per phase. */ +const RELOAD_TRIM = { start: 3.2, magout: 3.0, magin: 1.0, end: 1.5 }; + +export function reloadPhase(actx, bank, rng, phase, o = {}) { + const t0 = o.when ?? actx.currentTime; + const heavy = o.heavy ?? 1; // LMG/shotgun = heavier hardware + const out = gain(actx, 0.42 * (RELOAD_TRIM[phase] ?? 1.5)); // VOICE TRIM + let end = t0 + 0.3; + const metal = (t, parts, exc = 0.0025) => { + struckResonator(actx, bank, rng, t, parts, exc).connect(out); + end = Math.max(end, t + 0.35); + }; + const rustle = (t, dur, level, f) => { + const src = bank.source('white', rng, rng.range(0.8, 1.2)); + const bp = biquad(actx, 'bandpass', f, 0.6); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + ad(g.gain, t, level, 0.02, dur); + src.start(t, src._offset, dur * 2 + 0.05); + end = Math.max(end, t + dur * 2); + }; + + switch (phase) { + case 'start': + // Hand leaves the grip, palm slaps the magwell, mag catch is pressed. + rustle(t0, 0.18, 0.2, rng.range(1400, 2200)); + metal(t0 + rng.range(0.04, 0.08), [ + { f: 2450 * semis(rng.range(-2, 2)), q: 30, g: 0.55 * heavy, decay: 0.03 }, + { f: 5100, q: 18, g: 0.25, decay: 0.016 }, + { f: 780, q: 10, g: 0.3 * heavy, decay: 0.045 }, + ]); + break; + + case 'magout': { + // Spring release, mag scrapes out of the well, then plastic hits the deck. + metal(t0, [ + { f: 1650 * semis(rng.range(-2, 2)), q: 24, g: 0.65 * heavy, decay: 0.05 }, + { f: 3400, q: 16, g: 0.35, decay: 0.025 }, + ]); + const st = t0 + 0.03; + const src = bank.source('white', rng, rng.range(0.9, 1.3)); + const bp = biquad(actx, 'bandpass', 3200, 1.1); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + sweep(bp.frequency, st, 4200, 1600, 0.12); + ad(g.gain, st, 0.2, 0.01, 0.12); + src.start(st, src._offset, 0.3); + // Empty magazine hitting the ground — polymer, not metal. + const dt = t0 + rng.range(0.16, 0.3); + metal(dt, [ + { f: 480 * semis(rng.range(-3, 3)), q: 9, g: 0.2, decay: 0.05 }, + { f: 1180, q: 7, g: 0.11, decay: 0.03 }, + { f: 2600, q: 5, g: 0.05, decay: 0.015 }, + ], 0.004); + end = Math.max(end, dt + 0.3); + break; + } + + case 'magin': { + // Fresh mag guided in, seated with a palm strike: a low thunk plus a sharp + // latch click. The thunk needs real low end or it feels weightless. + rustle(t0, 0.12, 0.16, rng.range(1200, 2000)); + const it = t0 + rng.range(0.05, 0.1); + const b = osc(actx, 'sine', 190 * heavy); + const bg = gain(actx, 0); + const drv = shaper(actx, saturationCurve(3, 0.5), '2x'); + b.connect(bg); series(bg, drv).connect(out); + sweep(b.frequency, it, 230 * heavy, 110 * heavy, 0.06); + ad(bg.gain, it, 0.4 * heavy, 0.002, 0.055); + b.start(it); b.stop(it + 0.16); + metal(it, [ + { f: 1250 * semis(rng.range(-2, 2)), q: 20, g: 0.3 * heavy, decay: 0.06 }, + { f: 2800, q: 26, g: 0.18, decay: 0.03 }, + { f: 6200, q: 14, g: 0.07, decay: 0.012 }, + ], 0.003); + metal(it + rng.range(0.02, 0.05), [ + { f: 3600, q: 34, g: 0.16, decay: 0.02 }, + { f: 7400, q: 20, g: 0.07, decay: 0.01 }, + ], 0.0015); + break; + } + + case 'end': + default: { + // Charging handle: scrape, hard rearward stop, spring-driven return, and + // the bolt slamming into battery. + const st = t0; + const src = bank.source('white', rng, rng.range(0.9, 1.25)); + const bp = biquad(actx, 'bandpass', 2600, 1.6); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + sweep(bp.frequency, st, 1800, 4200, 0.07); + ad(g.gain, st, 0.24, 0.008, 0.07); + src.start(st, src._offset, 0.2); + metal(st + 0.06, [ + { f: 1450 * semis(rng.range(-2, 2)), q: 22, g: 0.3 * heavy, decay: 0.05 }, + { f: 3100, q: 18, g: 0.16, decay: 0.022 }, + ]); + // Spring ring — the metallic "zing" behind the clack. + metal(st + 0.065, [ + { f: 4900 * semis(rng.range(-3, 3)), q: 46, g: 0.09, decay: 0.16 }, + { f: 7200, q: 38, g: 0.05, decay: 0.1 }, + ], 0.002); + const bt = st + rng.range(0.1, 0.15); + const b = osc(actx, 'sine', 150 * heavy); + const bg = gain(actx, 0); + b.connect(bg); bg.connect(out); + sweep(b.frequency, bt, 200 * heavy, 90 * heavy, 0.05); + ad(bg.gain, bt, 0.38 * heavy, 0.0015, 0.05); + b.start(bt); b.stop(bt + 0.14); + metal(bt, [ + { f: 1750, q: 20, g: 0.34 * heavy, decay: 0.05 }, + { f: 3900, q: 15, g: 0.15, decay: 0.02 }, + { f: 8200, q: 10, g: 0.05, decay: 0.008 }, + ], 0.0035); + break; + } + } + return { node: out, end: end + 0.05, send: 0.3 }; +} + +/* ------------------------------------------------------------------ */ +/* Explosions */ +/* ------------------------------------------------------------------ */ + +/** + * @param {object} o { when, distance, radius, level } + * Near: a violent transient, a huge sub sweep and a bright shrapnel spatter. + * Far: almost no transient, a long rolling low rumble, and a big wet tail. + */ +export function explosion(actx, bank, rng, o = {}) { + const t0 = o.when ?? actx.currentTime; + const dist = Math.max(0, o.distance ?? 0); + const size = clamp((o.radius ?? 6) / 6, 0.5, 2.4); + const near = clamp(1 - dist / 70, 0, 1); + const far = 1 - near; + const lvl = (o.level ?? 1) * size; + const out = gain(actx, 0.42); // VOICE TRIM + let end = t0 + 1; + + /* detonation transient */ + if (near > 0.05) { + const src = bank.source('white', rng, rng.range(0.9, 1.2)); + const hp = biquad(actx, 'highpass', 1800, 0.6); + const drv = shaper(actx, saturationCurve(14, 0.7), '4x'); + const g = gain(actx, 0); + series(src, hp, drv, g).connect(out); + hit(g.gain, t0, 0.85 * near * lvl, 0.02); + src.start(t0, src._offset, 0.1); + } + + /* sub-bass impact: the thing you feel in your chest */ + { + const s = osc(actx, 'sine', 110); + const s2 = osc(actx, 'triangle', 62); + const g = gain(actx, 0); + const drv = shaper(actx, saturationCurve(4, 0.6), '2x'); + const lp = biquad(actx, 'lowpass', 220, 0.9); + s.connect(g); s2.connect(g); + series(g, drv, lp).connect(out); + const subDur = (0.55 + size * 0.35) * rng.range(0.9, 1.15); + sweep(s.frequency, t0, 130 * size, 26, subDur); + sweep(s2.frequency, t0, 74 * size, 21, subDur * 1.2); + ad(g.gain, t0, 1.0 * lvl * (0.55 + near * 0.6), 0.008 + far * 0.05, subDur); + s.start(t0); s2.start(t0); + s.stop(t0 + subDur * 1.6); s2.stop(t0 + subDur * 1.6); + end = Math.max(end, t0 + subDur * 1.6); + } + + /* blast body: broadband noise under a fast-falling lowpass */ + { + const dur = (0.45 + size * 0.5) * (1 + far * 1.8); + const src = bank.source('brown', rng, rng.range(0.6, 1.1)); + const lp = biquad(actx, 'lowpass', 6000, 0.8); + const drv = shaper(actx, saturationCurve(6, 0.5), '2x'); + const g = gain(actx, 0); + series(src, lp, drv, g).connect(out); + sweep(lp.frequency, t0, lerp(7000, 700, far), lerp(260, 130, far), dur); + ad(g.gain, t0, 0.8 * lvl, 0.01 + far * 0.06, dur); + src.start(t0, src._offset, dur * 1.4 + 0.1); + end = Math.max(end, t0 + dur * 1.4); + } + + /* debris / shrapnel: grains scattered over the following second */ + const grains = Math.round(lerp(26, 4, far) * size); + for (let i = 0; i < grains; i++) { + const gt = t0 + rng.range(0.02, 0.9) * rng.range(0.3, 1); + struckResonator(actx, bank, rng, gt, [ + { f: rng.range(700, 7000), q: rng.range(8, 32), g: rng.range(0.02, 0.09) * near * lvl, decay: rng.range(0.01, 0.09) }, + ], 0.002).connect(out); + end = Math.max(end, gt + 0.15); + } + + /* dust and settling */ + { + const dur = 1.0 + size * 0.8; + const src = bank.source('pink', rng, rng.range(0.5, 0.9)); + const lp = biquad(actx, 'lowpass', 1400, 0.7); + const g = gain(actx, 0); + series(src, lp, g).connect(out); + sweep(lp.frequency, t0, 1600, 320, dur); + ad(g.gain, t0 + 0.05, 0.2 * lvl * (0.4 + near * 0.6), 0.12, dur); + src.start(t0 + 0.05, src._offset, dur * 1.3); + end = Math.max(end, t0 + dur * 1.3); + } + + return { node: out, end: end + 0.1, send: 0.85 + far * 0.5 }; +} + +/** A body hitting the ground: mass, gear, and a wet slap. */ +export function bodyFall(actx, bank, rng, o = {}) { + const t0 = o.when ?? actx.currentTime; + const lvl = o.level ?? 1; + const out = gain(actx, 0.4); // VOICE TRIM + const b = osc(actx, 'sine', 74); + const bg = gain(actx, 0); + const drv = shaper(actx, saturationCurve(2.2, 0.55), '2x'); + b.connect(bg); series(bg, drv).connect(out); + sweep(b.frequency, t0, 96, 44, 0.12); + ad(bg.gain, t0, 0.6 * lvl, 0.004, 0.13); + b.start(t0); b.stop(t0 + 0.35); + + const src = bank.source('white', rng, rng.range(0.7, 1.1)); + const lp = biquad(actx, 'lowpass', 900, 0.8); + const g = gain(actx, 0); + series(src, lp, g).connect(out); + ad(g.gain, t0, 0.35 * lvl, 0.006, 0.16); + src.start(t0, src._offset, 0.4); + + for (let i = 0; i < 5; i++) { + const gt = t0 + rng.range(0.005, 0.26); + struckResonator(actx, bank, rng, gt, [ + { f: rng.range(1500, 5200), q: rng.range(16, 40), g: rng.range(0.03, 0.09) * lvl, decay: rng.range(0.02, 0.1) }, + ], 0.002).connect(out); + } + return { node: out, end: t0 + 0.6, send: 0.4 }; +} + +/* ------------------------------------------------------------------ */ +/* UI */ +/* ------------------------------------------------------------------ */ + +/** Non-diegetic feedback. Short, dry, and deliberately synthetic. */ +export function uiSound(actx, bank, rng, kind, o = {}) { + const t0 = o.when ?? actx.currentTime; + const out = gain(actx, 1); + const lvl = o.level ?? 1; + switch (kind) { + case 'hitmarker': { + const o1 = osc(actx, 'square', 2400); + const g = gain(actx, 0); + const lp = biquad(actx, 'lowpass', 5200, 0.7); + o1.connect(g); series(g, lp).connect(out); + hit(g.gain, t0, 0.55 * lvl, 0.022); + o1.start(t0); o1.stop(t0 + 0.06); + break; + } + case 'headshot': { + const o1 = osc(actx, 'square', 3200); + const o2 = osc(actx, 'square', 4800); + const g = gain(actx, 0); + o1.connect(g); o2.connect(g); g.connect(out); + hit(g.gain, t0, 0.34 * lvl, 0.05); + o1.start(t0); o2.start(t0 + 0.03); + o1.stop(t0 + 0.12); o2.stop(t0 + 0.14); + break; + } + case 'kill': { + for (let i = 0; i < 3; i++) { + const o1 = osc(actx, 'triangle', 900 * Math.pow(1.5, i)); + const g = gain(actx, 0); + o1.connect(g); g.connect(out); + ad(g.gain, t0 + i * 0.055, 0.3 * lvl, 0.004, 0.09); + o1.start(t0 + i * 0.055); o1.stop(t0 + i * 0.055 + 0.2); + } + break; + } + case 'damage': { + // Directional pain sting: a dissonant low pair, no melody. + const o1 = osc(actx, 'sawtooth', 180); + const o2 = osc(actx, 'sawtooth', 191); + const g = gain(actx, 0); + const lp = biquad(actx, 'lowpass', 1400, 1.4); + o1.connect(g); o2.connect(g); series(g, lp).connect(out); + ad(g.gain, t0, 0.42 * lvl, 0.004, 0.22); + o1.start(t0); o2.start(t0); + o1.stop(t0 + 0.4); o2.stop(t0 + 0.4); + break; + } + case 'armour': { + // Ceramic plate strike: brighter and harder than a flesh hitmarker. + const r = struckResonator(actx, bank, rng, t0, [ + { f: 3900, q: 30, g: 0.09 * lvl, decay: 0.045 }, + { f: 6400, q: 22, g: 0.05 * lvl, decay: 0.025 }, + ], 0.0015); + r.connect(out); + break; + } + case 'grenade_warn': { + // Three rising beeps — reads as "danger", not as a notification. + for (let i = 0; i < 3; i++) { + const bt = t0 + i * 0.14; + const o1 = osc(actx, 'square', 1150 * Math.pow(1.19, i)); + const lp = biquad(actx, 'lowpass', 4200, 0.8); + const g = gain(actx, 0); + o1.connect(g); series(g, lp).connect(out); + ad(g.gain, bt, 0.3 * lvl, 0.004, 0.07); + o1.start(bt); o1.stop(bt + 0.16); + } + break; + } + case 'regen': { + // Soft filtered swell: the "you are OK now" cue. Deliberately unpitched. + const src = bank.source('pink', rng, 0.9); + const bp = biquad(actx, 'bandpass', 700, 1.1); + const g = gain(actx, 0); + series(src, bp, g).connect(out); + sweep(bp.frequency, t0, 500, 1900, 0.5); + ad(g.gain, t0, 0.3 * lvl, 0.15, 0.45); + src.start(t0, src._offset, 0.9); + const o1 = osc(actx, 'sine', 420); + const og = gain(actx, 0); + o1.connect(og); og.connect(out); + sweep(o1.frequency, t0, 380, 640, 0.45); + ad(og.gain, t0, 0.12 * lvl, 0.14, 0.4); + o1.start(t0); o1.stop(t0 + 0.8); + break; + } + case 'lowhealth': { + const o1 = osc(actx, 'sine', 92); + const g = gain(actx, 0); + o1.connect(g); g.connect(out); + ad(g.gain, t0, 0.45 * lvl, 0.05, 0.55); + o1.start(t0); o1.stop(t0 + 0.9); + break; + } + default: { + const o1 = osc(actx, 'sine', 1200); + const g = gain(actx, 0); + o1.connect(g); g.connect(out); + hit(g.gain, t0, 0.26 * lvl, 0.03); + o1.start(t0); o1.stop(t0 + 0.08); + } + } + return { node: out, end: t0 + 0.9, send: 0 }; +} + +/** + * Heartbeat + laboured breathing for low health. Returned so the caller can + * schedule it repeatedly rather than looping a node. + */ +export function heartbeat(actx, bank, rng, o = {}) { + const t0 = o.when ?? actx.currentTime; + const lvl = o.level ?? 1; + const out = gain(actx, 0.5); // VOICE TRIM + for (let i = 0; i < 2; i++) { + const bt = t0 + i * 0.19; + const b = osc(actx, 'sine', 58); + const g = gain(actx, 0); + b.connect(g); g.connect(out); + sweep(b.frequency, bt, 72, 42, 0.1); + ad(g.gain, bt, (i === 0 ? 0.5 : 0.33) * lvl, 0.008, 0.11); + b.start(bt); b.stop(bt + 0.3); + } + return { node: out, end: t0 + 0.6, send: 0.1 }; +} diff --git a/src/lib/cod/audio/rng.js b/src/lib/cod/audio/rng.js new file mode 100644 index 00000000..a66b19ef --- /dev/null +++ b/src/lib/cod/audio/rng.js @@ -0,0 +1,95 @@ +/** + * Deterministic PRNG (xoshiro128**). Gameplay randomness — recoil patterns, + * spread, particle jitter, AI timing — must run through this so capture mode + * produces byte-identical frames. + */ +export class Rng { + constructor(seed = 0x9e3779b9) { + this.seed(seed); + } + + seed(s) { + // SplitMix32 to spread one 32-bit seed across the four state words. + let z = s >>> 0; + const next = () => { + z = (z + 0x9e3779b9) >>> 0; + let x = z; + x = Math.imul(x ^ (x >>> 16), 0x21f0aaad); + x = Math.imul(x ^ (x >>> 15), 0x735a2d97); + return (x ^ (x >>> 15)) >>> 0; + }; + this.s0 = next(); + this.s1 = next(); + this.s2 = next(); + this.s3 = next(); + return this; + } + + /** Uniform uint32. */ + u32() { + const rot = (x, k) => ((x << k) | (x >>> (32 - k))) >>> 0; + const result = Math.imul(rot(Math.imul(this.s1, 5) >>> 0, 7), 9) >>> 0; + const t = (this.s1 << 9) >>> 0; + this.s2 ^= this.s0; + this.s3 ^= this.s1; + this.s1 ^= this.s2; + this.s0 ^= this.s3; + this.s2 ^= t; + this.s3 = rot(this.s3, 11); + return result; + } + + /** Uniform [0,1). */ + float() { + return this.u32() / 4294967296; + } + + /** Uniform [min,max). */ + range(min, max) { + return min + (max - min) * this.float(); + } + + /** Uniform integer [min,max]. */ + int(min, max) { + return min + (this.u32() % (max - min + 1)); + } + + /** Uniform [-1,1]. */ + signed() { + return this.float() * 2 - 1; + } + + /** Standard normal via Box–Muller (one sample; the pair's second is cached). */ + gauss() { + if (this._spare !== undefined) { + const v = this._spare; + this._spare = undefined; + return v; + } + let u = 0; + while (u === 0) u = this.float(); + const r = Math.sqrt(-2 * Math.log(u)); + const th = 2 * Math.PI * this.float(); + this._spare = r * Math.sin(th); + return r * Math.cos(th); + } + + pick(arr) { + return arr[this.u32() % arr.length]; + } + + /** Point uniformly inside the unit disc — bullet spread, particle emission. */ + disc(out = { x: 0, y: 0 }) { + const r = Math.sqrt(this.float()); + const a = this.float() * Math.PI * 2; + out.x = Math.cos(a) * r; + out.y = Math.sin(a) * r; + return out; + } + + /** Independent stream derived from this one — lets a subsystem randomise + * without perturbing another subsystem's sequence. */ + fork() { + return new Rng(this.u32()); + } +} diff --git a/src/lib/cod/audio/useFootsteps.test.ts b/src/lib/cod/audio/useFootsteps.test.ts new file mode 100644 index 00000000..d5d1f02d --- /dev/null +++ b/src/lib/cod/audio/useFootsteps.test.ts @@ -0,0 +1,30 @@ +/** + * useFootsteps — unit test. + * + * jsdom has no Web Audio (`window.AudioContext` is undefined), so the hook's + * guards make `resume()` + `step()` safe no-ops — the same "no-op, don't throw" + * contract the SSR path relies on. Audible correctness is verified in a real + * browser (Playwright). + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useFootsteps } from './useFootsteps'; + +describe('useFootsteps', () => { + it('returns resume + step callbacks', () => { + const { result } = renderHook(() => useFootsteps()); + expect(typeof result.current.resume).toBe('function'); + expect(typeof result.current.step).toBe('function'); + }); + + it('is a silent no-op without Web Audio (jsdom) — never throws', () => { + const { result, unmount } = renderHook(() => useFootsteps()); + expect(() => { + result.current.resume(); + result.current.step(3, true, 'concrete'); + result.current.step(3, false, 'dirt'); + unmount(); + }).not.toThrow(); + }); +}); diff --git a/src/lib/cod/audio/useFootsteps.ts b/src/lib/cod/audio/useFootsteps.ts new file mode 100644 index 00000000..e03b8dd0 --- /dev/null +++ b/src/lib/cod/audio/useFootsteps.ts @@ -0,0 +1,162 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +// Vendored, framework-agnostic Claude-of-Duty procedural audio (MIT — see +// src/lib/cod/audio/NOTICE.md). Pure Web Audio, zero assets, no ctx. +import { Rng } from './rng'; +import { NoiseBank, gain } from './dsp'; +import { footstep } from './foley'; + +/** Metres travelled per footstep, by gait. */ +const STRIDE_BY_GAIT: Record = { + sprint: 2.6, + run: 2.4, + walk: 2.2, + crouch: 1.6, + prone: 1.4, +}; + +interface Voice { + node: AudioNode; + /** Absolute AudioContext time the tail finishes. */ + end: number; +} + +interface FootstepState { + actx: AudioContext | null; + master: GainNode | null; + bank: NoiseBank | null; + rng: Rng | null; + /** Accumulated grounded distance since the last step. */ + acc: number; + live: Voice[]; +} + +export interface UseFootsteps { + /** + * Create (lazily) + resume the AudioContext. MUST be called from inside a + * user gesture (e.g. the canvas click that captures pointer-lock) — the + * browser autoplay policy leaves a fresh context 'suspended' until then. + */ + resume: () => void; + /** + * Feed one movement tick. Call every frame from the controller's move loop + * with the distance travelled this frame, the grounded flag, and the surface + * underfoot. Fires a surface-keyed footstep every STRIDE metres; also prunes + * finished voices. Allocates only when a step actually fires (never per frame). + * Returns `true` on the frame a footstep fires — so the same cadence can drive + * other effects (e.g. dust). + */ + step: ( + distance: number, + grounded: boolean, + surface: string, + gait?: string + ) => boolean; +} + +/** + * Surface-keyed procedural footsteps harvested from Claude-of-Duty (Web Audio). + * + * Harvest-not-embed: no engine, no event bus, no SpatialField — the caller + * drives it imperatively from the R3F movement loop. First-person is head-locked, + * so voices play "dry" into a single master gain. SSR/jsdom-safe (no AudioContext + * is constructed until `resume()` runs inside a browser gesture), and the context + * is closed on unmount. + */ +export function useFootsteps(): UseFootsteps { + const s = useRef({ + actx: null, + master: null, + bank: null, + rng: null, + acc: 0, + live: [], + }).current; + + const resume = useCallback(() => { + if (typeof window === 'undefined') return; // SSR + const AC = + window.AudioContext ?? + (window as Window & { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!AC) return; // jsdom / no Web Audio + if (!s.actx) { + const actx = new AC({ latencyHint: 'interactive' }); + const master = gain(actx, 0.9) as GainNode; + master.connect(actx.destination); + const rng = new Rng(0x1234abcd); + const bank = new NoiseBank(actx, rng.fork(), 2.4); // shared buffers, built once + s.actx = actx; + s.master = master; + s.rng = rng; + s.bank = bank; + } + if (s.actx && s.actx.state === 'suspended') void s.actx.resume(); + }, [s]); + + const step = useCallback( + ( + distance: number, + grounded: boolean, + surface: string, + gait = 'walk' + ): boolean => { + const actx = s.actx; + if (!actx || actx.state !== 'running') return false; + + // Prune finished voices in-frame (no timers). + const now = actx.currentTime; + for (let i = s.live.length - 1; i >= 0; i--) { + if (s.live[i].end < now) { + try { + s.live[i].node.disconnect(); + } catch { + /* already disconnected / context closing */ + } + s.live.splice(i, 1); + } + } + + if (!grounded) { + s.acc = 0; // airtime must not bank a step + return false; + } + const stride = STRIDE_BY_GAIT[gait] ?? 2.2; + s.acc += Math.abs(distance); + if (s.acc < stride) return false; + s.acc -= stride; + + const v = footstep(s.actx, s.bank, s.rng, { + when: actx.currentTime, + surface: surface || 'concrete', // identity: foley keys == the 12 physics names + gait, + }); + if (s.master) v.node.connect(s.master); + s.live.push({ node: v.node, end: v.end }); + return true; // a footstep fired this call + }, + [s] + ); + + useEffect( + () => () => { + for (const v of s.live) { + try { + v.node.disconnect(); + } catch { + /* noop */ + } + } + s.live = []; + if (s.actx && s.actx.state !== 'closed') void s.actx.close(); + s.actx = null; + s.master = null; + s.bank = null; + s.rng = null; + }, + [s] + ); + + return { resume, step }; +} diff --git a/src/lib/cod/bvh.d.ts b/src/lib/cod/bvh.d.ts new file mode 100644 index 00000000..012d211d --- /dev/null +++ b/src/lib/cod/bvh.d.ts @@ -0,0 +1,114 @@ +// Hand-written public types for the vendored `bvh.js` (Claude-of-Duty, MIT). +// The runtime is the sibling .js (bundled); this declares its public API surface. +import type * as THREE from 'three'; + +/** A collision hit record (see `math.makeHitRecord`). */ +export interface HitRecord { + hit: boolean; + /** Time of impact / parametric distance. */ + t: number; + /** Contact point. */ + px: number; + py: number; + pz: number; + /** Contact normal (faces the query). */ + nx: number; + ny: number; + nz: number; + tri: number; + surface: number; + object: number; + frontFace: boolean; + body: unknown; +} + +export interface AddMeshOptions { + userData?: unknown; +} + +/** + * Static world: triangle soup + binned-SAH BVH. Register meshes, `build()`, then + * run raycast / swept-capsule / overlap queries. Allocation-free after build. + */ +export class StaticWorld { + constructor(); + + /** Bake a mesh (or InstancedMesh) into world-space triangles. Returns the object id, or -1. */ + addMesh( + mesh: THREE.Mesh | THREE.InstancedMesh, + surface?: string | number, + mask?: number, + opts?: AddMeshOptions + ): number; + /** Register raw world-space triangles (Float32Array, 9 floats each). */ + addTriangles( + positions: Float32Array, + count: number, + surface: string | number, + mask?: number, + name?: string + ): number; + removeObject(id: number): boolean; + findByMesh(mesh: THREE.Object3D): number; + + /** Rebuild the BVH after adding/removing meshes. */ + build(): void; + + /** Closest-hit ray query. Writes into `out`; returns true on hit. */ + raycast( + ox: number, + oy: number, + oz: number, + dx: number, + dy: number, + dz: number, + maxDist: number, + mask: number, + out: HitRecord, + ignoreObject?: number + ): boolean; + /** Any-hit shadow/visibility ray. */ + raycastAny( + ox: number, + oy: number, + oz: number, + dx: number, + dy: number, + dz: number, + maxDist: number, + mask: number + ): boolean; + /** Swept capsule vs the static world (true time of impact, no tunnelling). */ + sweepCapsule( + p0x: number, + p0y: number, + p0z: number, + p1x: number, + p1y: number, + p1z: number, + radius: number, + dx: number, + dy: number, + dz: number, + maxDist: number, + mask: number, + out: HitRecord + ): boolean; + /** Penetration contacts for a capsule at rest (fills `this.contacts`). Returns count. */ + overlapCapsule( + p0x: number, + p0y: number, + p0z: number, + p1x: number, + p1y: number, + p1z: number, + radius: number, + mask: number, + margin?: number + ): number; + + surfaceOf(tri: number): number; + dispose(): void; + + readonly triCount: number; +} diff --git a/src/lib/cod/bvh.js b/src/lib/cod/bvh.js new file mode 100644 index 00000000..47258873 --- /dev/null +++ b/src/lib/cod/bvh.js @@ -0,0 +1,933 @@ +/** + * Static world: triangle soup + binned-SAH BVH. + * + * `world` registers meshes through PhysicsSystem.addStatic(); we bake them into + * world space once, concatenate everything into flat typed arrays, and build a + * BVH over the result. Nothing here allocates after build() — queries run on + * preallocated stacks and write into caller-supplied records. + * + * Layout + * pos Float32Array, 9 floats per triangle (a.xyz b.xyz c.xyz), world space + * nrm Float32Array, 3 floats per triangle (unit geometric normal) + * surface Uint8Array, surface enum index per triangle + * mask Uint16Array, collision layer bits per triangle + * object Int32Array, owner object id per triangle + * + * Nodes + * nodeBounds Float32Array, 6 per node + * nodeMeta Int32Array, 2 per node — [leftFirst, count] + * count > 0 : leaf, triIndex[leftFirst .. +count) + * count = 0 : interior, children at leftFirst, +1 + */ + +import * as THREE from 'three'; +import { + rayAabb, + rayTriangle, + segTriangleClosest, + makeClosest, + EPS, +} from './math.js'; +import { surfaceIndex, guessSurface, LAYER } from './surfaces.js'; + +const BINS = 12; +const LEAF_SIZE = 6; +const TRAV_COST = 1.0; +const TRI_COST = 1.35; +/** Conservative-advancement tolerance, metres. */ +const CA_TOL = 1e-4; +const CA_ITERS = 48; + +const _m4 = new THREE.Matrix4(); + +export class StaticWorld { + constructor() { + this.objects = []; // { id, name, mesh, surface, mask, tris, triCount, alive, aabb } + this._freeIds = []; + + this.triCount = 0; + this.pos = new Float32Array(0); + this.nrm = new Float32Array(0); + this.surface = new Uint8Array(0); + this.mask = new Uint16Array(0); + this.object = new Int32Array(0); + + this.triIndex = new Uint32Array(0); + this.nodeBounds = new Float32Array(0); + this.nodeMeta = new Int32Array(0); + this.nodeCount = 0; + this.maxDepth = 0; + + this.dirty = false; + this.buildMs = 0; + this.version = 0; + + // scratch + this._cent = new Float32Array(0); + this._taabb = new Float32Array(0); + this._stackNode = new Int32Array(128); + this._stackT = new Float32Array(128); + this._buildStack = new Int32Array(3 * 4096); + this._cl = makeClosest(); + this._cl2 = makeClosest(); + this._cand = new Int32Array(4096); + this._candCount = 0; + + // shared contact buffer for overlap queries + this.contacts = { + count: 0, + capacity: 256, + nx: new Float32Array(256), + ny: new Float32Array(256), + nz: new Float32Array(256), + px: new Float32Array(256), + py: new Float32Array(256), + pz: new Float32Array(256), + depth: new Float32Array(256), + /** Parameter along the query segment where the contact sits, 0..1. */ + s: new Float32Array(256), + tri: new Int32Array(256), + }; + + this.aabb = { minx: 0, miny: 0, minz: 0, maxx: 0, maxy: 0, maxz: 0 }; + this.stats = { rayTests: 0, nodeTests: 0, triTests: 0 }; + } + + /* ---------------------------------------------------------------- */ + /* Registration */ + /* ---------------------------------------------------------------- */ + + /** + * Bake a mesh (or InstancedMesh) into world-space triangles. + * Returns the object id, or -1 if the mesh had no usable geometry. + */ + addMesh(mesh, surface, mask = LAYER.STATIC, opts = {}) { + if (!mesh) return -1; + const baked = bakeMesh(mesh, surface, opts); + if (!baked || baked.count === 0) return -1; + + const id = this._freeIds.length ? this._freeIds.pop() : this.objects.length; + const obj = { + id, + name: mesh.name || mesh.type, + mesh, + surface: baked.uniformSurface, + surfaces: baked.surfaces, + mask, + tris: baked.pos, + triCount: baked.count, + alive: true, + userData: opts.userData ?? null, + }; + this.objects[id] = obj; + this.dirty = true; + return id; + } + + /** Register raw world-space triangles (Float32Array, 9 floats each). */ + addTriangles(positions, count, surface, mask = LAYER.STATIC, name = 'raw') { + const id = this._freeIds.length ? this._freeIds.pop() : this.objects.length; + const s = surfaceIndex(surface); + const surfaces = new Uint8Array(count); + surfaces.fill(s); + this.objects[id] = { + id, name, mesh: null, surface: s, surfaces, mask, + tris: positions, triCount: count, alive: true, userData: null, + }; + this.dirty = true; + return id; + } + + removeObject(id) { + const o = this.objects[id]; + if (!o || !o.alive) return false; + o.alive = false; + o.tris = null; + o.surfaces = null; + this.objects[id] = null; + this._freeIds.push(id); + this.dirty = true; + return true; + } + + findByMesh(mesh) { + for (let i = 0; i < this.objects.length; i++) { + const o = this.objects[i]; + if (o && o.alive && o.mesh === mesh) return i; + } + return -1; + } + + /* ---------------------------------------------------------------- */ + /* Build */ + /* ---------------------------------------------------------------- */ + + build() { + const t0 = typeof performance !== 'undefined' ? performance.now() : 0; + let total = 0; + for (const o of this.objects) if (o && o.alive) total += o.triCount; + + if (total === 0) { + this.triCount = 0; + this.nodeCount = 0; + this.dirty = false; + this.version++; + return; + } + + if (this.pos.length < total * 9) { + this.pos = new Float32Array(total * 9); + this.nrm = new Float32Array(total * 3); + this.surface = new Uint8Array(total); + this.mask = new Uint16Array(total); + this.object = new Int32Array(total); + this.triIndex = new Uint32Array(total); + this._cent = new Float32Array(total * 3); + this._taabb = new Float32Array(total * 6); + const maxNodes = 2 * total + 8; + this.nodeBounds = new Float32Array(maxNodes * 6); + this.nodeMeta = new Int32Array(maxNodes * 2); + } + + const pos = this.pos; + let w = 0; + for (const o of this.objects) { + if (!o || !o.alive) continue; + pos.set(o.tris.subarray(0, o.triCount * 9), w * 9); + for (let i = 0; i < o.triCount; i++) { + this.surface[w + i] = o.surfaces ? o.surfaces[i] : o.surface; + this.mask[w + i] = o.mask; + this.object[w + i] = o.id; + } + w += o.triCount; + } + this.triCount = total; + + // Per-triangle normals, centroids, bounds. + const cent = this._cent; + const ta = this._taabb; + const nrm = this.nrm; + let gminx = Infinity, gminy = Infinity, gminz = Infinity; + let gmaxx = -Infinity, gmaxy = -Infinity, gmaxz = -Infinity; + for (let i = 0; i < total; i++) { + const p = i * 9; + const ax = pos[p], ay = pos[p + 1], az = pos[p + 2]; + const bx = pos[p + 3], by = pos[p + 4], bz = pos[p + 5]; + const cx = pos[p + 6], cy = pos[p + 7], cz = pos[p + 8]; + const e1x = bx - ax, e1y = by - ay, e1z = bz - az; + const e2x = cx - ax, e2y = cy - ay, e2z = cz - az; + let nx = e1y * e2z - e1z * e2y; + let ny = e1z * e2x - e1x * e2z; + let nz = e1x * e2y - e1y * e2x; + const l = Math.hypot(nx, ny, nz); + if (l > EPS) { nx /= l; ny /= l; nz /= l; } else { nx = 0; ny = 1; nz = 0; } + nrm[i * 3] = nx; nrm[i * 3 + 1] = ny; nrm[i * 3 + 2] = nz; + + const mnx = Math.min(ax, bx, cx), mny = Math.min(ay, by, cy), mnz = Math.min(az, bz, cz); + const mxx = Math.max(ax, bx, cx), mxy = Math.max(ay, by, cy), mxz = Math.max(az, bz, cz); + const b = i * 6; + ta[b] = mnx; ta[b + 1] = mny; ta[b + 2] = mnz; + ta[b + 3] = mxx; ta[b + 4] = mxy; ta[b + 5] = mxz; + cent[i * 3] = (mnx + mxx) * 0.5; + cent[i * 3 + 1] = (mny + mxy) * 0.5; + cent[i * 3 + 2] = (mnz + mxz) * 0.5; + this.triIndex[i] = i; + if (mnx < gminx) gminx = mnx; + if (mny < gminy) gminy = mny; + if (mnz < gminz) gminz = mnz; + if (mxx > gmaxx) gmaxx = mxx; + if (mxy > gmaxy) gmaxy = mxy; + if (mxz > gmaxz) gmaxz = mxz; + } + this.aabb.minx = gminx; this.aabb.miny = gminy; this.aabb.minz = gminz; + this.aabb.maxx = gmaxx; this.aabb.maxy = gmaxy; this.aabb.maxz = gmaxz; + + this._buildNodes(total); + this.dirty = false; + this.version++; + this.buildMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0; + } + + _buildNodes(total) { + const meta = this.nodeMeta; + const bounds = this.nodeBounds; + const idx = this.triIndex; + const ta = this._taabb; + const cent = this._cent; + + this.nodeCount = 1; + meta[0] = 0; + meta[1] = total; + this._nodeBoundsFromRange(0, 0, total); + + // Explicit stack of (nodeIndex, start, count, depth) + const need = 4 * (2 * Math.ceil(total / LEAF_SIZE) + 64); + if (this._buildStack.length < need) this._buildStack = new Int32Array(need); + const stack = this._buildStack; + let sp = 0; + stack[sp++] = 0; stack[sp++] = 0; stack[sp++] = total; stack[sp++] = 0; + + // Binned SAH scratch (reused every split) + const binCount = new Int32Array(BINS); + const binB = new Float32Array(BINS * 6); + const leftArea = new Float32Array(BINS); + const leftCnt = new Int32Array(BINS); + let maxDepth = 0; + + while (sp > 0) { + const depth = stack[--sp]; + const count = stack[--sp]; + const start = stack[--sp]; + const node = stack[--sp]; + if (depth > maxDepth) maxDepth = depth; + if (count <= LEAF_SIZE || depth > 60) continue; + + const nb = node * 6; + // centroid bounds + let cminx = Infinity, cminy = Infinity, cminz = Infinity; + let cmaxx = -Infinity, cmaxy = -Infinity, cmaxz = -Infinity; + for (let i = start; i < start + count; i++) { + const t = idx[i] * 3; + const x = cent[t], y = cent[t + 1], z = cent[t + 2]; + if (x < cminx) cminx = x; if (x > cmaxx) cmaxx = x; + if (y < cminy) cminy = y; if (y > cmaxy) cmaxy = y; + if (z < cminz) cminz = z; if (z > cmaxz) cmaxz = z; + } + const ex = cmaxx - cminx, ey = cmaxy - cminy, ez = cmaxz - cminz; + let axis = 0, extent = ex, cmin = cminx; + if (ey > extent) { axis = 1; extent = ey; cmin = cminy; } + if (ez > extent) { axis = 2; extent = ez; cmin = cminz; } + if (extent < 1e-7) continue; // degenerate cluster -> leaf + + const scale = BINS / extent; + binCount.fill(0); + for (let b = 0; b < BINS; b++) { + const o = b * 6; + binB[o] = binB[o + 1] = binB[o + 2] = Infinity; + binB[o + 3] = binB[o + 4] = binB[o + 5] = -Infinity; + } + for (let i = start; i < start + count; i++) { + const tri = idx[i]; + let b = ((cent[tri * 3 + axis] - cmin) * scale) | 0; + if (b < 0) b = 0; else if (b >= BINS) b = BINS - 1; + binCount[b]++; + const o = b * 6, tb = tri * 6; + if (ta[tb] < binB[o]) binB[o] = ta[tb]; + if (ta[tb + 1] < binB[o + 1]) binB[o + 1] = ta[tb + 1]; + if (ta[tb + 2] < binB[o + 2]) binB[o + 2] = ta[tb + 2]; + if (ta[tb + 3] > binB[o + 3]) binB[o + 3] = ta[tb + 3]; + if (ta[tb + 4] > binB[o + 4]) binB[o + 4] = ta[tb + 4]; + if (ta[tb + 5] > binB[o + 5]) binB[o + 5] = ta[tb + 5]; + } + + // sweep left + let axmin = Infinity, aymin = Infinity, azmin = Infinity; + let axmax = -Infinity, aymax = -Infinity, azmax = -Infinity; + let acc = 0; + for (let b = 0; b < BINS - 1; b++) { + const o = b * 6; + if (binCount[b] > 0) { + if (binB[o] < axmin) axmin = binB[o]; + if (binB[o + 1] < aymin) aymin = binB[o + 1]; + if (binB[o + 2] < azmin) azmin = binB[o + 2]; + if (binB[o + 3] > axmax) axmax = binB[o + 3]; + if (binB[o + 4] > aymax) aymax = binB[o + 4]; + if (binB[o + 5] > azmax) azmax = binB[o + 5]; + } + acc += binCount[b]; + leftCnt[b] = acc; + leftArea[b] = acc > 0 ? surfaceArea(axmin, aymin, azmin, axmax, aymax, azmax) : 0; + } + // sweep right + pick + axmin = aymin = azmin = Infinity; + axmax = aymax = azmax = -Infinity; + let rAcc = 0; + let bestCost = TRI_COST * count; // cost of making this a leaf + let bestSplit = -1; + const parentArea = surfaceArea( + bounds[nb], bounds[nb + 1], bounds[nb + 2], + bounds[nb + 3], bounds[nb + 4], bounds[nb + 5] + ); + const invParent = parentArea > 0 ? 1 / parentArea : 0; + for (let b = BINS - 1; b > 0; b--) { + const o = b * 6; + if (binCount[b] > 0) { + if (binB[o] < axmin) axmin = binB[o]; + if (binB[o + 1] < aymin) aymin = binB[o + 1]; + if (binB[o + 2] < azmin) azmin = binB[o + 2]; + if (binB[o + 3] > axmax) axmax = binB[o + 3]; + if (binB[o + 4] > aymax) aymax = binB[o + 4]; + if (binB[o + 5] > azmax) azmax = binB[o + 5]; + } + rAcc += binCount[b]; + const lc = leftCnt[b - 1]; + if (lc === 0 || rAcc === 0) continue; + const rArea = surfaceArea(axmin, aymin, azmin, axmax, aymax, azmax); + const cost = TRAV_COST + TRI_COST * invParent * (leftArea[b - 1] * lc + rArea * rAcc); + if (cost < bestCost) { + bestCost = cost; + bestSplit = b; + } + } + if (bestSplit < 0) continue; // leaf is cheaper + + // partition in place + const splitPos = cmin + extent * (bestSplit / BINS); + let i = start, j = start + count - 1; + while (i <= j) { + const tri = idx[i]; + if (cent[tri * 3 + axis] < splitPos) i++; + else { idx[i] = idx[j]; idx[j] = tri; j--; } + } + const leftCount = i - start; + if (leftCount === 0 || leftCount === count) continue; + + const l = this.nodeCount; + this.nodeCount += 2; + meta[node * 2] = l; + meta[node * 2 + 1] = 0; + meta[l * 2] = start; meta[l * 2 + 1] = leftCount; + meta[(l + 1) * 2] = i; meta[(l + 1) * 2 + 1] = count - leftCount; + this._nodeBoundsFromRange(l, start, leftCount); + this._nodeBoundsFromRange(l + 1, i, count - leftCount); + + stack[sp++] = l; stack[sp++] = start; stack[sp++] = leftCount; stack[sp++] = depth + 1; + stack[sp++] = l + 1; stack[sp++] = i; stack[sp++] = count - leftCount; stack[sp++] = depth + 1; + } + this.maxDepth = maxDepth; + const needStack = Math.max(64, maxDepth * 2 + 8); + if (this._stackNode.length < needStack) { + this._stackNode = new Int32Array(needStack); + this._stackT = new Float32Array(needStack); + } + } + + _nodeBoundsFromRange(node, start, count) { + const ta = this._taabb; + const idx = this.triIndex; + let mnx = Infinity, mny = Infinity, mnz = Infinity; + let mxx = -Infinity, mxy = -Infinity, mxz = -Infinity; + for (let i = start; i < start + count; i++) { + const b = idx[i] * 6; + if (ta[b] < mnx) mnx = ta[b]; + if (ta[b + 1] < mny) mny = ta[b + 1]; + if (ta[b + 2] < mnz) mnz = ta[b + 2]; + if (ta[b + 3] > mxx) mxx = ta[b + 3]; + if (ta[b + 4] > mxy) mxy = ta[b + 4]; + if (ta[b + 5] > mxz) mxz = ta[b + 5]; + } + // Float32 storage can round a bound inwards; pad by a hair so we never + // reject a triangle that actually straddles the plane. + const p = 1e-5; + const o = node * 6; + this.nodeBounds[o] = mnx - p; + this.nodeBounds[o + 1] = mny - p; + this.nodeBounds[o + 2] = mnz - p; + this.nodeBounds[o + 3] = mxx + p; + this.nodeBounds[o + 4] = mxy + p; + this.nodeBounds[o + 5] = mxz + p; + } + + /* ---------------------------------------------------------------- */ + /* Queries */ + /* ---------------------------------------------------------------- */ + + /** + * Closest-hit ray query. `out` is a hit record (see math.makeHitRecord). + * Returns true on hit. Both faces are tested — bullet penetration needs the + * backface exit hit. + */ + raycast(ox, oy, oz, dx, dy, dz, maxDist, mask, out, ignoreObject = -1) { + out.hit = false; + if (this.nodeCount === 0 || this.triCount === 0) return false; + const ix = 1 / (dx !== 0 ? dx : 1e-30); + const iy = 1 / (dy !== 0 ? dy : 1e-30); + const iz = 1 / (dz !== 0 ? dz : 1e-30); + const nb = this.nodeBounds; + const meta = this.nodeMeta; + const idx = this.triIndex; + const pos = this.pos; + const stackNode = this._stackNode; + const stackT = this._stackT; + + let best = maxDist; + let bestTri = -1; + let bestFront = true; + + if (rayAabb(ox, oy, oz, ix, iy, iz, nb[0], nb[1], nb[2], nb[3], nb[4], nb[5], best) === Infinity) + return false; + + let sp = 0; + stackNode[sp] = 0; + stackT[sp] = 0; + sp++; + + while (sp > 0) { + sp--; + if (stackT[sp] >= best) continue; + let node = stackNode[sp]; + for (;;) { + const count = meta[node * 2 + 1]; + if (count > 0) { + const start = meta[node * 2]; + for (let i = start; i < start + count; i++) { + const tri = idx[i]; + if ((this.mask[tri] & mask) === 0) continue; + if (ignoreObject >= 0 && this.object[tri] === ignoreObject) continue; + const p = tri * 9; + const t = rayTriangle( + ox, oy, oz, dx, dy, dz, + pos[p], pos[p + 1], pos[p + 2], + pos[p + 3], pos[p + 4], pos[p + 5], + pos[p + 6], pos[p + 7], pos[p + 8], + out + ); + if (t >= 0 && t < best) { + best = t; + bestTri = tri; + bestFront = out.frontFace; // written by rayTriangle + } + } + break; + } + const l = meta[node * 2]; + const r = l + 1; + const lo = l * 6, ro = r * 6; + const tl = rayAabb(ox, oy, oz, ix, iy, iz, nb[lo], nb[lo + 1], nb[lo + 2], nb[lo + 3], nb[lo + 4], nb[lo + 5], best); + const tr = rayAabb(ox, oy, oz, ix, iy, iz, nb[ro], nb[ro + 1], nb[ro + 2], nb[ro + 3], nb[ro + 4], nb[ro + 5], best); + if (tl === Infinity && tr === Infinity) break; + if (tl <= tr) { + if (tr !== Infinity) { stackNode[sp] = r; stackT[sp] = tr; sp++; } + node = l; + } else { + if (tl !== Infinity) { stackNode[sp] = l; stackT[sp] = tl; sp++; } + node = r; + } + } + } + + if (bestTri < 0) return false; + this._fillHit(out, bestTri, best, ox, oy, oz, dx, dy, dz); + out.frontFace = bestFront; + // Face the normal against the incoming ray so callers can always use it + // directly for reflection / decal orientation. + if (out.nx * dx + out.ny * dy + out.nz * dz > 0) { + out.nx = -out.nx; out.ny = -out.ny; out.nz = -out.nz; + } + return true; + } + + _fillHit(out, tri, t, ox, oy, oz, dx, dy, dz) { + out.hit = true; + out.t = t; + out.px = ox + dx * t; + out.py = oy + dy * t; + out.pz = oz + dz * t; + out.nx = this.nrm[tri * 3]; + out.ny = this.nrm[tri * 3 + 1]; + out.nz = this.nrm[tri * 3 + 2]; + out.tri = tri; + out.surface = this.surface[tri]; + out.object = this.object[tri]; + out.body = null; + } + + /** Any-hit shadow/visibility ray. Cheaper: no ordering, first hit wins. */ + raycastAny(ox, oy, oz, dx, dy, dz, maxDist, mask) { + if (this.nodeCount === 0) return false; + const ix = 1 / (dx !== 0 ? dx : 1e-30); + const iy = 1 / (dy !== 0 ? dy : 1e-30); + const iz = 1 / (dz !== 0 ? dz : 1e-30); + const nb = this.nodeBounds; + const meta = this.nodeMeta; + const idx = this.triIndex; + const pos = this.pos; + const stack = this._stackNode; + let sp = 0; + if (rayAabb(ox, oy, oz, ix, iy, iz, nb[0], nb[1], nb[2], nb[3], nb[4], nb[5], maxDist) === Infinity) + return false; + stack[sp++] = 0; + while (sp > 0) { + const node = stack[--sp]; + const count = meta[node * 2 + 1]; + if (count > 0) { + const start = meta[node * 2]; + for (let i = start; i < start + count; i++) { + const tri = idx[i]; + if ((this.mask[tri] & mask) === 0) continue; + const p = tri * 9; + const t = rayTriangle( + ox, oy, oz, dx, dy, dz, + pos[p], pos[p + 1], pos[p + 2], + pos[p + 3], pos[p + 4], pos[p + 5], + pos[p + 6], pos[p + 7], pos[p + 8], + null + ); + if (t >= 0 && t < maxDist) return true; + } + continue; + } + const l = meta[node * 2]; + const r = l + 1; + const lo = l * 6, ro = r * 6; + if (rayAabb(ox, oy, oz, ix, iy, iz, nb[lo], nb[lo + 1], nb[lo + 2], nb[lo + 3], nb[lo + 4], nb[lo + 5], maxDist) !== Infinity) + stack[sp++] = l; + if (rayAabb(ox, oy, oz, ix, iy, iz, nb[ro], nb[ro + 1], nb[ro + 2], nb[ro + 3], nb[ro + 4], nb[ro + 5], maxDist) !== Infinity) + stack[sp++] = r; + } + return false; + } + + /** Gather triangle indices whose AABB overlaps the query box. */ + queryAabb(minx, miny, minz, maxx, maxy, maxz, mask) { + this._candCount = 0; + if (this.nodeCount === 0) return 0; + const nb = this.nodeBounds; + const meta = this.nodeMeta; + const idx = this.triIndex; + const ta = this._taabb; + const stack = this._stackNode; + let sp = 0; + if (nb[0] > maxx || nb[3] < minx || nb[1] > maxy || nb[4] < miny || nb[2] > maxz || nb[5] < minz) + return 0; + stack[sp++] = 0; + let n = 0; + let cand = this._cand; + while (sp > 0) { + const node = stack[--sp]; + const count = meta[node * 2 + 1]; + if (count > 0) { + const start = meta[node * 2]; + for (let i = start; i < start + count; i++) { + const tri = idx[i]; + if ((this.mask[tri] & mask) === 0) continue; + const b = tri * 6; + if (ta[b] > maxx || ta[b + 3] < minx) continue; + if (ta[b + 1] > maxy || ta[b + 4] < miny) continue; + if (ta[b + 2] > maxz || ta[b + 5] < minz) continue; + if (n >= cand.length) { + const bigger = new Int32Array(cand.length * 2); + bigger.set(cand); + this._cand = cand = bigger; + } + cand[n++] = tri; + } + continue; + } + const l = meta[node * 2]; + const r = l + 1; + const lo = l * 6, ro = r * 6; + const hitL = !(nb[lo] > maxx || nb[lo + 3] < minx || nb[lo + 1] > maxy || nb[lo + 4] < miny || nb[lo + 2] > maxz || nb[lo + 5] < minz); + const hitR = !(nb[ro] > maxx || nb[ro + 3] < minx || nb[ro + 1] > maxy || nb[ro + 4] < miny || nb[ro + 2] > maxz || nb[ro + 5] < minz); + if (hitL) stack[sp++] = l; + if (hitR) stack[sp++] = r; + if (sp >= stack.length - 2) break; // stack is sized from tree depth; never hit in practice + } + this._candCount = n; + return n; + } + + get candidates() { + return this._cand; + } + get candidateCount() { + return this._candCount; + } + + /** + * Swept capsule against the static world. The capsule translates linearly; + * per candidate triangle we run conservative advancement on the exact + * segment/triangle distance function, which is convex under linear motion — + * so the result is a true time of impact with no tunnelling at any speed. + */ + sweepCapsule(p0x, p0y, p0z, p1x, p1y, p1z, radius, dx, dy, dz, maxDist, mask, out) { + out.hit = false; + if (this.nodeCount === 0) return false; + const ex = dx * maxDist, ey = dy * maxDist, ez = dz * maxDist; + const r = radius + 0.002; + const minx = Math.min(p0x, p1x, p0x + ex, p1x + ex) - r; + const miny = Math.min(p0y, p1y, p0y + ey, p1y + ey) - r; + const minz = Math.min(p0z, p1z, p0z + ez, p1z + ez) - r; + const maxx = Math.max(p0x, p1x, p0x + ex, p1x + ex) + r; + const maxy = Math.max(p0y, p1y, p0y + ey, p1y + ey) + r; + const maxz = Math.max(p0z, p1z, p0z + ez, p1z + ez) + r; + const n = this.queryAabb(minx, miny, minz, maxx, maxy, maxz, mask); + if (n === 0) return false; + + const cand = this._cand; + const pos = this.pos; + const nrm = this.nrm; + const cl = this._cl; + let best = maxDist; + let bestTri = -1; + let bnx = 0, bny = 1, bnz = 0; + let bpx = 0, bpy = 0, bpz = 0; + + for (let c = 0; c < n; c++) { + const tri = cand[c]; + const p = tri * 9; + const ax = pos[p], ay = pos[p + 1], az = pos[p + 2]; + const bx = pos[p + 3], by = pos[p + 4], bz = pos[p + 5]; + const cx = pos[p + 6], cy = pos[p + 7], cz = pos[p + 8]; + + // Cheap plane-slab prefilter. The min signed distance over the capsule + // axis is linear in t, so the whole sweep can be rejected with two dots. + const tnx = nrm[tri * 3], tny = nrm[tri * 3 + 1], tnz = nrm[tri * 3 + 2]; + const sdA = (p0x - ax) * tnx + (p0y - ay) * tny + (p0z - az) * tnz; + const sdB = (p1x - ax) * tnx + (p1y - ay) * tny + (p1z - az) * tnz; + const vd = (dx * tnx + dy * tny + dz * tnz) * best; + const lo = Math.min(sdA, sdB) + Math.min(0, vd); + const hi = Math.max(sdA, sdB) + Math.max(0, vd); + if (lo > radius || hi < -radius) continue; + + let t = 0; + let hitT = -1; + for (let iter = 0; iter < CA_ITERS; iter++) { + const ox = dx * t, oy = dy * t, oz = dz * t; + segTriangleClosest( + p0x + ox, p0y + oy, p0z + oz, + p1x + ox, p1y + oy, p1z + oz, + ax, ay, az, bx, by, bz, cx, cy, cz, + cl + ); + const dist = Math.sqrt(cl.d2) - radius; + // separating axis: capsule axis point -> triangle point + let sx = cl.bx - cl.ax, sy = cl.by - cl.ay, sz = cl.bz - cl.az; + const sl = Math.hypot(sx, sy, sz); + if (sl < 1e-12) { hitT = t; break; } // axis passes through the face + sx /= sl; sy /= sl; sz /= sl; + const closing = dx * sx + dy * sy + dz * sz; + if (dist <= CA_TOL) { + // Already touching. Only a *blocking* contact counts — a capsule + // resting on the floor must still be able to slide along it, or the + // controller stalls the instant it stands on anything. + if (closing > 1e-6) hitT = t; + break; + } + if (closing <= 1e-7) break; // convex distance is non-decreasing -> miss + const step = dist / closing; + t += step > 1e-7 ? step : 1e-7; + if (t >= best) break; + } + if (hitT < 0 || hitT >= best) continue; + + // Recover the contact normal at the impact configuration. + const ox = dx * hitT, oy = dy * hitT, oz = dz * hitT; + segTriangleClosest( + p0x + ox, p0y + oy, p0z + oz, + p1x + ox, p1y + oy, p1z + oz, + ax, ay, az, bx, by, bz, cx, cy, cz, + cl + ); + let nx = cl.ax - cl.bx, ny = cl.ay - cl.by, nz = cl.az - cl.bz; + const nl = Math.hypot(nx, ny, nz); + if (nl > 1e-7) { nx /= nl; ny /= nl; nz /= nl; } + else { nx = tnx; ny = tny; nz = tnz; } + // Never return a normal we are travelling away from. + if (nx * dx + ny * dy + nz * dz > 0) { + if (tnx * dx + tny * dy + tnz * dz < 0) { nx = tnx; ny = tny; nz = tnz; } + else { nx = -tnx; ny = -tny; nz = -tnz; } + } + best = hitT; + bestTri = tri; + bnx = nx; bny = ny; bnz = nz; + bpx = cl.bx; bpy = cl.by; bpz = cl.bz; + } + + if (bestTri < 0) return false; + out.hit = true; + out.t = best; + out.px = bpx; out.py = bpy; out.pz = bpz; + out.nx = bnx; out.ny = bny; out.nz = bnz; + out.tri = bestTri; + out.surface = this.surface[bestTri]; + out.object = this.object[bestTri]; + out.frontFace = true; + out.body = null; + return true; + } + + /** + * Collect penetration contacts for a capsule at rest. Fills `this.contacts` + * (shared, valid until the next overlap query). Normals point out of the + * surface, towards the capsule. + */ + overlapCapsule(p0x, p0y, p0z, p1x, p1y, p1z, radius, mask, margin = 0) { + const cts = this.contacts; + cts.count = 0; + if (this.nodeCount === 0) return 0; + const r = radius + margin; + const n = this.queryAabb( + Math.min(p0x, p1x) - r, Math.min(p0y, p1y) - r, Math.min(p0z, p1z) - r, + Math.max(p0x, p1x) + r, Math.max(p0y, p1y) + r, Math.max(p0z, p1z) + r, + mask + ); + if (n === 0) return 0; + const cand = this._cand; + const pos = this.pos; + const nrm = this.nrm; + const cl = this._cl2; + const r2 = r * r; + let k = 0; + for (let c = 0; c < n && k < cts.capacity; c++) { + const tri = cand[c]; + const p = tri * 9; + const d2 = segTriangleClosest( + p0x, p0y, p0z, p1x, p1y, p1z, + pos[p], pos[p + 1], pos[p + 2], + pos[p + 3], pos[p + 4], pos[p + 5], + pos[p + 6], pos[p + 7], pos[p + 8], + cl + ); + if (d2 >= r2) continue; + const d = Math.sqrt(d2); + let nx, ny, nz; + if (d > 1e-6) { + nx = (cl.ax - cl.bx) / d; + ny = (cl.ay - cl.by) / d; + nz = (cl.az - cl.bz) / d; + // Deep contacts can pick a normal pointing into the solid; fall back to + // the face normal when the closest-point direction disagrees with it. + const fn = nx * nrm[tri * 3] + ny * nrm[tri * 3 + 1] + nz * nrm[tri * 3 + 2]; + if (fn < 0.05) { + nx = nrm[tri * 3]; ny = nrm[tri * 3 + 1]; nz = nrm[tri * 3 + 2]; + } + } else { + nx = nrm[tri * 3]; ny = nrm[tri * 3 + 1]; nz = nrm[tri * 3 + 2]; + } + cts.nx[k] = nx; cts.ny[k] = ny; cts.nz[k] = nz; + cts.px[k] = cl.bx; cts.py[k] = cl.by; cts.pz[k] = cl.bz; + cts.depth[k] = r - d; + cts.s[k] = cl.s; + cts.tri[k] = tri; + k++; + } + cts.count = k; + return k; + } + + surfaceOf(tri) { + return this.surface[tri] ?? 0; + } + + objectOf(tri) { + return this.objects[this.object[tri]] ?? null; + } + + dispose() { + this.objects.length = 0; + this.pos = new Float32Array(0); + this.nodeCount = 0; + this.triCount = 0; + } +} + +function surfaceArea(minx, miny, minz, maxx, maxy, maxz) { + const dx = maxx - minx, dy = maxy - miny, dz = maxz - minz; + if (dx < 0 || dy < 0 || dz < 0) return 0; + return 2 * (dx * dy + dy * dz + dz * dx); +} + +/* ------------------------------------------------------------------ */ +/* Mesh baking */ +/* ------------------------------------------------------------------ */ + +/** + * Flatten a Mesh / InstancedMesh into world-space triangles. + * Handles indexed and non-indexed geometry, multi-material groups (each group + * can carry its own surface, inferred from the material name), and instancing. + */ +export function bakeMesh(mesh, surfaceOverride, opts = {}) { + const geo = mesh.geometry; + if (!geo || !geo.attributes || !geo.attributes.position) return null; + const posAttr = geo.attributes.position; + const index = geo.index; + const triPerInstance = (index ? index.count : posAttr.count) / 3 | 0; + if (triPerInstance === 0) return null; + + const isInstanced = mesh.isInstancedMesh === true && mesh.count > 0; + const instances = isInstanced ? mesh.count : 1; + const total = triPerInstance * instances; + + const out = new Float32Array(total * 9); + const surfaces = new Uint8Array(total); + + mesh.updateWorldMatrix(true, false); + + // Per-group surface resolution. + const groups = geo.groups && geo.groups.length ? geo.groups : null; + const baseSurface = surfaceOverride !== undefined && surfaceOverride !== null + ? surfaceIndex(surfaceOverride) + : surfaceIndex( + mesh.userData?.surface ?? materialName(mesh.material) ?? mesh.name, + guessSurface(mesh.name) + ); + const groupSurface = groups + ? groups.map((g, gi) => { + if (surfaceOverride !== undefined && surfaceOverride !== null) return baseSurface; + const mat = Array.isArray(mesh.material) ? mesh.material[g.materialIndex ?? gi] : mesh.material; + return surfaceIndex(mat?.userData?.surface ?? mat?.name ?? mesh.name, baseSurface); + }) + : null; + + const pos = posAttr.array; + const stride = posAttr.itemSize; + const idxArr = index ? index.array : null; + + for (let inst = 0; inst < instances; inst++) { + if (isInstanced) { + mesh.getMatrixAt(inst, _m4); + _m4.premultiply(mesh.matrixWorld); + } else { + _m4.copy(mesh.matrixWorld); + } + const e = _m4.elements; + const base = inst * triPerInstance; + for (let t = 0; t < triPerInstance; t++) { + const o = (base + t) * 9; + for (let v = 0; v < 3; v++) { + const vi = idxArr ? idxArr[t * 3 + v] : t * 3 + v; + const px = pos[vi * stride]; + const py = pos[vi * stride + 1]; + const pz = pos[vi * stride + 2]; + out[o + v * 3] = e[0] * px + e[4] * py + e[8] * pz + e[12]; + out[o + v * 3 + 1] = e[1] * px + e[5] * py + e[9] * pz + e[13]; + out[o + v * 3 + 2] = e[2] * px + e[6] * py + e[10] * pz + e[14]; + } + let s = baseSurface; + if (groupSurface) { + const vStart = t * 3; + for (let gi = 0; gi < geo.groups.length; gi++) { + const g = geo.groups[gi]; + if (vStart >= g.start && vStart < g.start + g.count) { s = groupSurface[gi]; break; } + } + } + surfaces[base + t] = s; + } + } + + // Drop degenerate triangles (zero area) — they poison normals and SAH bins. + let w = 0; + for (let t = 0; t < total; t++) { + const p = t * 9; + const e1x = out[p + 3] - out[p], e1y = out[p + 4] - out[p + 1], e1z = out[p + 5] - out[p + 2]; + const e2x = out[p + 6] - out[p], e2y = out[p + 7] - out[p + 1], e2z = out[p + 8] - out[p + 2]; + const cx = e1y * e2z - e1z * e2y; + const cy = e1z * e2x - e1x * e2z; + const cz = e1x * e2y - e1y * e2x; + if (cx * cx + cy * cy + cz * cz < 1e-14) continue; + if (w !== t) { + out.copyWithin(w * 9, p, p + 9); + surfaces[w] = surfaces[t]; + } + w++; + } + + return { pos: out, count: w, surfaces, uniformSurface: baseSurface }; +} + +function materialName(m) { + if (!m) return null; + if (Array.isArray(m)) return m[0]?.name ?? null; + return m.userData?.surface ?? m.name ?? null; +} diff --git a/src/lib/cod/character.d.ts b/src/lib/cod/character.d.ts new file mode 100644 index 00000000..84fba56f --- /dev/null +++ b/src/lib/cod/character.d.ts @@ -0,0 +1,89 @@ +// Hand-written public types for the vendored `character.js` (Claude-of-Duty, MIT). +// The runtime is the sibling .js (bundled); this declares its public API surface. +import type { StaticWorld } from './bvh'; + +export interface Vec3 { + x: number; + y: number; + z: number; +} + +export interface CharacterControllerOptions { + id?: string; + owner?: unknown; + /** Capsule radius (default 0.32). */ + radius?: number; + /** Total capsule height, feet to crown (default 1.78). */ + height?: number; + /** Max step-up height (default 0.42). */ + stepHeight?: number; + /** Walkable slope limit in radians (default 50°). */ + slopeLimit?: number; + snapDistance?: number; + /** Collision mask (see MASK in surfaces). */ + mask?: number; + maxIterations?: number; + position?: Vec3; +} + +/** + * Swept-capsule character controller — collide and slide. Kinematic: the caller + * owns velocity each fixed step; `move()` resolves a displacement against the + * static BVH and only clips velocity against contacts. + */ +export class CharacterController { + constructor(world: StaticWorld, opts?: CharacterControllerOptions); + + radius: number; + height: number; + stepHeight: number; + slopeLimit: number; + snapDistance: number; + mask: number; + maxIterations: number; + + /** Feet position (bottom of the capsule) — the authoritative transform. */ + readonly position: Vec3; + /** Velocity owned by the caller; `move()` only clips it against contacts. */ + readonly velocity: Vec3; + + grounded: boolean; + wasGrounded: boolean; + readonly groundNormal: Vec3; + groundSurface: number; + groundDistance: number; + groundObject: number; + onSteepSlope: boolean; + touchingCeiling: boolean; + touchingWall: boolean; + readonly wallNormal: Vec3; + lastMoveBlocked: boolean; + steppedUp: number; + /** Impact speed along the ground normal on the frame the character landed. */ + landingSpeed: number; + enabled: boolean; + + get cosSlope(): number; + get p0y(): number; + get p1y(): number; + get groundFriction(): number; + get groundSurfaceName(): string; + + setPosition(x: number, y: number, z: number): void; + teleport(x: number, y: number, z: number): void; + /** + * Change capsule height keeping the feet planted. Returns false if standing up + * is blocked by a ceiling (caller stays crouched). + */ + setHeight(h: number, force?: boolean): boolean; + /** Would a capsule of `h` metres fit at the current feet position? */ + canFit(h: number): boolean; + /** + * Resolve a displacement (metres for this step). Returns the distance actually + * travelled. + */ + move(dx: number, dy: number, dz: number): number; + depenetrate(iterations?: number): number; + probeGround(): boolean; + checkCapsule(x: number, y: number, z: number, height?: number): boolean; +} diff --git a/src/lib/cod/character.js b/src/lib/cod/character.js new file mode 100644 index 00000000..a170857e --- /dev/null +++ b/src/lib/cod/character.js @@ -0,0 +1,490 @@ +/** + * Swept-capsule character controller — collide and slide. + * + * The controller is kinematic: `player` (or `ai`) sets a desired displacement + * each fixed step and we resolve it against the static BVH. Nothing here + * integrates forces; velocity is owned by the caller and only *clipped* by us, + * so the movement state machine keeps full authority over feel. + * + * Resolution per move(): + * 1. depenetrate — push out of anything we are already inside + * 2. lift — grounded moves raise the capsule by stepHeight first, so + * a stair tread is simply invisible to the horizontal sweep + * 3. slide — up to N swept sweeps, clipping the remaining motion + * against every plane we touch (Quake-style plane stack so + * creases don't launch or trap the player) + * 4. drop — come back down by the lift plus gravity plus the stair + * descent snap, refusing to cling to unwalkable faces + * 5. ground probe — publish grounded / normal / surface for this frame + * + * The sweep is a true continuous test (see StaticWorld.sweepCapsule), so there + * is no tunnelling regardless of speed — a 300 m/s displacement resolves + * correctly in one step. + */ + +import { makeHitRecord } from './math.js'; +import { MASK, SURFACE_PROPS, surfaceName } from './surfaces.js'; + +const MAX_PLANES = 5; +const SKIN = 0.008; + +export class CharacterController { + constructor(world, opts = {}) { + this.world = world; + this.id = opts.id ?? 'character'; + this.owner = opts.owner ?? null; + + this.radius = opts.radius ?? 0.32; + this.height = opts.height ?? 1.78; // total capsule height, feet to crown + this.stepHeight = opts.stepHeight ?? 0.42; + this.slopeLimit = opts.slopeLimit ?? 50 * (Math.PI / 180); + this.snapDistance = opts.snapDistance ?? 0.32; + this.mask = opts.mask ?? MASK.CHARACTER; + this.maxIterations = opts.maxIterations ?? 5; + + /** Feet position (bottom of the capsule), the authoritative transform. */ + this.position = { x: 0, y: 0, z: 0 }; + /** Velocity is owned by the caller; move() clips it against contacts. */ + this.velocity = { x: 0, y: 0, z: 0 }; + + this.grounded = false; + this.wasGrounded = false; + this.groundNormal = { x: 0, y: 1, z: 0 }; + this.groundSurface = 0; + this.groundDistance = 0; + this.groundObject = -1; + this.onSteepSlope = false; + this.touchingCeiling = false; + this.touchingWall = false; + this.wallNormal = { x: 0, y: 0, z: 0 }; + this.lastMoveBlocked = false; + this.steppedUp = 0; + /** Impact speed along the ground normal on the frame we landed. */ + this.landingSpeed = 0; + this.enabled = true; + + // preallocated scratch + this._hit = makeHitRecord(); + this._hit2 = makeHitRecord(); + this._planes = new Float32Array(MAX_PLANES * 3); + this._planeCount = 0; + this._startPos = { x: 0, y: 0, z: 0 }; + + if (opts.position) this.setPosition(opts.position.x, opts.position.y, opts.position.z); + } + + get cosSlope() { + return Math.cos(this.slopeLimit); + } + + /** Lower sphere centre of the capsule. */ + get p0y() { + return this.position.y + this.radius; + } + /** Upper sphere centre of the capsule. */ + get p1y() { + return this.position.y + this.height - this.radius; + } + + setPosition(x, y, z) { + this.position.x = x; + this.position.y = y; + this.position.z = z; + } + + /** Teleport: clears contact state and de-penetrates at the destination. */ + teleport(x, y, z) { + this.setPosition(x, y, z); + this.velocity.x = this.velocity.y = this.velocity.z = 0; + this.grounded = false; + this.touchingCeiling = false; + this.touchingWall = false; + this.depenetrate(8); + this.probeGround(); + } + + /** + * Change capsule height keeping the feet planted. Returns false if standing + * up is blocked by a ceiling (caller stays crouched). + */ + setHeight(h, force = false) { + if (h > this.height && !force && !this.canFit(h)) return false; + this.height = h; + return true; + } + + /** Would a capsule of `h` metres fit at the current feet position? */ + canFit(h) { + const r = this.radius; + const p0y = this.position.y + r; + const p1y = this.position.y + h - r; + if (p1y < p0y) return true; + const n = this.world.overlapCapsule( + this.position.x, p0y, this.position.z, + this.position.x, p1y, this.position.z, + r - 0.01, this.mask, 0 + ); + return n === 0; + } + + /** + * Resolve a displacement. `dx/dy/dz` are metres for this step (the caller has + * already multiplied by dt). Returns the distance actually travelled. + * + * Grounded moves use the step-offset scheme: lift the capsule by stepHeight, + * slide horizontally, then drop back down. A tread shorter than stepHeight is + * simply invisible to the horizontal sweep, which is the only way to make + * stairs work with a capsule — its bottom hemisphere always meets a stair + * nose at a shallow angle, so a "detect the wall then retry higher" scheme + * never gets enough forward travel in one 8 ms step to clear the nose. + */ + move(dx, dy, dz) { + if (!this.enabled) return 0; + const st = this._startPos; + st.x = this.position.x; st.y = this.position.y; st.z = this.position.z; + this.wasGrounded = this.grounded; + this.touchingCeiling = false; + this.touchingWall = false; + this.lastMoveBlocked = false; + this.steppedUp = 0; + + this.depenetrate(4); + + const jumping = dy > 1e-6; + const useStepOffset = + this.wasGrounded && !jumping && this.stepHeight > 1e-4 && + (dx * dx + dz * dz) > 1e-10; + + if (!useStepOffset) { + this._slide(dx, dy, dz); + } else { + // 1. lift — a low ceiling shortens the lift automatically + const lift = this._sweepMove(0, this.stepHeight, 0); + // 2. horizontal + this._slide(dx, 0, dz); + // 3. drop back down, plus this step's gravity, plus the stair-descent snap + const want = lift + Math.max(0, -dy); + const snap = this.snapDistance; + const yBefore = this.position.y; + const dropped = this._sweepDown(want + snap); + if (dropped < 0) { + // Nothing under us: fall exactly what was asked, no more. + this.position.y = yBefore - want; + } else if (dropped > want && this._hit2.ny < this.cosSlope) { + // The only thing within snap range is a cliff face — don't cling to it. + this.position.y = yBefore - want; + } + const gained = this.position.y - st.y; + if (gained > 1e-4) this.steppedUp = gained; + } + + this.depenetrate(3); + this.probeGround(); + + if (this.grounded && !this.wasGrounded) { + this.landingSpeed = -Math.min(0, this.velocity.y); + } + + return Math.hypot(this.position.x - st.x, this.position.y - st.y, this.position.z - st.z); + } + + /** Collide-and-slide core. Returns true if any plane stopped us. */ + _slide(dx, dy, dz) { + const planes = this._planes; + let planeCount = 0; + let blocked = false; + + for (let iter = 0; iter < this.maxIterations; iter++) { + const dist = Math.hypot(dx, dy, dz); + if (dist < 1e-6) break; + const inv = 1 / dist; + const ux = dx * inv, uy = dy * inv, uz = dz * inv; + + const hit = this._hit; + const r = this.radius; + const ok = this.world.sweepCapsule( + this.position.x, this.p0y, this.position.z, + this.position.x, this.p1y, this.position.z, + r, ux, uy, uz, dist + SKIN, this.mask, hit + ); + + if (!ok) { + this.position.x += dx; + this.position.y += dy; + this.position.z += dz; + break; + } + + blocked = true; + const advance = Math.max(0, Math.min(hit.t - SKIN, dist)); + this.position.x += ux * advance; + this.position.y += uy * advance; + this.position.z += uz * advance; + + // remaining motion + const rem = dist - advance; + dx = ux * rem; dy = uy * rem; dz = uz * rem; + + const nx = hit.nx, ny = hit.ny, nz = hit.nz; + this._classifyContact(nx, ny, nz, hit); + // Note: steep contacts keep their vertical component on purpose. Zeroing + // it (the usual "don't ramp up cliffs" hack) turns every stair nose into + // a wall, because the bottom hemisphere always meets a step edge at a + // shallow angle. Unwalkable surfaces are handled where they should be — + // probeGround() reports grounded = false, so the caller keeps applying + // gravity and the character slides straight back down. + + if (planeCount >= MAX_PLANES) break; + planes[planeCount * 3] = nx; + planes[planeCount * 3 + 1] = ny; + planes[planeCount * 3 + 2] = nz; + planeCount++; + + // Clip against every plane collected so far; if a single-plane projection + // still violates another plane, slide along the crease of the two. + let cx = dx, cy = dy, cz = dz; + let resolved = false; + for (let i = 0; i < planeCount && !resolved; i++) { + const px = planes[i * 3], py = planes[i * 3 + 1], pz = planes[i * 3 + 2]; + if (dx * px + dy * py + dz * pz >= 0) continue; + let tx = dx, ty = dy, tz = dz; + const into = tx * px + ty * py + tz * pz; + tx -= px * into; ty -= py * into; tz -= pz * into; + let violates = -1; + for (let j = 0; j < planeCount; j++) { + if (j === i) continue; + const qx = planes[j * 3], qy = planes[j * 3 + 1], qz = planes[j * 3 + 2]; + if (tx * qx + ty * qy + tz * qz < 0) { violates = j; break; } + } + if (violates < 0) { + cx = tx; cy = ty; cz = tz; + resolved = true; + } else { + // crease: travel along the intersection of the two planes + const qx = planes[violates * 3], qy = planes[violates * 3 + 1], qz = planes[violates * 3 + 2]; + let ex = py * qz - pz * qy; + let ey = pz * qx - px * qz; + let ez = px * qy - py * qx; + const el = Math.hypot(ex, ey, ez); + if (el < 1e-6) { cx = cy = cz = 0; resolved = true; break; } + ex /= el; ey /= el; ez /= el; + const along = dx * ex + dy * ey + dz * ez; + cx = ex * along; cy = ey * along; cz = ez * along; + // Reject if the crease direction is blocked by a third plane. + let bad = false; + for (let j = 0; j < planeCount; j++) { + const rx = planes[j * 3], ry = planes[j * 3 + 1], rz = planes[j * 3 + 2]; + if (cx * rx + cy * ry + cz * rz < -1e-6) { bad = true; break; } + } + if (bad) { cx = cy = cz = 0; } + resolved = true; + } + } + dx = cx; dy = cy; dz = cz; + + // Clip the caller's velocity the same way so accumulated speed doesn't + // survive a wall impact. + this._clipVelocity(nx, ny, nz); + + if (dx * dx + dy * dy + dz * dz < 1e-12) break; + } + this._planeCount = planeCount; + this.lastMoveBlocked = blocked; + return blocked; + } + + _classifyContact(nx, ny, nz, hit) { + if (ny >= this.cosSlope) { + this.grounded = true; + this.groundNormal.x = nx; this.groundNormal.y = ny; this.groundNormal.z = nz; + this.groundSurface = hit.surface; + this.groundObject = hit.object; + this.onSteepSlope = false; + } else if (ny < -0.5) { + this.touchingCeiling = true; + } else { + this.touchingWall = true; + this.wallNormal.x = nx; this.wallNormal.y = ny; this.wallNormal.z = nz; + if (ny > 0.05) this.onSteepSlope = true; + } + } + + _clipVelocity(nx, ny, nz) { + const v = this.velocity; + const into = v.x * nx + v.y * ny + v.z * nz; + if (into < 0) { + v.x -= nx * into; + v.y -= ny * into; + v.z -= nz * into; + } + } + + /** Single swept translation with no sliding. Returns distance travelled. */ + _sweepMove(dx, dy, dz) { + const dist = Math.hypot(dx, dy, dz); + if (dist < 1e-7) return 0; + const inv = 1 / dist; + const ux = dx * inv, uy = dy * inv, uz = dz * inv; + const hit = this._hit2; + const ok = this.world.sweepCapsule( + this.position.x, this.p0y, this.position.z, + this.position.x, this.p1y, this.position.z, + this.radius, ux, uy, uz, dist + SKIN, this.mask, hit + ); + const adv = ok ? Math.max(0, Math.min(hit.t - SKIN, dist)) : dist; + this.position.x += ux * adv; + this.position.y += uy * adv; + this.position.z += uz * adv; + return adv; + } + + /** + * Sweep straight down up to `dist`. Returns the drop distance, or -1 if + * nothing was hit (the capsule is then left where it started). + * `radiusScale` shrinks the capsule for the trace — the step-up drop uses a + * thinner probe so the bottom hemisphere settles onto a step tread instead of + * hanging on its nose. + */ + _sweepDown(dist, radiusScale = 1) { + const hit = this._hit2; + const r = this.radius * radiusScale; + const ok = this.world.sweepCapsule( + this.position.x, this.position.y + r, this.position.z, + this.position.x, this.position.y + this.height - r, this.position.z, + r, 0, -1, 0, dist + SKIN, this.mask, hit + ); + if (!ok) return -1; + const adv = Math.max(0, Math.min(hit.t - SKIN, dist)); + this.position.y -= adv; + return adv; + } + + /** Push the capsule out of anything it currently overlaps. */ + depenetrate(iterations = 4) { + const w = this.world; + let moved = 0; + for (let it = 0; it < iterations; it++) { + const n = w.overlapCapsule( + this.position.x, this.p0y, this.position.z, + this.position.x, this.p1y, this.position.z, + this.radius, this.mask, 0 + ); + if (n === 0) break; + const c = w.contacts; + // Accumulate the maximum push along each distinct normal rather than the + // sum — summing over a tessellated wall ejects the capsule across the map. + let px = 0, py = 0, pz = 0; + for (let i = 0; i < n; i++) { + const d = c.depth[i]; + if (d <= 1e-5) continue; + const nx = c.nx[i], ny = c.ny[i], nz = c.nz[i]; + const already = px * nx + py * ny + pz * nz; + const extra = d - already; + if (extra > 0) { + px += nx * extra; + py += ny * extra; + pz += nz * extra; + } + } + const l = Math.hypot(px, py, pz); + if (l < 1e-5) break; + // Damp so a bad contact set can never fling the character. + const maxPush = 0.25; + const s = l > maxPush ? maxPush / l : 1; + this.position.x += px * s; + this.position.y += py * s; + this.position.z += pz * s; + moved += l * s; + if (l < 1e-4) break; + } + return moved; + } + + /** + * Short downward sweep that publishes grounded state for this frame. + * + * Two traces on purpose. The thin one (60 % radius) finds the floor while + * ignoring convex edges — without it, a character riding up a stair nose is + * reported airborne because the nose is the nearest thing below and its + * normal is steeper than the slope limit. The wide one is the fallback for + * standing on a narrow beam, where the thin trace would miss entirely. + */ + probeGround() { + const probe = 0.06; + const cos = this.cosSlope; + const hit = this._hit; + const w = this.world; + + const thin = w.sweepCapsule( + this.position.x, this.position.y + this.radius * 0.6, this.position.z, + this.position.x, this.position.y + this.height - this.radius * 0.6, this.position.z, + this.radius * 0.6, 0, -1, 0, probe, this.mask, hit + ); + + let found = thin && hit.ny >= cos; + if (!found) { + const wide = w.sweepCapsule( + this.position.x, this.p0y, this.position.z, + this.position.x, this.p1y, this.position.z, + this.radius * 0.98, 0, -1, 0, probe, this.mask, hit + ); + // A surface with any meaningful upward component supports us even if it + // is too steep to be "walkable" — that is what a stair nose is. + found = wide && hit.ny > 0.15; + } + + if (found) { + this.grounded = true; + this.groundNormal.x = hit.nx; + this.groundNormal.y = hit.ny; + this.groundNormal.z = hit.nz; + this.groundSurface = hit.surface; + this.groundObject = hit.object; + this.groundDistance = hit.t; + this.onSteepSlope = hit.ny < cos; + } else { + this.grounded = false; + this.groundDistance = hit.hit ? hit.t : Infinity; + this.onSteepSlope = hit.hit && hit.ny > 0.05 && hit.ny < cos; + if (hit.hit) { + this.groundNormal.x = hit.nx; + this.groundNormal.y = hit.ny; + this.groundNormal.z = hit.nz; + this.groundSurface = hit.surface; + } + } + + // Ceiling probe — the movement machine needs this to cancel a jump. + const ch = this._hit2; + this.touchingCeiling = this.world.sweepCapsule( + this.position.x, this.p0y, this.position.z, + this.position.x, this.p1y, this.position.z, + this.radius * 0.98, 0, 1, 0, 0.06, this.mask, ch + ) && ch.ny < -0.4; + + return this.grounded; + } + + /** Friction coefficient of whatever we are standing on. */ + get groundFriction() { + return SURFACE_PROPS[this.groundSurface]?.friction ?? 0.9; + } + + get groundSurfaceName() { + return surfaceName(this.groundSurface); + } + + /** + * Can the character stand here? Used by AI spawn placement and by `player` + * before a mantle/vault commits. + */ + checkCapsule(x, y, z, height = this.height) { + return ( + this.world.overlapCapsule( + x, y + this.radius, z, + x, y + height - this.radius, z, + this.radius - 0.005, this.mask, 0 + ) === 0 + ); + } +} diff --git a/src/lib/cod/core/NOTICE.md b/src/lib/cod/core/NOTICE.md new file mode 100644 index 00000000..fb9537b7 --- /dev/null +++ b/src/lib/cod/core/NOTICE.md @@ -0,0 +1,15 @@ +# Vendored from Claude-of-Duty (MIT) — core gems + +Source: https://github.com/mshumer/Claude-of-Duty (Matt Shumer), MIT (see ../LICENSE). + +The two framework-agnostic "core gems", ported to typed TS. The OVERWATCH kernel +(engine / registry / prewarm / main) is NOT harvested — React-Three-Fiber replaces +its imperative loop + service locator. + +- `event-bus.ts` — the `EventBus` class extracted from `src/core/registry.js:86-122` + (game events without React re-renders; `on`/`once` return an unsubscribe closure). +- `quality.ts` — `QUALITY_PRESETS` from `src/core/config.js:21` (renderer-generic + fields only; CoD's post-chain flags taa/gtao/ssr/volumetrics/motionBlur dropped) + plus a module-store `useQuality()` hook. + +Both are three-free with zero runtime dependencies. diff --git a/src/lib/cod/core/event-bus.test.ts b/src/lib/cod/core/event-bus.test.ts new file mode 100644 index 00000000..162813d4 --- /dev/null +++ b/src/lib/cod/core/event-bus.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EventBus } from './event-bus'; + +describe('EventBus', () => { + it('delivers payloads to subscribers in registration order', () => { + const bus = new EventBus<{ ping: number }>(); + const seen: string[] = []; + bus.on('ping', (n) => seen.push(`a${n}`)); + bus.on('ping', (n) => seen.push(`b${n}`)); + bus.emit('ping', 1); + expect(seen).toEqual(['a1', 'b1']); + }); + + it('on() returns an unsubscribe closure', () => { + const bus = new EventBus<{ ping: number }>(); + let count = 0; + const off = bus.on('ping', () => { + count++; + }); + bus.emit('ping', 1); + off(); + bus.emit('ping', 1); + expect(count).toBe(1); + }); + + it('once() fires exactly once', () => { + const bus = new EventBus<{ ping: number }>(); + let count = 0; + bus.once('ping', () => { + count++; + }); + bus.emit('ping', 1); + bus.emit('ping', 1); + expect(count).toBe(1); + }); + + it('off() removes a handler', () => { + const bus = new EventBus<{ ping: number }>(); + let count = 0; + const fn = (): void => { + count++; + }; + bus.on('ping', fn); + bus.off('ping', fn); + bus.emit('ping', 1); + expect(count).toBe(0); + }); + + it('isolates a throwing handler — the rest still run', () => { + const bus = new EventBus<{ ping: number }>(); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + let ran = false; + bus.on('ping', () => { + throw new Error('boom'); + }); + bus.on('ping', () => { + ran = true; + }); + bus.emit('ping', 1); + expect(ran).toBe(true); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + it('emit with no listeners is a no-op', () => { + const bus = new EventBus<{ ping: number }>(); + expect(() => bus.emit('ping', 1)).not.toThrow(); + }); + + it('a handler may unsubscribe mid-dispatch (set is copied per emit)', () => { + const bus = new EventBus<{ ping: number }>(); + let bCount = 0; + let offB: () => void = () => {}; + bus.on('ping', () => offB()); // unsubscribes b during dispatch + offB = bus.on('ping', () => { + bCount++; + }); + bus.emit('ping', 1); // b still runs this dispatch + bus.emit('ping', 1); // b now unsubscribed + expect(bCount).toBe(1); + }); +}); diff --git a/src/lib/cod/core/event-bus.ts b/src/lib/cod/core/event-bus.ts new file mode 100644 index 00000000..59bbf7c9 --- /dev/null +++ b/src/lib/cod/core/event-bus.ts @@ -0,0 +1,82 @@ +/** + * Minimal synchronous event bus — game events without React re-renders. + * + * Ported to typed TS from Claude-of-Duty (MIT), `src/core/registry.js:86-122` + * (extracted from the OVERWATCH service-locator, which is NOT harvested). + * Handlers fire in registration order; a throwing handler is isolated (logged, + * never stops the rest). `on`/`once` return an unsubscribe closure — ideal as a + * React `useEffect` cleanup. + * + * `emit` copies the listener set so a handler may unsubscribe mid-dispatch. That + * is one small allocation per emit — fine for discrete game events (footsteps, + * hits, pickups); do not emit from a per-frame hot path. + */ + +export type Handler = (payload: T) => void; +export type Unsubscribe = () => void; + +export class EventBus< + Events extends Record = Record, +> { + #map = new Map>>(); + + /** Subscribe to `type`. Returns an unsubscribe closure. */ + on(type: K, fn: Handler): Unsubscribe { + let set = this.#map.get(type); + if (!set) { + set = new Set(); + this.#map.set(type, set); + } + set.add(fn as Handler); + return () => this.off(type, fn); + } + + /** Subscribe for a single emit, then auto-unsubscribe. */ + once(type: K, fn: Handler): Unsubscribe { + const off = this.on(type, (e) => { + off(); + fn(e); + }); + return off; + } + + off(type: K, fn: Handler): void { + this.#map.get(type)?.delete(fn as Handler); + } + + emit(type: K, payload: Events[K]): void { + const set = this.#map.get(type); + if (!set) return; + // Copy so handlers may unsubscribe during dispatch. + for (const fn of [...set]) { + try { + (fn as Handler)(payload); + } catch (err) { + console.error(`[events] handler for "${String(type)}" threw:`, err); + } + } + } + + clear(): void { + this.#map.clear(); + } +} + +/** A world-space position payload. */ +export interface Vec3Payload { + x: number; + y: number; + z: number; +} + +/** + * The game-event vocabulary the toolkit's demo emits. Extend this map (or use a + * fresh `new EventBus()`) for a game's own events. + */ +export interface GameEvents extends Record { + 'player:stance': { stance: string }; + 'player:footstep': { surface: string; position: Vec3Payload }; +} + +/** Shared game-event bus. Emit from event handlers; subscribe in `useEffect`. */ +export const bus = new EventBus(); diff --git a/src/lib/cod/core/quality.test.ts b/src/lib/cod/core/quality.test.ts new file mode 100644 index 00000000..bb8b7108 --- /dev/null +++ b/src/lib/cod/core/quality.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + QUALITY_PRESETS, + QUALITY_TIERS, + setQuality, + useQuality, +} from './quality'; + +describe('quality presets', () => { + it('has all four tiers with the generic fields', () => { + for (const tier of QUALITY_TIERS) { + const p = QUALITY_PRESETS[tier]; + expect(p.renderScale).toBeGreaterThan(0); + expect(p.shadowMapSize).toBeGreaterThan(0); + expect(p.anisotropy).toBeGreaterThan(0); + expect(p.particleBudget).toBeGreaterThan(0); + } + }); + + it('drops the CoD post-chain-specific fields', () => { + expect('taa' in QUALITY_PRESETS.high).toBe(false); + expect('volumetrics' in QUALITY_PRESETS.high).toBe(false); + expect('ssr' in QUALITY_PRESETS.high).toBe(false); + }); + + it('budgets scale up with the tier', () => { + expect(QUALITY_PRESETS.low.particleBudget).toBeLessThan( + QUALITY_PRESETS.ultra.particleBudget + ); + expect(QUALITY_PRESETS.low.shadowMapSize).toBeLessThan( + QUALITY_PRESETS.ultra.shadowMapSize + ); + }); +}); + +describe('useQuality store', () => { + beforeEach(() => { + act(() => setQuality('high')); + }); + + it('defaults to high and returns its preset', () => { + const { result } = renderHook(() => useQuality()); + expect(result.current.tier).toBe('high'); + expect(result.current.preset).toBe(QUALITY_PRESETS.high); + }); + + it('setTier updates the active tier + preset', () => { + const { result } = renderHook(() => useQuality()); + act(() => result.current.setTier('low')); + expect(result.current.tier).toBe('low'); + expect(result.current.preset.particleBudget).toBe( + QUALITY_PRESETS.low.particleBudget + ); + }); +}); diff --git a/src/lib/cod/core/quality.ts b/src/lib/cod/core/quality.ts new file mode 100644 index 00000000..573eeb2f --- /dev/null +++ b/src/lib/cod/core/quality.ts @@ -0,0 +1,124 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +/** + * Quality tiers — ported to typed TS from Claude-of-Duty (MIT), + * `src/core/config.js:21` (QUALITY_PRESETS). Only the renderer-generic fields are + * kept; CoD's post-chain-specific flags (taa/gtao/ssr/volumetrics/motionBlur) are + * dropped — wire your own `@react-three/postprocessing` passes if you want them. + * + * The active tier lives in a module store (shared across all `useQuality` + * consumers, no context provider needed); initialised from the `?q=` URL param. + */ + +export type QualityTier = 'low' | 'medium' | 'high' | 'ultra'; + +export interface QualityPreset { + /** Multiplier on devicePixelRatio → feed a clamped value to ``. */ + renderScale: number; + shadowMapSize: number; + cascades: number; + shadowDistance: number; + anisotropy: number; + particleBudget: number; + decalBudget: number; + bloom: boolean; +} + +export const QUALITY_PRESETS: Record = { + low: { + renderScale: 0.72, + shadowMapSize: 1024, + cascades: 3, + shadowDistance: 60, + anisotropy: 4, + particleBudget: 2000, + decalBudget: 64, + bloom: true, + }, + medium: { + renderScale: 0.85, + shadowMapSize: 2048, + cascades: 3, + shadowDistance: 90, + anisotropy: 8, + particleBudget: 6000, + decalBudget: 128, + bloom: true, + }, + high: { + renderScale: 1.0, + shadowMapSize: 2048, + cascades: 4, + shadowDistance: 140, + anisotropy: 16, + particleBudget: 12000, + decalBudget: 256, + bloom: true, + }, + ultra: { + renderScale: 1.0, + shadowMapSize: 4096, + cascades: 4, + shadowDistance: 200, + anisotropy: 16, + particleBudget: 24000, + decalBudget: 512, + bloom: true, + }, +}; + +export const QUALITY_TIERS: readonly QualityTier[] = [ + 'low', + 'medium', + 'high', + 'ultra', +]; + +function isTier(v: string | null): v is QualityTier { + return v === 'low' || v === 'medium' || v === 'high' || v === 'ultra'; +} + +/** Initial tier from the `?q=` URL param, else 'high'. */ +function initialTier(): QualityTier { + if (typeof window === 'undefined') return 'high'; + const q = new URLSearchParams(window.location.search).get('q'); + return isTier(q) ? q : 'high'; +} + +// ---- module store (shared; survives re-renders) -------------------------------- +let currentTier: QualityTier = initialTier(); +const listeners = new Set<() => void>(); + +function subscribe(cb: () => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} +function getSnapshot(): QualityTier { + return currentTier; +} +function getServerSnapshot(): QualityTier { + return 'high'; +} + +/** Set the active quality tier (notifies every `useQuality` consumer). */ +export function setQuality(tier: QualityTier): void { + if (tier === currentTier) return; + currentTier = tier; + for (const cb of [...listeners]) cb(); +} + +export interface UseQuality { + tier: QualityTier; + preset: QualityPreset; + setTier: (tier: QualityTier) => void; +} + +/** Subscribe to the active quality tier + its preset. */ +export function useQuality(): UseQuality { + const tier = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return { tier, preset: QUALITY_PRESETS[tier], setTier: setQuality }; +} diff --git a/src/lib/cod/fx/NOTICE.md b/src/lib/cod/fx/NOTICE.md new file mode 100644 index 00000000..fea6070d --- /dev/null +++ b/src/lib/cod/fx/NOTICE.md @@ -0,0 +1,11 @@ +# Vendored from Claude-of-Duty (MIT) — GPU particles + +Source: https://github.com/mshumer/Claude-of-Duty (Matt Shumer), MIT (see ../LICENSE). + +`ParticleLayer` — a deterministic GPU particle system: instanced quads, per-particle +sim in the vertex shader (closed-form from a `uTime` clock, CPU never re-touches a +particle after spawn). Standalone `THREE.ShaderMaterial` (GLSL3) — three-only, no ctx, +no renderer, and r184-safe (no onBeforeCompile / no shader-chunk patching). Only this +one file is vendored; the sprite atlas + FPS effects (impacts/muzzle/decals/…) are not. +Lifecycle: new ParticleLayer({capacity,mode,atlas,cols,soft}) → .mesh (add to scene) → +resetSpawn()+emit(SP,now) per particle → flush(now) each frame → dispose(). diff --git a/src/lib/cod/fx/particles.js b/src/lib/cod/fx/particles.js new file mode 100644 index 00000000..6a42df13 --- /dev/null +++ b/src/lib/cod/fx/particles.js @@ -0,0 +1,446 @@ +import * as THREE from 'three'; + +/** + * GPU particle system. + * + * One instanced quad per particle. The whole simulation lives in the vertex + * shader as a closed-form solution of + * + * dv/dt = -k v + g => v(t) = v0 e^-kt + g/k (1 - e^-kt) + * x(t) = x0 + (v0 - g/k)(1 - e^-kt)/k + g t / k + * + * plus a per-particle turbulence term, so the CPU never touches a particle + * again after it is spawned: no per-frame simulation, no per-frame allocation, + * no readback. Spawning writes 32 floats into a preallocated interleaved ring + * buffer and uploads only the dirty span. + * + * Two blend modes share the code: + * ADDITIVE premultiplied ONE/ONE — sparks, muzzle flash, fire, tracers. + * Order independent, so no sorting is needed. + * LIT/alpha src-alpha over — smoke, dust, blood. Shaded with a spherical + * fake normal bent by the sprite's own density gradient, wrapped + * sun term plus a forward-scatter lobe, so a puff reads as volume. + * + * Both fade softly against `render.depthTexture` (linear view depth in metres) + * so nothing shows a hard intersection line with the world. + */ + +export const STRIDE = 32; + +// interleaved slot offsets +const O_PS = 0; // pos.xyz, size0 +const O_VS = 4; // vel.xyz, size1 +const O_LF = 8; // birth, 1/life, drag, gravity +const O_RT = 12; // rot0, spin, stretch, sizeCurve +const O_C0 = 16; // colour A rgb, intensity A +const O_C1 = 20; // colour B rgb, intensity B +const O_MS = 24; // tile, softness, alpha, alphaCurve +const O_EX = 28; // turbAmp, turbFreq, seed, flags + +/** Reusable spawn descriptor — spawning must never allocate. */ +export const SP = { + x: 0, y: 0, z: 0, + vx: 0, vy: 0, vz: 0, + size0: 0.2, size1: 0.3, sizeCurve: 1, + life: 1, delay: 0, drag: 1.4, gravity: 0, + rot: 0, spin: 0, + /** Velocity-aligned smear: length = size * (1 + stretch * speed). ~1 is one + * frame of motion blur at 60 Hz for a centimetre-scale sprite. */ + stretch: 0, + r0: 1, g0: 1, b0: 1, i0: 1, + r1: 1, g1: 1, b1: 1, i1: 0, + tile: 0, soft: 0.4, alpha: 1, alphaCurve: 1, + turb: 0, turbFreq: 1, seed: 0, flags: 0, +}; + +export function resetSpawn() { + const s = SP; + s.x = s.y = s.z = 0; + s.vx = s.vy = s.vz = 0; + s.size0 = 0.2; s.size1 = 0.3; s.sizeCurve = 1; + s.life = 1; s.delay = 0; s.drag = 1.4; s.gravity = 0; + s.rot = 0; s.spin = 0; s.stretch = 0; + s.r0 = s.g0 = s.b0 = 1; s.i0 = 1; + s.r1 = s.g1 = s.b1 = 1; s.i1 = 0; + s.tile = 0; s.soft = 0.4; s.alpha = 1; s.alphaCurve = 1; + s.turb = 0; s.turbFreq = 1; s.seed = 0; s.flags = 0; + return s; +} + +/* ------------------------------------------------------------------------- */ +/* shaders */ +/* ------------------------------------------------------------------------- */ + +export const PARTICLE_VERT = /* glsl */ ` +precision highp float; + +attribute vec4 aPS; +attribute vec4 aVS; +attribute vec4 aLife; +attribute vec4 aRot; +attribute vec4 aCol0; +attribute vec4 aCol1; +attribute vec4 aMisc; +attribute vec4 aExtra; + +uniform float uTime; +uniform vec2 uAtlas; // cols, 1/cols + +varying vec2 vUv; +varying vec4 vCol; +varying float vViewZ; +varying float vSoft; +varying vec2 vQ; +varying float vAge; + +void main() { + float t = uTime - aLife.x; + float n = t * aLife.y; + if ( t < 0.0 || n >= 1.0 ) { + vUv = vec2( 0.0 ); + vCol = vec4( 0.0 ); + vViewZ = 1.0; + vSoft = 1.0; + vQ = vec2( 0.0 ); + vAge = 0.0; + gl_Position = vec4( 0.0, 0.0, 2.0, 1.0 ); // behind the far plane: clipped + return; + } + + float k = max( aLife.z, 0.02 ); + float e = exp( -k * t ); + vec3 gk = vec3( 0.0, aLife.w, 0.0 ) / k; + vec3 wpos = aPS.xyz + ( aVS.xyz - gk ) * ( ( 1.0 - e ) / k ) + gk * t; + vec3 wvel = aVS.xyz * e + gk * ( 1.0 - e ); + + // Turbulence: three decorrelated sines. Grows in so particles do not + // teleport on their first frame, and contributes to the velocity used for + // stretch orientation so drifting smoke leans the right way. + float ph = aExtra.z * 6.2831853; + float f = aExtra.y; + float grow = smoothstep( 0.0, 0.4, n ); + float amp = aExtra.x * grow; + wpos += vec3( sin( t * f * 1.13 + ph ), sin( t * f * 0.79 + ph * 2.1 ), cos( t * f * 1.31 + ph * 1.7 ) ) * amp; + wvel += vec3( cos( t * f * 1.13 + ph ), cos( t * f * 0.79 + ph * 2.1 ), -sin( t * f * 1.31 + ph * 1.7 ) ) * ( amp * f ); + + vec4 mv = viewMatrix * vec4( wpos, 1.0 ); + vec3 velView = ( viewMatrix * vec4( wvel, 0.0 ) ).xyz; + + float size = mix( aPS.w, aVS.w, pow( n, max( aRot.w, 0.02 ) ) ); + vec2 c = position.xy; + vec2 off; + if ( aRot.z > 0.001 ) { + // velocity-aligned: +Y of the sprite runs along screen-space velocity + vec2 d = velView.xy; + float dl = length( d ); + vec2 along = dl > 1e-5 ? d / dl : vec2( 0.0, 1.0 ); + vec2 perp = vec2( -along.y, along.x ); + float len = size * ( 1.0 + aRot.z * length( velView ) ); + off = along * ( c.y * len ) + perp * ( c.x * size ); + } else { + float rot = aRot.x + aRot.y * t; + float s = sin( rot ), co = cos( rot ); + off = vec2( c.x * co - c.y * s, c.x * s + c.y * co ) * size; + } + mv.xy += off; + + vViewZ = -mv.z; + vSoft = max( aMisc.y, 0.002 ); + vQ = off / max( size, 1e-4 ) * 2.0; + vAge = n; + gl_Position = projectionMatrix * mv; + + vec2 tuv = vec2( mod( aMisc.x, uAtlas.x ), floor( aMisc.x * uAtlas.y ) ); + vUv = ( uv + tuv ) * uAtlas.y; + + vec3 col = mix( aCol0.rgb, aCol1.rgb, n ); + float inten = mix( aCol0.w, aCol1.w, n * n ); + if ( aExtra.w > 0.5 ) inten *= 0.72 + 0.28 * sin( t * 63.0 + ph * 9.0 ); // spark flicker + float a = aMisc.z * pow( max( 1.0 - n, 0.0 ), max( aMisc.w, 0.02 ) ) * smoothstep( 0.0, 0.045, n ); + vCol = vec4( col * inten, a ); +} +`; + +export const PARTICLE_FRAG = /* glsl */ ` +precision highp float; + +uniform sampler2D uSprite; +uniform sampler2D uDepth; +uniform vec2 uRes; +uniform vec2 uSoftEnable; +uniform vec3 uSunDir; // view space, pointing at the sun +uniform vec3 uSunCol; +uniform vec3 uAmbTop; +uniform vec3 uAmbBot; +uniform vec3 uUpView; +uniform vec4 uFog; // rgb, density + +varying vec2 vUv; +varying vec4 vCol; +varying float vViewZ; +varying float vSoft; +varying vec2 vQ; +varying float vAge; + +layout(location = 0) out vec4 outColor; + +void main() { + if ( vCol.a <= 0.0 ) discard; + vec4 tex = texture2D( uSprite, vUv ); + float a = tex.a * vCol.a; + if ( a < 0.0035 ) discard; + vec3 c = vCol.rgb * tex.rgb; + +#ifdef LIT + float rr = dot( vQ, vQ ); + vec3 nrm = vec3( vQ, sqrt( max( 0.03, 1.0 - rr ) ) ); + // Bend the fake sphere normal by the sprite's own density gradient: this is + // what turns a soft blob into something with legible internal form. + nrm = normalize( nrm - vec3( dFdx( tex.r ), dFdy( tex.r ), 0.0 ) * 7.0 ); + float ndl = dot( nrm, uSunDir ); + float wrap = max( 0.0, ( ndl + 0.42 ) / 1.42 ); + float back = max( 0.0, -ndl ); + float up = 0.5 + 0.5 * dot( nrm, uUpView ); + // Irradiance -> radiance: the 1/PI is what keeps a dust puff sitting at the + // same exposure as the wall behind it instead of blowing out white. + vec3 lit = ( mix( uAmbBot, uAmbTop, up ) + uSunCol * ( wrap * 0.9 + pow( back, 4.0 ) * 0.55 ) ) * 0.3183099; + lit *= mix( 1.0, 0.55, clamp( tex.a * 1.1, 0.0, 1.0 ) ); // self-shadowing by density + c *= lit; +#endif + +#ifdef SOFT + if ( uSoftEnable.x > 0.5 ) { + float sceneZ = texture2D( uDepth, gl_FragCoord.xy / uRes ).r; + sceneZ = sceneZ > 0.001 ? sceneZ : 1.0e6; // nothing drawn == infinitely far + a *= clamp( ( sceneZ - vViewZ ) / vSoft, 0.0, 1.0 ); + } +#endif + + // never let a sprite smear across the lens + a *= clamp( ( vViewZ - 0.05 ) / 0.2, 0.0, 1.0 ); + + float fogAmt = 1.0 - exp( -uFog.w * vViewZ ); +#ifdef ADDITIVE + c *= ( 1.0 - fogAmt ); + outColor = vec4( c * a, a ); +#else + c = mix( c, uFog.rgb, fogAmt ); + outColor = vec4( c, a ); +#endif +} +`; + +/* ------------------------------------------------------------------------- */ +/* ring-buffer storage */ +/* ------------------------------------------------------------------------- */ + +let quadGeoSource = null; + +function quadSource() { + if (!quadGeoSource) { + quadGeoSource = new THREE.PlaneGeometry(1, 1, 1, 1); + } + return quadGeoSource; +} + +/** + * A fixed-capacity ring of particles backed by one interleaved buffer. + * Allocation happens exactly once, in the constructor. + */ +export class ParticleLayer { + /** + * @param {object} o + * @param {number} o.capacity hard cap, from config.q.particleBudget + * @param {'additive'|'lit'} o.mode + * @param {THREE.Texture} o.atlas + * @param {number} o.cols atlas columns + * @param {boolean} [o.soft] depth-fade against the scene + */ + constructor(o) { + this.capacity = Math.max(16, o.capacity | 0); + this.mode = o.mode; + this.cursor = 0; + this.highWater = 0; + this.expireAt = -1; + this.spawned = 0; + + this.array = new Float32Array(this.capacity * STRIDE); + this.ibuf = new THREE.InstancedInterleavedBuffer(this.array, STRIDE, 1); + this.ibuf.setUsage(THREE.DynamicDrawUsage); + + const src = quadSource(); + const geo = new THREE.InstancedBufferGeometry(); + geo.index = src.index; + geo.setAttribute('position', src.getAttribute('position')); + geo.setAttribute('uv', src.getAttribute('uv')); + const bind = (name, offset) => + geo.setAttribute(name, new THREE.InterleavedBufferAttribute(this.ibuf, 4, offset)); + bind('aPS', O_PS); + bind('aVS', O_VS); + bind('aLife', O_LF); + bind('aRot', O_RT); + bind('aCol0', O_C0); + bind('aCol1', O_C1); + bind('aMisc', O_MS); + bind('aExtra', O_EX); + geo.instanceCount = 0; + // Particles are world-space in the shader; the mesh transform is identity + // and culling must never remove it. + geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e7); + this.geometry = geo; + + const additive = o.mode === 'additive'; + this.uniforms = { + uTime: { value: 0 }, + uAtlas: { value: new THREE.Vector2(o.cols, 1 / o.cols) }, + uSprite: { value: o.atlas }, + uDepth: { value: null }, + uRes: { value: new THREE.Vector2(1920, 1080) }, + uSoftEnable: { value: new THREE.Vector2(0, 0) }, + uSunDir: { value: new THREE.Vector3(0, 1, 0) }, + uSunCol: { value: new THREE.Vector3(1, 0.95, 0.86) }, + uAmbTop: { value: new THREE.Vector3(0.35, 0.42, 0.55) }, + uAmbBot: { value: new THREE.Vector3(0.16, 0.14, 0.12) }, + uUpView: { value: new THREE.Vector3(0, 1, 0) }, + uFog: { value: new THREE.Vector4(0.6, 0.65, 0.72, 0.0) }, + }; + + const defines = { SOFT: '' }; + if (additive) defines.ADDITIVE = ''; + else defines.LIT = ''; + if (o.soft === false) delete defines.SOFT; + + const mat = new THREE.ShaderMaterial({ + name: `fx-particles-${o.mode}`, + glslVersion: THREE.GLSL3, + uniforms: this.uniforms, + vertexShader: PARTICLE_VERT, + fragmentShader: PARTICLE_FRAG, + transparent: true, + depthTest: true, + depthWrite: false, + side: THREE.DoubleSide, + toneMapped: false, + defines, + blending: THREE.CustomBlending, + blendSrc: additive ? THREE.OneFactor : THREE.SrcAlphaFactor, + blendDst: additive ? THREE.OneFactor : THREE.OneMinusSrcAlphaFactor, + blendEquation: THREE.AddEquation, + }); + this.material = mat; + + this.mesh = new THREE.Mesh(geo, mat); + this.mesh.frustumCulled = false; + this.mesh.matrixAutoUpdate = false; + this.mesh.renderOrder = o.renderOrder ?? (additive ? 12 : 10); + this.mesh.visible = false; + this.mesh.name = `fx-particles-${o.mode}`; + // FX are not level content: keep the render probe's "is the world empty?" + // heuristic from counting our sprites as geometry. + this.mesh.userData.owProbe = true; + this.mesh.userData.owNoShadow = true; + + this._dirtyLo = Infinity; + this._dirtyHi = -Infinity; + this._wrapped = false; + } + + /** True while anything might still be alive. */ + get active() { + return this.mesh.visible; + } + + /** + * Write one particle. `s` is the shared {@link SP} descriptor — pass it after + * resetSpawn() so nothing leaks between call sites. + */ + emit(s, now) { + const i = this.cursor; + this.cursor = i + 1; + if (this.cursor >= this.capacity) { + this.cursor = 0; + this._wrapped = true; + } + if (i + 1 > this.highWater) this.highWater = i + 1; + + const a = this.array; + const b = i * STRIDE; + const life = Math.max(0.016, s.life); + const birth = now + s.delay; + + a[b + O_PS] = s.x; + a[b + O_PS + 1] = s.y; + a[b + O_PS + 2] = s.z; + a[b + O_PS + 3] = s.size0; + + a[b + O_VS] = s.vx; + a[b + O_VS + 1] = s.vy; + a[b + O_VS + 2] = s.vz; + a[b + O_VS + 3] = s.size1; + + a[b + O_LF] = birth; + a[b + O_LF + 1] = 1 / life; + a[b + O_LF + 2] = s.drag; + a[b + O_LF + 3] = s.gravity; + + a[b + O_RT] = s.rot; + a[b + O_RT + 1] = s.spin; + a[b + O_RT + 2] = s.stretch; + a[b + O_RT + 3] = s.sizeCurve; + + a[b + O_C0] = s.r0; + a[b + O_C0 + 1] = s.g0; + a[b + O_C0 + 2] = s.b0; + a[b + O_C0 + 3] = s.i0; + + a[b + O_C1] = s.r1; + a[b + O_C1 + 1] = s.g1; + a[b + O_C1 + 2] = s.b1; + a[b + O_C1 + 3] = s.i1; + + a[b + O_MS] = s.tile; + a[b + O_MS + 1] = s.soft; + a[b + O_MS + 2] = s.alpha; + a[b + O_MS + 3] = s.alphaCurve; + + a[b + O_EX] = s.turb; + a[b + O_EX + 1] = s.turbFreq; + a[b + O_EX + 2] = s.seed; + a[b + O_EX + 3] = s.flags; + + if (i < this._dirtyLo) this._dirtyLo = i; + if (i > this._dirtyHi) this._dirtyHi = i; + const end = birth + life; + if (end > this.expireAt) this.expireAt = end; + this.spawned++; + return i; + } + + /** Upload the dirty span and update per-frame uniforms. Call once per frame. */ + flush(now) { + if (this._dirtyHi >= this._dirtyLo) { + const start = this._dirtyLo * STRIDE; + const count = (this._dirtyHi - this._dirtyLo + 1) * STRIDE; + this.ibuf.addUpdateRange(start, count); + this.ibuf.needsUpdate = true; + this._dirtyLo = Infinity; + this._dirtyHi = -Infinity; + } + this.uniforms.uTime.value = now; + this.geometry.instanceCount = this._wrapped ? this.capacity : this.highWater; + this.mesh.visible = now < this.expireAt && this.geometry.instanceCount > 0; + } + + dispose() { + this.geometry.dispose(); + this.material.dispose(); + } +} + +/** Dispose the module-level quad prototype (called by the FX system). */ +export function disposeQuadSource() { + if (quadGeoSource) { + quadGeoSource.dispose(); + quadGeoSource = null; + } +} diff --git a/src/lib/cod/fx/useFootstepDust.test.ts b/src/lib/cod/fx/useFootstepDust.test.ts new file mode 100644 index 00000000..f84d58cd --- /dev/null +++ b/src/lib/cod/fx/useFootstepDust.test.ts @@ -0,0 +1,34 @@ +/** + * useFootstepDust — unit test. + * + * With `useThree` mocked to return no renderer (the jsdom / mocked-Canvas path), + * the hook builds no GPU objects and `emit`/`tick` are safe no-ops. Visible + * correctness (particles actually emit) is verified in a real browser (Playwright). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; + +vi.mock('@react-three/fiber', () => ({ + useThree: () => ({}), +})); + +import { useFootstepDust } from './useFootstepDust'; + +describe('useFootstepDust', () => { + it('returns emit + tick callbacks', () => { + const { result } = renderHook(() => useFootstepDust()); + expect(typeof result.current.emit).toBe('function'); + expect(typeof result.current.tick).toBe('function'); + }); + + it('is a no-op without a renderer (mocked useThree) — never throws', () => { + const { result, unmount } = renderHook(() => useFootstepDust()); + expect(() => { + result.current.emit(1, 0, 2, 'dirt'); + result.current.emit(1, 0, 2, 'unknown-surface'); + result.current.tick(0.016); + unmount(); + }).not.toThrow(); + }); +}); diff --git a/src/lib/cod/fx/useFootstepDust.ts b/src/lib/cod/fx/useFootstepDust.ts new file mode 100644 index 00000000..27863044 --- /dev/null +++ b/src/lib/cod/fx/useFootstepDust.ts @@ -0,0 +1,163 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; +import * as THREE from 'three'; +import { useThree } from '@react-three/fiber'; +// Vendored, framework-agnostic Claude-of-Duty GPU particle system (MIT — see +// src/lib/cod/fx/NOTICE.md). Standalone GLSL3 ShaderMaterial, r184-safe. +import { ParticleLayer, resetSpawn } from './particles'; +import { Rng } from '@/lib/cod/audio/rng'; + +/** Per-surface dust tint (the 12 physics SURFACE_NAMES → linear RGB). */ +const TINT: Record = { + concrete: [0.55, 0.55, 0.57], + metal: [0.5, 0.52, 0.55], + wood: [0.5, 0.37, 0.22], + dirt: [0.46, 0.35, 0.22], + sand: [0.74, 0.63, 0.42], + glass: [0.62, 0.64, 0.66], + water: [0.4, 0.46, 0.52], + foliage: [0.34, 0.4, 0.18], + fabric: [0.52, 0.47, 0.4], + flesh: [0.6, 0.42, 0.38], + rubber: [0.3, 0.3, 0.32], + plaster: [0.74, 0.71, 0.66], +}; +const DEFAULT_TINT: [number, number, number] = [0.46, 0.35, 0.22]; // dirt + +/** Particles kicked up per footstep. */ +const PUFF = 8; + +/** One 64² round-alpha sprite so the puff reads soft — no atlas.js needed. */ +function roundSprite(): THREE.DataTexture { + const S = 64; + const d = new Uint8Array(S * S * 4); + for (let y = 0; y < S; y++) { + for (let x = 0; x < S; x++) { + const i = (y * S + x) * 4; + const r = Math.hypot((x / (S - 1)) * 2 - 1, (y / (S - 1)) * 2 - 1); + const a = Math.max(0, 1 - r); + d[i] = 255; + d[i + 1] = 255; + d[i + 2] = 255; + d[i + 3] = a * a * 255; // soft round falloff + } + } + const t = new THREE.DataTexture(d, S, S, THREE.RGBAFormat); + t.needsUpdate = true; + return t; +} + +interface DustState { + layer: ParticleLayer; + sprite: THREE.DataTexture; + rng: Rng; + /** Monotonic seconds clock for the GPU sim (accumulated in tick). */ + now: number; +} + +export interface UseFootstepDust { + /** + * Emit a surface-tinted dust puff at world (x,y,z). Call on a footstep. + * `intensity` scales the particle count (e.g. more when sprinting). + */ + emit: ( + x: number, + y: number, + z: number, + surface: string, + intensity?: number + ) => void; + /** Advance + upload the particle sim. Call once per frame with the frame dt. */ + tick: (dt: number) => void; +} + +/** + * Surface-keyed footstep dust harvested from Claude-of-Duty's GPU particle layer. + * + * Must be used inside an R3F `` (reads `useThree().scene`). Driven + * imperatively (like `useFootsteps`): the caller's move loop calls `tick(dt)` + * every frame and `emit(...)` on each footstep. The particle sim runs entirely + * on the GPU (one instanced draw). SSR/jsdom-safe — no GPU objects are built + * without a renderer — and everything is disposed on unmount. + */ +export function useFootstepDust(capacity = 512): UseFootstepDust { + const { gl, scene } = useThree(); + + const state = useMemo(() => { + if (!gl) return null; // SSR / mocked-Canvas → no-op + const sprite = roundSprite(); + const layer = new ParticleLayer({ + capacity: Math.max(64, capacity), // e.g. from the quality tier's particleBudget + mode: 'lit', + atlas: sprite, + cols: 1, + soft: false, // drops the depth-texture dependency + }); + return { layer, sprite, rng: new Rng(0xf007), now: 0 }; + }, [gl, capacity]); + + useEffect(() => { + if (!state || !scene) return; + scene.add(state.layer.mesh); + return () => { + state.layer.mesh.parent?.remove(state.layer.mesh); + state.layer.dispose(); + state.sprite.dispose(); + }; + }, [state, scene]); + + const emit = useCallback( + (x: number, y: number, z: number, surface: string, intensity = 1) => { + if (!state) return; + const [r, g, b] = TINT[surface] ?? DEFAULT_TINT; + const rng = state.rng; + const count = Math.max(1, Math.round(PUFF * intensity)); + // Radial cone rising from the feet — mirrors impacts.js concrete dust. + for (let i = 0; i < count; i++) { + const s = resetSpawn(); + const ang = rng.float() * Math.PI * 2; + const sp = rng.range(0.2, 0.9); + s.x = x + rng.signed() * 0.05; + s.y = y + 0.04; + s.z = z + rng.signed() * 0.05; + s.vx = Math.cos(ang) * sp; + s.vy = rng.range(0.15, 0.5); + s.vz = Math.sin(ang) * sp; + s.size0 = 0.05; + s.size1 = rng.range(0.22, 0.42); + s.sizeCurve = 0.6; + s.life = rng.range(0.4, 0.85); + s.drag = rng.range(2.5, 4); + s.gravity = -0.7; + s.rot = ang; + s.spin = rng.signed() * 1.2; + s.r0 = r; + s.g0 = g; + s.b0 = b; + s.i0 = 1; + s.r1 = r * 0.8; + s.g1 = g * 0.8; + s.b1 = b * 0.8; + s.i1 = 1; + s.alpha = rng.range(0.35, 0.6); + s.alphaCurve = 1.5; + s.soft = 0.1; + s.seed = rng.float(); + state.layer.emit(s, state.now); + } + }, + [state] + ); + + const tick = useCallback( + (dt: number) => { + if (!state) return; + state.now += dt; + state.layer.flush(state.now); + }, + [state] + ); + + return { emit, tick }; +} diff --git a/src/lib/cod/fx/useWeather.ts b/src/lib/cod/fx/useWeather.ts new file mode 100644 index 00000000..22567adc --- /dev/null +++ b/src/lib/cod/fx/useWeather.ts @@ -0,0 +1,136 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; +import * as THREE from 'three'; +import { useThree } from '@react-three/fiber'; +// Reuses the vendored Claude-of-Duty GPU particle layer (MIT — src/lib/cod/fx/ +// NOTICE.md), the same system footstep dust uses — one additive instanced draw. +import { ParticleLayer, resetSpawn } from './particles'; +import { Rng } from '@/lib/cod/audio/rng'; + +export type WeatherKind = 'none' | 'rain'; + +/** A 64² sprite with a THIN vertical bright streak (fading top/bottom) so a + * billboarded particle reads as a rain streak, not a blob. */ +function streakSprite(): THREE.DataTexture { + const S = 64; + const d = new Uint8Array(S * S * 4); + for (let y = 0; y < S; y++) { + for (let x = 0; x < S; x++) { + const i = (y * S + x) * 4; + const cx = Math.abs((x / (S - 1)) * 2 - 1); // 0 centre → 1 edge (horizontal) + const cy = (y / (S - 1)) * 2 - 1; // −1..1 (vertical) + const band = Math.max(0, 1 - cx * 3.5); // vertical streak band + const vfade = Math.max(0, 1 - Math.abs(cy)); // fade the ends + const a = band * vfade; + d[i] = 255; + d[i + 1] = 255; + d[i + 2] = 255; + d[i + 3] = a * 255; + } + } + const t = new THREE.DataTexture(d, S, S, THREE.RGBAFormat); + t.needsUpdate = true; + return t; +} + +const RAIN_RATE = 700; // particles/second +const RAIN_RADIUS = 22; // emit-box half-extent around the camera (m) +const RAIN_TOP = 14; // spawn height above the camera (m) + +interface WeatherState { + layer: ParticleLayer; + sprite: THREE.DataTexture; + rng: Rng; + now: number; + accum: number; // fractional-particle accumulator for the emit rate +} + +export interface UseWeather { + /** Advance the sim + emit weather around the camera. Call every frame with dt. */ + tick: (dt: number) => void; +} + +/** + * Ambient weather for Walk mode, reusing the CoD GPU ParticleLayer. `kind` gates + * it — 'none' builds nothing (no GPU cost), 'rain' streams additive rain streaks + * in a box that FOLLOWS the camera, so it's always around the player. Driven + * imperatively (call `tick(dt)` each frame). SSR/jsdom-safe (no GPU objects + * without a renderer); disposed on unmount / when disabled. + */ +export function useWeather(kind: WeatherKind): UseWeather { + const { gl, scene, camera } = useThree(); + const active = kind === 'rain'; + + const state = useMemo(() => { + if (!gl || !active) return null; // SSR / disabled → no-op + const sprite = streakSprite(); + const layer = new ParticleLayer({ + capacity: 1024, + mode: 'additive', + atlas: sprite, + cols: 1, + soft: false, + }); + return { layer, sprite, rng: new Rng(0x9a1c3d), now: 0, accum: 0 }; + }, [gl, active]); + + useEffect(() => { + if (!state || !scene) return; + scene.add(state.layer.mesh); + return () => { + state.layer.mesh.parent?.remove(state.layer.mesh); + state.layer.dispose(); + state.sprite.dispose(); + }; + }, [state, scene]); + + const tick = useCallback( + (dt: number) => { + if (!state) return; + state.now += dt; + state.accum += RAIN_RATE * Math.min(dt, 0.05); + let n = Math.floor(state.accum); + state.accum -= n; + if (n > 60) n = 60; // cap a per-frame burst + const rng = state.rng; + const cx = camera.position.x, + cy = camera.position.y, + cz = camera.position.z; + for (let k = 0; k < n; k++) { + const s = resetSpawn(); + s.x = cx + rng.signed() * RAIN_RADIUS; + s.y = cy + RAIN_TOP + rng.float() * 4; + s.z = cz + rng.signed() * RAIN_RADIUS; + s.vx = rng.signed() * 0.8; // slight wind + s.vy = -18 - rng.float() * 6; // fast fall + s.vz = rng.signed() * 0.8; + s.gravity = -6; + s.drag = 0; + s.size0 = 0.95; + s.size1 = 0.95; + s.sizeCurve = 1; + s.life = 1.1; + s.rot = 0; + s.spin = 0; + s.r0 = 0.72; + s.g0 = 0.8; + s.b0 = 0.95; + s.i0 = 1; + s.r1 = 0.72; + s.g1 = 0.8; + s.b1 = 0.95; + s.i1 = 1; + s.alpha = 0.55; + s.alphaCurve = 1; + s.soft = 0; + s.seed = rng.float(); + state.layer.emit(s, state.now); + } + state.layer.flush(state.now); + }, + [state, camera] + ); + + return { tick }; +} diff --git a/src/lib/cod/index.ts b/src/lib/cod/index.ts new file mode 100644 index 00000000..3788d92d --- /dev/null +++ b/src/lib/cod/index.ts @@ -0,0 +1,67 @@ +/** + * Public API for the harvested Claude-of-Duty game toolkit (MIT). + * + * A procedural, asset-free React-Three-Fiber game toolkit: swept-capsule physics, + * a procedural PBR material forge, an atmospheric sky + IBL, procedural audio, + * a GPU particle system, camera-feel springs, and the core gems (event bus + + * quality tiers). Import everything from `@/lib/cod`. See ./README.md and the + * per-subdir NOTICE.md files (MIT attribution). + */ + +// ── React hooks (client) ────────────────────────────────────────────────────── +export { useFootsteps } from './audio/useFootsteps'; +export type { UseFootsteps } from './audio/useFootsteps'; +export { useAmbientCity } from './audio/ambientCity'; +export type { UseAmbientCity } from './audio/ambientCity'; +export { useFootstepDust } from './fx/useFootstepDust'; +export type { UseFootstepDust } from './fx/useFootstepDust'; +export { useWeather } from './fx/useWeather'; +export type { UseWeather, WeatherKind } from './fx/useWeather'; +export { useCameraFeel } from './player/useCameraFeel'; +export type { CameraFeel } from './player/useCameraFeel'; + +// ── Core gems ───────────────────────────────────────────────────────────────── +export { EventBus, bus } from './core/event-bus'; +export type { + GameEvents, + Handler, + Unsubscribe, + Vec3Payload, +} from './core/event-bus'; +export { QUALITY_PRESETS, QUALITY_TIERS, useQuality, setQuality } from './core/quality'; +export type { QualityTier, QualityPreset, UseQuality } from './core/quality'; + +// ── Physics (typed) ─────────────────────────────────────────────────────────── +export { StaticWorld } from './bvh'; +export type { HitRecord, AddMeshOptions } from './bvh'; +export { CharacterController } from './character'; +export type { CharacterControllerOptions, Vec3 } from './character'; + +// ── Embodied first-person controller (physics + gravity + stances) ──────────── +export { EmbodiedController } from './player/EmbodiedController'; +export type { + EmbodiedConfig, + EmbodiedInput, + Stance, + StanceCfg, + SprintCfg, + Vec3Like, +} from './player/EmbodiedController'; + +// ── Materials · particles · sky · springs (vendored JS, loose-typed) ────────── +export { MaterialSystem } from './materials'; +export { ParticleLayer, resetSpawn } from './fx/particles'; +export { SkyDome } from './sky/dome'; +export { Spring, RecoilAxis } from './springs'; + +// ── Surface vocabulary ──────────────────────────────────────────────────────── +export { + MASK, + SURFACE, + LAYER, + SURFACE_NAMES, + SURFACE_PROPS, + surfaceName, + surfaceIndex, + guessSurface, +} from './surfaces'; diff --git a/src/lib/cod/materials/NOTICE.md b/src/lib/cod/materials/NOTICE.md new file mode 100644 index 00000000..68bd5ce7 --- /dev/null +++ b/src/lib/cod/materials/NOTICE.md @@ -0,0 +1,12 @@ +# Vendored from Claude-of-Duty (MIT) — materials forge + +Source: https://github.com/mshumer/Claude-of-Duty (Matt Shumer), MIT (see ../LICENSE). + +Procedural PBR material forge (`three`-only, zero art assets). Bakes surface +albedo/normal/ORM textures to WebGLRenderTargets at load time via fullscreen +fragment passes. Framework-agnostic: the OVERWATCH `ctx` is fully bypassed by +the `new MaterialSystem({ renderer })` option + `init({})` (no rng/events/config +needed on the `getTextureSet` "plain" path). Only these 10 files are vendored +(the minimal `getTextureSet` set): index, generator, library, shader, masks + +glsl/{noise,surfaces-arch,-ground,-metal,-organic}. `shader.js`/`masks.js` are +import-time-only on the plain path (extendMaterial/bakeMasks unused here). diff --git a/src/lib/cod/materials/generator.js b/src/lib/cod/materials/generator.js new file mode 100644 index 00000000..20ccb2dc --- /dev/null +++ b/src/lib/cod/materials/generator.js @@ -0,0 +1,393 @@ +import * as THREE from 'three'; +import { NOISE_GLSL } from './glsl/noise.js'; +import { RUST_HELPERS } from './glsl/surfaces-metal.js'; + +/** + * GPU procedural texture forge. + * + * Every surface is one fragment program evaluated four times into four render + * targets — height (16F, scratch), albedo+height (sRGB8), ORM (linear8) and a + * tangent-space normal derived from the height field with a Sobel filter. + * Nothing is read back to the CPU; the render targets *are* the textures, so a + * full 1K set costs one framebuffer bind and four full-screen draws. + * + * Channel packing (this is the contract the material shader relies on): + * albedo.rgb = base colour (sRGB encoded by the hardware) + * albedo.a = height 0..1 (or the cutout mask for alpha-tested surfaces) + * orm.r = ambient occlusion / cavity + * orm.g = roughness (matches three's ORM convention) + * orm.b = metalness + * orm.a = 1 + * normal.rgb = tangent-space normal, OpenGL convention (+Y up) + */ + +const VERT = /* glsl */ ` +varying vec2 vUv; +void main(){ + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); +} +`; + +const HEADER = /* glsl */ ` +precision highp float; +varying vec2 vUv; +uniform float uSeed; +uniform int uOutput; +uniform vec3 uTintA; +uniform vec3 uTintB; +uniform vec4 uParam; +`; + +const FOOTER = /* glsl */ ` +void main(){ + vec3 alb = vec3(0.5); + float h = 0.5, rough = 0.5, metal = 0.0, ao = 1.0; + owSurface(vUv, alb, h, rough, metal, ao); + if (uOutput == 0) gl_FragColor = vec4(h, h, h, 1.0); + else if (uOutput == 1) gl_FragColor = vec4(alb, h); + else gl_FragColor = vec4(ao, rough, metal, 1.0); +} +`; + +const SOBEL = /* glsl */ ` +precision highp float; +varying vec2 vUv; +uniform sampler2D uHeight; +uniform vec2 uTexel; +uniform float uStrength; + +float H(vec2 o){ return texture2D(uHeight, vUv + o * uTexel).r; } + +void main(){ + float tl = H(vec2(-1.0, 1.0)), t = H(vec2(0.0, 1.0)), tr = H(vec2(1.0, 1.0)); + float l = H(vec2(-1.0, 0.0)), r = H(vec2(1.0, 0.0)); + float bl = H(vec2(-1.0, -1.0)), b = H(vec2(0.0, -1.0)), br = H(vec2(1.0, -1.0)); + + // Sobel over the height field; the 1/8 normalises the kernel weight. + float dx = ((tr + 2.0 * r + br) - (tl + 2.0 * l + bl)) * 0.125; + float dy = ((tl + 2.0 * t + tr) - (bl + 2.0 * b + br)) * 0.125; + + // dx/dy are per-texel; convert to a slope over the whole tile. + float sx = dx / uTexel.x; + float sy = dy / uTexel.y; + + vec3 n = normalize(vec3(-sx * uStrength, -sy * uStrength, 1.0)); + gl_FragColor = vec4(n * 0.5 + 0.5, 1.0); +} +`; + +/** + * Micro surface detail — the layer that stops close-ups looking like plastic. + * + * NYQUIST. The tile is 1024 px across 0.25 m, so one texel is 0.244 mm. With + * `p = uv * 8`, a term written at `p * K` puts 8K feature cells across 1024 + * texels, i.e. 128/K texels per cell. Anything past K≈24 is under five texels + * and bakes as white noise: at mip 0 it is salt-and-pepper dither and one mip + * down it has averaged to flat grey, which is exactly the "sandpaper close up, + * featureless at 2 m" failure. Every band here is therefore capped at K = 20 + * (6.4 texels, 1.6 mm) and given real amplitude instead. + */ +const DETAIL_SRC = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed; + // ~10 mm swell, ~3.5 mm tooth + float a = owFbm01(p * 3.0, P * 3.0, 4, 0.55); + float b = owFbm01(p * 9.0, P * 9.0, 4, 0.52); + // 3.9 mm pits and 1.6 mm grains — both wide enough to survive two mip levels + vec4 pores = owWorley(p * 8.0, P * 8.0, 1.0); + vec4 grit = owWorley(p * 20.0, P * 20.0, 1.0); + float scr = owScratches(p * 2.5, P * 2.5, 16.0, 1.0, 0.66) + + owScratches(p * 4.0 + 5.0, P * 4.0, 11.0, -2.0, 0.70) * 0.8; + // Proud grains: a solid, rounded bump rather than a threshold speck. + float gritA = smoothstep(0.34, 0.08, pores.x) * step(0.38, pores.z); + float gritB = smoothstep(0.30, 0.06, grit.x) * step(0.34, grit.z); + float pit = smoothstep(0.26, 0.0, pores.x) * step(0.72, pores.w); + h = 0.5 + (a - 0.5) * 0.34 + (b - 0.5) * 0.26; + h -= pit * 0.38; + h += gritA * 0.26 * (0.5 + grit.z) + gritB * 0.20; + h -= clamp(scr, 0.0, 1.0) * 0.18; + // Albedo tracks the grain so a proud grain reads light and its trough reads + // dark; the shader scales this by the per-surface detail albedo amount. + alb = vec3(0.5 + (a - 0.5) * 0.22 + (b - 0.5) * 0.15 + + gritA * 0.16 + gritB * 0.10 - pit * 0.14); + rough = 0.5 + (b - 0.5) * 0.5; + metal = 0.0; + ao = 1.0 - pit * 0.45 - gritB * 0.10; + h = clamp(h, 0.0, 1.0); +} +`; + +/** + * Four bands of low-frequency variation used by every material to break up + * tiling: R = very low fbm, G = warped blotches, B = mid fbm, A = fine fbm. + */ +const MACRO_SRC = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(6.0); + vec2 p = uv * P + uSeed * 3.0; + float a = owFbm01(p * 0.5, P * 0.5, 4, 0.62); + float b = owFbm01(owWarp(p * 1.0, P, 1.1, 3), P, 4, 0.58); + float c = owFbm01(p * 2.5, P * 2.5, 4, 0.55); + float d = owFbm01(p * 7.0, P * 7.0, 4, 0.5); + alb = vec3(a, b, c); + h = d; + rough = 0.5; metal = 0.0; ao = 1.0; +} +`; + +export class TextureForge { + /** + * @param {THREE.WebGLRenderer} renderer + * @param {{anisotropy?:number}} [opts] + */ + constructor(renderer, opts = {}) { + this.renderer = renderer; + this.anisotropy = Math.min( + opts.anisotropy ?? 8, + renderer.capabilities.getMaxAnisotropy?.() ?? 8 + ); + + this._camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + this._geo = new THREE.PlaneGeometry(2, 2); + this._scene = new THREE.Scene(); + this._mesh = new THREE.Mesh(this._geo, null); + this._mesh.frustumCulled = false; + this._scene.add(this._mesh); + + this._sobelMat = new THREE.ShaderMaterial({ + vertexShader: VERT, + fragmentShader: SOBEL, + uniforms: { + uHeight: { value: null }, + uTexel: { value: new THREE.Vector2() }, + uStrength: { value: 1 }, + }, + depthTest: false, + depthWrite: false, + }); + + /** scratch height targets keyed by size */ + this._heightRTs = new Map(); + this._owned = []; + this._programs = new Map(); + } + + _heightRT(size) { + let rt = this._heightRTs.get(size); + if (!rt) { + // Half-float keeps the Sobel free of the stair-stepping an 8-bit height + // field produces. Fall back to 8-bit if the context can't render to it. + const canHalf = + this.renderer.extensions.has('EXT_color_buffer_float') || + this.renderer.extensions.has('EXT_color_buffer_half_float'); + rt = new THREE.WebGLRenderTarget(size, size, { + type: canHalf ? THREE.HalfFloatType : THREE.UnsignedByteType, + format: THREE.RGBAFormat, + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + wrapS: THREE.RepeatWrapping, + wrapT: THREE.RepeatWrapping, + generateMipmaps: false, + depthBuffer: false, + stencilBuffer: false, + }); + this._heightRTs.set(size, rt); + } + return rt; + } + + _target(size, { srgb = false } = {}) { + const rt = new THREE.WebGLRenderTarget(size, size, { + type: THREE.UnsignedByteType, + format: THREE.RGBAFormat, + colorSpace: srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace, + minFilter: THREE.LinearMipmapLinearFilter, + magFilter: THREE.LinearFilter, + wrapS: THREE.RepeatWrapping, + wrapT: THREE.RepeatWrapping, + generateMipmaps: true, + depthBuffer: false, + stencilBuffer: false, + }); + rt.texture.anisotropy = this.anisotropy; + this._owned.push(rt); + return rt; + } + + _material(key, glsl) { + let mat = this._programs.get(key); + if (!mat) { + mat = new THREE.ShaderMaterial({ + vertexShader: VERT, + fragmentShader: HEADER + NOISE_GLSL + RUST_HELPERS + glsl + FOOTER, + uniforms: { + uSeed: { value: 0 }, + uOutput: { value: 0 }, + uTintA: { value: new THREE.Color(1, 1, 1) }, + uTintB: { value: new THREE.Color(1, 1, 1) }, + uParam: { value: new THREE.Vector4() }, + }, + depthTest: false, + depthWrite: false, + }); + this._programs.set(key, mat); + } + return mat; + } + + /** + * Build one texture set. + * @param {object} def + * @param {string} def.key cache key / program key + * @param {string} def.glsl surface source implementing owSurface() + * @param {number} def.size square resolution + * @param {number} def.seed + * @param {number} def.worldSize metres the tile spans (drives normal slope) + * @param {number} def.relief peak-to-trough depth in metres + * @param {THREE.Color} [def.tintA] + * @param {THREE.Color} [def.tintB] + * @param {boolean} [def.orm=true] allocate + render the ORM output + * @param {boolean} [def.normal=true] allocate + render the tangent normal + * + * A surface set needs all three outputs, but the two shared maps do not: the + * material shader only ever samples the detail *albedo* and *normal* and the + * macro *albedo* (see shader.js — owDetailTex / owDetailNrm / owMacroTex). + * Baking the outputs nobody reads cost a 1K RGBA8 mip chain (5.6 MB), two + * 256px ones, and four full-screen evaluations of the noise stack at boot. + */ + build(def) { + const r = this.renderer; + const size = def.size; + const wantOrm = def.orm !== false; + const wantNormal = def.normal !== false; + const prevTarget = r.getRenderTarget(); + const prevAutoClear = r.autoClear; + r.autoClear = false; + + const mat = this._material(def.key, def.glsl); + mat.uniforms.uSeed.value = def.seed ?? 0; + if (def.tintA) mat.uniforms.uTintA.value.copy(def.tintA); + if (def.tintB) mat.uniforms.uTintB.value.copy(def.tintB); + if (def.param) mat.uniforms.uParam.value.copy(def.param); + this._mesh.material = mat; + + const albedoRT = this._target(size, { srgb: def.linearAlbedo !== true }); + const ormRT = wantOrm ? this._target(size) : null; + const normalRT = wantNormal ? this._target(size) : null; + + // The height pass exists only to feed the Sobel, so it is skipped with it. + let heightRT = null; + if (wantNormal) { + heightRT = this._heightRT(size); + mat.uniforms.uOutput.value = 0; + r.setRenderTarget(heightRT); + r.render(this._scene, this._camera); + } + + mat.uniforms.uOutput.value = 1; + r.setRenderTarget(albedoRT); + r.render(this._scene, this._camera); + + if (ormRT) { + mat.uniforms.uOutput.value = 2; + r.setRenderTarget(ormRT); + r.render(this._scene, this._camera); + } + + // Height -> normal. Slope is (relief metres / worldSize metres) so the + // normal map is physically consistent with the mapping scale used later. + if (normalRT) { + this._mesh.material = this._sobelMat; + this._sobelMat.uniforms.uHeight.value = heightRT.texture; + this._sobelMat.uniforms.uTexel.value.set(1 / size, 1 / size); + this._sobelMat.uniforms.uStrength.value = (def.relief ?? 0.02) / (def.worldSize ?? 2); + r.setRenderTarget(normalRT); + r.render(this._scene, this._camera); + } + + r.setRenderTarget(prevTarget); + r.autoClear = prevAutoClear; + + return { + albedo: albedoRT.texture, + orm: ormRT?.texture ?? null, + normal: normalRT?.texture ?? null, + size, + worldSize: def.worldSize ?? 2, + relief: def.relief ?? 0.02, + }; + } + + /** + * Free the scratch height targets. + * + * They are pure intermediates — the Sobel pass reads one and nothing else + * ever does — but at 16F RGBA they are the single largest allocation the + * forge makes (a 1K one is 8 MB, and the 1K/512/256 set is ~10.5 MB) and they + * were being held for the whole session for the sake of bakes that all happen + * during boot. `_heightRT()` recreates on demand, so a late bake still works; + * it just pays for the allocation again. + */ + releaseScratch() { + let freed = 0; + for (const rt of this._heightRTs.values()) { + rt.dispose(); + freed++; + } + this._heightRTs.clear(); + this._sobelMat.uniforms.uHeight.value = null; + return freed; + } + + /** Shared micro-detail normal + a matching micro albedo/roughness. */ + buildDetail(size = 1024, seed = 1) { + return this.build({ + key: '__detail', + glsl: DETAIL_SRC, + size, + seed, + worldSize: 0.25, + // 1.6 mm grain standing ~0.4 mm proud: a real tooth, not a bump-map hint + relief: 0.0034, + // The detail map is DATA, not colour. Stored sRGB-encoded, a value of + // 0.5 came back as 0.21 linear, so the shader's `dTex.r - 0.5` term was + // biased to -0.29 and could only ever darken — half the micro layer was + // a constant tint rather than a texture. + linearAlbedo: true, + // Only the albedo (micro albedo/roughness in rgb, height in a) and the + // derived normal are sampled; the ORM output was never bound anywhere. + orm: false, + }); + } + + /** Shared 4-band low-frequency variation map. */ + buildMacro(size = 256, seed = 2) { + // Macro is data, not colour — it must be stored and sampled linearly. + return this.build({ + key: '__macro', + glsl: MACRO_SRC, + size, + seed, + worldSize: 32, + relief: 0.5, + linearAlbedo: true, + // Four bands packed into the albedo output is the whole map; the macro + // ORM and macro normal were baked and then never sampled. + orm: false, + normal: false, + }); + } + + dispose() { + for (const rt of this._heightRTs.values()) rt.dispose(); + for (const rt of this._owned) rt.dispose(); + for (const m of this._programs.values()) m.dispose(); + this._heightRTs.clear(); + this._owned.length = 0; + this._programs.clear(); + this._sobelMat.dispose(); + this._geo.dispose(); + } +} diff --git a/src/lib/cod/materials/glsl/noise.js b/src/lib/cod/materials/glsl/noise.js new file mode 100644 index 00000000..6bb967e4 --- /dev/null +++ b/src/lib/cod/materials/glsl/noise.js @@ -0,0 +1,218 @@ +/** + * Tileable procedural noise library (GLSL, shared by every surface generator). + * + * Everything here is *periodic*: each function takes a `per` (period, in lattice + * cells) and wraps its hash lattice with mod(), so a texture generated over + * uv in [0,1) with p = uv * per tiles seamlessly. Octaves double both the + * frequency and the period, which keeps the whole fbm stack seamless. + * + * Hashes are sin-free (Dave Hoskins style) — sin() based hashes band badly on + * Apple GPUs at high lattice coordinates. + */ +export const NOISE_GLSL = /* glsl */ ` + +// ---------------------------------------------------------------- hashes ---- +float owHash11(float p){ + p = fract(p * 0.1031); + p *= p + 33.33; + p *= p + p; + return fract(p); +} +float owHash12(vec2 p){ + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} +vec2 owHash22(vec2 p){ + vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.xx + p3.yz) * p3.zy); +} +vec3 owHash32(vec2 p){ + vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yxz + 33.33); + return fract((p3.xxy + p3.yzz) * p3.zyx); +} +vec4 owHash42(vec2 p){ + vec4 p4 = fract(vec4(p.xyxy) * vec4(0.1031, 0.1030, 0.0973, 0.1099)); + p4 += dot(p4, p4.wzxy + 33.33); + return fract((p4.xxyz + p4.yzzw) * p4.zywx); +} + +// ------------------------------------------------------- gradient noise ---- +vec2 owGrad2(vec2 i, vec2 per){ + float a = owHash12(mod(i, per) + 0.317) * 6.28318530718; + return vec2(cos(a), sin(a)); +} + +/** Periodic gradient (Perlin) noise. Returns ~[-1,1]. */ +float owNoise(vec2 p, vec2 per){ + vec2 i = floor(p), f = fract(p); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + float a = dot(owGrad2(i + vec2(0.0, 0.0), per), f - vec2(0.0, 0.0)); + float b = dot(owGrad2(i + vec2(1.0, 0.0), per), f - vec2(1.0, 0.0)); + float c = dot(owGrad2(i + vec2(0.0, 1.0), per), f - vec2(0.0, 1.0)); + float d = dot(owGrad2(i + vec2(1.0, 1.0), per), f - vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 1.4142; +} +float owNoise01(vec2 p, vec2 per){ return owNoise(p, per) * 0.5 + 0.5; } + +/** Periodic value noise — blockier, good for cell-ish tint variation. */ +float owValue(vec2 p, vec2 per){ + vec2 i = floor(p), f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = owHash12(mod(i + vec2(0.0, 0.0), per) + 1.7); + float b = owHash12(mod(i + vec2(1.0, 0.0), per) + 1.7); + float c = owHash12(mod(i + vec2(0.0, 1.0), per) + 1.7); + float d = owHash12(mod(i + vec2(1.0, 1.0), per) + 1.7); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +// ------------------------------------------------------------------ fbm ---- +float owFbm(vec2 p, vec2 per, int oct, float gain){ + float s = 0.0, a = 0.5, n = 0.0; + for (int i = 0; i < 10; i++){ + if (i >= oct) break; + s += a * owNoise(p, per); + n += a; + p *= 2.0; per *= 2.0; a *= gain; + } + return s / max(n, 1e-4); +} +float owFbm01(vec2 p, vec2 per, int oct, float gain){ return owFbm(p, per, oct, gain) * 0.5 + 0.5; } + +/** Ridged fbm — sharp creases, good for cracks / rock. Returns [0,1]. */ +float owRidged(vec2 p, vec2 per, int oct, float gain){ + float s = 0.0, a = 0.5, n = 0.0; + for (int i = 0; i < 10; i++){ + if (i >= oct) break; + float v = 1.0 - abs(owNoise(p, per)); + s += a * v * v; + n += a; + p *= 2.0; per *= 2.0; a *= gain; + } + return s / max(n, 1e-4); +} + +/** Billowy fbm — puffy clumps, good for rust blooms and clay. */ +float owBillow(vec2 p, vec2 per, int oct, float gain){ + float s = 0.0, a = 0.5, n = 0.0; + for (int i = 0; i < 10; i++){ + if (i >= oct) break; + s += a * abs(owNoise(p, per)); + n += a; + p *= 2.0; per *= 2.0; a *= gain; + } + return s / max(n, 1e-4); +} + +// --------------------------------------------------------- domain warp ----- +vec2 owWarp(vec2 p, vec2 per, float amp, int oct){ + vec2 q = vec2(owFbm(p + vec2(1.7, 9.2), per, oct, 0.5), + owFbm(p + vec2(8.3, 2.8), per, oct, 0.5)); + return p + amp * q; +} + +// -------------------------------------------------------------- worley ----- +/** + * Periodic Worley/Voronoi. + * .x = F1 distance, .y = F2 distance, .z = hash id of the F1 cell, + * .w = second hash of the F1 cell. + */ +vec4 owWorley(vec2 p, vec2 per, float jitter){ + vec2 ip = floor(p), fp = fract(p); + float f1 = 8.0, f2 = 8.0; + vec2 id = vec2(0.0); + for (int y = -1; y <= 1; y++){ + for (int x = -1; x <= 1; x++){ + vec2 g = vec2(float(x), float(y)); + vec2 cell = mod(ip + g, per); + vec2 o = owHash22(cell + 0.771) * jitter + (1.0 - jitter) * 0.5; + vec2 r = g + o - fp; + float d = dot(r, r); + if (d < f1){ f2 = f1; f1 = d; id = owHash22(cell + 3.117); } + else if (d < f2){ f2 = d; } + } + } + return vec4(sqrt(f1), sqrt(f2), id); +} + +/** + * Distance to the *edge* of the Voronoi cell (Quilez two-pass). Much better + * looking crack networks than F2-F1. Returns [0, ~0.7]. + */ +float owVoronoiEdge(vec2 p, vec2 per, float jitter){ + vec2 ip = floor(p), fp = fract(p); + vec2 mr = vec2(0.0), mg = vec2(0.0); + float md = 8.0; + for (int y = -1; y <= 1; y++){ + for (int x = -1; x <= 1; x++){ + vec2 g = vec2(float(x), float(y)); + vec2 o = owHash22(mod(ip + g, per) + 0.771) * jitter + (1.0 - jitter) * 0.5; + vec2 r = g + o - fp; + float d = dot(r, r); + if (d < md){ md = d; mr = r; mg = g; } + } + } + md = 8.0; + for (int y = -2; y <= 2; y++){ + for (int x = -2; x <= 2; x++){ + vec2 g = mg + vec2(float(x), float(y)); + vec2 o = owHash22(mod(ip + g, per) + 0.771) * jitter + (1.0 - jitter) * 0.5; + vec2 r = g + o - fp; + vec2 diff = r - mr; + if (dot(diff, diff) > 1e-5){ + md = min(md, dot(0.5 * (mr + r), normalize(diff))); + } + } + } + return md; +} + +/** + * Crack network: warped voronoi edges, thinned and broken up so lines + * terminate instead of forming a perfect mesh. Returns [0,1], 1 = deep crack. + */ +float owCracks(vec2 p, vec2 per, float jitter, float width, float breakUp){ + vec2 wp = owWarp(p, per, 0.20, 3); + float e = owVoronoiEdge(wp, per, jitter); + float c = 1.0 - smoothstep(0.0, width, e); + // Break the network so it reads as damage, not as a net. + float mask = owFbm01(p * 1.7 + 11.3, per * 1.7, 4, 0.55); + c *= smoothstep(breakUp, breakUp + 0.28, mask); + return clamp(c, 0.0, 1.0); +} + +// ------------------------------------------------------------ utilities ---- +float owSat(float x){ return clamp(x, 0.0, 1.0); } +vec3 owSat3(vec3 x){ return clamp(x, 0.0, 1.0); } +float owRemap(float x, float a, float b, float c, float d){ + return c + (d - c) * clamp((x - a) / max(b - a, 1e-5), 0.0, 1.0); +} +vec2 owRot(vec2 p, float a){ + float s = sin(a), c = cos(a); + return mat2(c, -s, s, c) * p; +} +/** sRGB hex-ish helper: authoring colours in gamma space, output linear. */ +vec3 owSRGB(vec3 c){ + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, step(c, vec3(0.04045))); +} +/** + * Anisotropic shear that preserves tileability: 'k' and 'stretch' must be + * integers so the lattice still wraps on 'per'. + */ +vec2 owShear(vec2 p, float k, float stretch){ + return vec2(p.x + p.y * k, p.y * stretch); +} +vec2 owShearPer(vec2 per, float stretch){ + return vec2(per.x, per.y * stretch); +} + +/** Scratch lines: long thin streaks running along a sheared axis. [0,1]. */ +float owScratches(vec2 p, vec2 per, float stretch, float k, float thin){ + vec2 q = owShear(p, k, stretch); + vec2 qper = owShearPer(per, stretch); + float n = owFbm01(q, qper, 4, 0.5); + return smoothstep(thin, thin + 0.06, n) * (1.0 - smoothstep(thin + 0.06, thin + 0.2, n)); +} +`; diff --git a/src/lib/cod/materials/glsl/surfaces-arch.js b/src/lib/cod/materials/glsl/surfaces-arch.js new file mode 100644 index 00000000..35c16ebf --- /dev/null +++ b/src/lib/cod/materials/glsl/surfaces-arch.js @@ -0,0 +1,563 @@ +/** + * Architectural surfaces: concrete, brick, plaster, stucco, ceramic tile. + * + * Every surface implements: + * void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, + * out float metal, out float ao) + * 'uv' is [0,1) across the tile, 'h' is 0..1 (0.5 ≈ the nominal surface plane), + * 'alb' is LINEAR albedo (authored via owSRGB() so the numbers read like paint + * swatches), and 'ao' is a baked cavity term, not a lighting term. + * + * uSeed shifts the noise lattice so two variants of the same surface never + * line up. Shifting the argument of a periodic function keeps it periodic. + */ + +export const CONCRETE = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 13.7; + + // ---- base tone: pour variation, wet/dry patches, cement bloom ---- + float macro = owFbm01(p * 0.5, P * 0.5, 4, 0.58); + float mid = owFbm01(owWarp(p * 2.0, P * 2.0, 0.7, 3), P * 2.0, 5, 0.5); + float fine = owFbm01(p * 18.0, P * 18.0, 4, 0.5); + float micro = owFbm01(p * 26.0, P * 26.0, 3, 0.5); + + vec3 cLight = owSRGB(vec3(0.520, 0.512, 0.492)); + vec3 cMid = owSRGB(vec3(0.395, 0.392, 0.385)); + vec3 cDark = owSRGB(vec3(0.255, 0.253, 0.258)); + vec3 c = mix(cMid, cLight, smoothstep(0.35, 0.85, macro)); + c = mix(c, cDark, smoothstep(0.55, 0.95, mid) * 0.55); + c *= 0.93 + 0.14 * fine; + // The 0.1-1 m band — see the long note in PLASTER. Pour blotching and the + // wash of dirt that runs over any concrete left outdoors. + // contrast-expanded: see the note in PLASTER + float pourB = owFbm01(owWarp(p * 1.5 + 8.3, P * 1.5, 0.6, 3), P * 1.5, 4, 0.58); + pourB = clamp((pourB - 0.5) * 2.5 + 0.5, 0.0, 1.0); + c *= 0.82 + 0.38 * pourB; + float wash = owFbm01(p * 7.0 + 2.0, P * 7.0, 4, 0.5); + wash = clamp((wash - 0.5) * 2.2 + 0.5, 0.0, 1.0); + c *= 0.925 + 0.155 * wash; + + h = 0.62 + (fine - 0.5) * 0.035 + (mid - 0.5) * 0.05; + rough = 0.70 + (mid - 0.5) * 0.16 + (micro - 0.5) * 0.07; + ao = 1.0; + metal = 0.0; + + // ---- exposed aggregate: stone chips sitting just under the skin ---- + vec4 agg = owWorley(p * 13.0, P * 13.0, 0.95); + float aggShape = smoothstep(0.46, 0.10, agg.x); + float aggRnd = agg.z; + // Only some chips break the surface. + float aggExposed = aggShape * step(0.74, owFbm01(p * 3.0 + 5.0, P * 3.0, 3, 0.5) + aggRnd * 0.35); + h += aggExposed * 0.022 * (0.5 + aggRnd); + c = mix(c, mix(owSRGB(vec3(0.335, 0.320, 0.300)), owSRGB(vec3(0.560, 0.545, 0.505)), aggRnd), aggExposed * 0.7); + rough += aggExposed * 0.07 * (aggRnd - 0.5); + + // ---- coarse sand fraction: the 5-8 mm grit of the cement skin ---- + // The 0.5-2 mm tooth is NOT authored here. At 2.5 m over a 1024 bake one + // texel is 2.4 mm, so a 1 mm grain is a sub-texel hash: it bakes as white + // noise, dithers at mip 0 and is gone by mip 1. That band belongs to the + // shared detail map, which is tiled ten times finer. What lives here is the + // grit you can actually resolve, at real amplitude. + vec4 sand = owWorley(p * 20.0, P * 20.0, 1.0); + float sandM = smoothstep(0.44, 0.05, sand.x); + float sandSel = 0.40 + 0.60 * step(0.30, sand.z); + h += sandM * sandSel * 0.028; + c *= 1.0 + (sandM * sandSel - 0.20) * 0.15; + rough += (sand.z - 0.5) * 0.11 + sandM * 0.04; + ao -= sandM * 0.06; + float sandTrough = smoothstep(0.52, 0.88, sand.x); + c = mix(c, c * 0.86, sandTrough * 0.34); + + // ---- air pockets / bug holes from the pour ---- + vec4 pores = owWorley(p * 22.0, P * 22.0, 1.0); + float pore = smoothstep(0.26, 0.0, pores.x) * step(0.84, pores.w); + h -= pore * 0.055; + ao -= pore * 0.55; + rough += pore * 0.10; + + // uParam.x = board-formed wall (1) vs poured slab (0) + // uParam.y = saw-cut control joints, for floors + float formAmt = uParam.x; + float jointAmt = uParam.y; + + // ---- formwork: horizontal board lines + tie-rod holes ---- + float boards = uv.y * 4.0; + float bi = floor(boards); + float bf = fract(boards); + float seam = (1.0 - smoothstep(0.0, 0.030, bf)) + (1.0 - smoothstep(0.0, 0.030, 1.0 - bf)); + seam = clamp(seam, 0.0, 1.0); + // Boards are never perfectly aligned: each course steps a fraction of a mm. + float boardStep = (owHash11(bi + uSeed) - 0.5) * 0.028 * formAmt; + h += boardStep; + h -= seam * 0.055 * formAmt; + ao -= seam * 0.40 * formAmt; + c *= 1.0 - seam * 0.16 * formAmt; + // cement bled along the seam and set lighter + float bleed = (1.0 - smoothstep(0.0, 0.10, abs(bf - 0.02))) * 0.5 * formAmt; + c = mix(c, cLight * 1.05, bleed * 0.35 * owFbm01(p * 8.0, P * 8.0, 3, 0.5)); + + // tie holes, plugged, one every other board + vec2 tf = fract(vec2(uv.x * 3.0, boards * 0.5)) - 0.5; + float tieRnd = owHash12(floor(vec2(uv.x * 3.0, boards * 0.5)) + uSeed); + float tie = smoothstep(0.085, 0.05, length(tf * vec2(1.0, 2.0))) * step(0.45, tieRnd) * formAmt; + h -= tie * 0.10; + ao -= tie * 0.5; + c = mix(c, cDark * 0.85, tie * 0.6); + + // ---- saw-cut control joints (slabs) + power-float polish ---- + vec2 jd = abs(fract(uv + 0.5) - 0.5); + float joint = max(1.0 - smoothstep(0.0035, 0.010, jd.x), 1.0 - smoothstep(0.0035, 0.010, jd.y)); + joint *= jointAmt; + h -= joint * 0.10; + ao -= joint * 0.55; + c = mix(c, cDark * 0.62, joint * 0.65); + // trowel arcs left by the power float + float swirl = owFbm01(owWarp(p * 1.1 + 3.0, P * 1.1, 1.4, 3), P * 1.1, 3, 0.6); + rough -= jointAmt * smoothstep(0.35, 0.85, swirl) * 0.10; + c *= 1.0 - jointAmt * smoothstep(0.4, 0.9, swirl) * 0.07; + + // ---- structural cracks: branch from the seams and corners ---- + float crk = owCracks(p * 2.6, P * 2.6, 0.85, 0.028, 0.50); + float crkFine = owCracks(p * 7.0 + 31.0, P * 7.0, 0.9, 0.020, 0.60) * 0.55; + float crack = clamp(crk + crkFine, 0.0, 1.0); + h -= crack * 0.12; + ao -= crack * 0.45; + c = mix(c, cDark * 0.80, crack * 0.42); + rough += crack * 0.12; + + // ---- spalling: a chunk of the skin has broken off, aggregate showing ---- + vec4 sp = owWorley(p * 1.1 + 7.3, P * 1.1, 0.9); + float spallCell = step(0.90, sp.w); + float spall = spallCell * smoothstep(0.44, 0.16, sp.x) * + smoothstep(0.42, 0.62, owFbm01(p * 4.0 + 2.0, P * 4.0, 4, 0.5)); + h -= spall * 0.13; + ao -= spall * 0.35; + c = mix(c, mix(cDark, cMid, aggRnd) * 0.88, spall * 0.8); + rough += spall * 0.10; + // rim of the spall catches light + float spallRim = spall * (1.0 - spall) * 4.0; + c *= 1.0 + spallRim * 0.10; + + // ---- small chips: 2-5 cm bites out of the skin showing darker, wetter + // concrete plus the sand fraction underneath (~3% of the surface) ---- + vec4 ck = owWorley(owWarp(p * 5.6 + 19.0, P * 5.6, 0.6, 3), P * 5.6, 0.95); + float ckSel = step(0.90, ck.w); + float ckSize = 0.20 + 0.16 * ck.z; + float ckShape = smoothstep(ckSize, ckSize * 0.3, + ck.x * (0.72 + 0.56 * owFbm01(p * 16.0, P * 16.0, 3, 0.5))); + float chip = ckSel * ckShape; + c = mix(c, mix(c * 0.74, mix(cDark, cMid, sand.z), 0.5), chip * 0.85); + h -= chip * 0.045; + ao -= chip * 0.24; + rough += chip * 0.08; + float ckLip = max(ckSel * (smoothstep(ckSize * 1.25, ckSize, ck.x) - ckShape), 0.0); + c *= 1.0 + ckLip * 0.10; + + // ---- staining: rain runoff, soot, rust bleed from rebar ---- + // Only ~3:1 stretched and shallow: the long runs come from the runtime + // weather layer, which knows where the sills and ledges are. A 10:1 stretch + // baked into the tile at full strength just reads as wood veneer. + float streak = owFbm01(vec2(p.x * 6.0, p.y * 2.0), vec2(P.x * 6.0, P.y * 2.0), 5, 0.55); + float runoff = smoothstep(0.58, 0.95, streak) * (0.35 + 0.65 * smoothstep(0.2, 0.8, macro)); + c *= 1.0 - runoff * 0.14; + rough += runoff * 0.05; + + float rustBleed = smoothstep(0.72, 0.98, streak * (0.6 + 0.5 * tieRnd)) * step(0.80, tieRnd); + c = mix(c, owSRGB(vec3(0.42, 0.24, 0.12)), rustBleed * 0.45); + + // dirt collects in every recess + float cavity = 1.0 - smoothstep(0.42, 0.66, h); + c = mix(c, owSRGB(vec3(0.20, 0.19, 0.17)), cavity * 0.35); + + alb = clamp(c, vec3(0.02), vec3(0.85)); + rough = clamp(rough, 0.48, 0.98); + ao = clamp(ao, 0.15, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const BRICK = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + const float COLS = 6.0; // bricks across the tile + const float ROWS = 18.0; // courses up the tile + vec2 p = uv * P + uSeed * 9.1; + + // ---------------- brick lattice, running bond ---------------- + float rowF = uv.y * ROWS; + float row = floor(rowF); + float colF = uv.x * COLS + mod(row, 2.0) * 0.5; + float col = floor(colF); + vec2 id = vec2(mod(col, COLS), row); + vec2 f = vec2(fract(colF), fract(rowF)); + + vec4 rnd = owHash42(id + uSeed * 3.0); + vec4 rnd2 = owHash42(id * 1.37 + 21.0 + uSeed); + vec4 rnd3 = owHash42(id * 0.73 + 7.7 + uSeed * 1.9); + + // Bricks are laid by hand: each one is a hair off square. + vec2 jitter = (rnd.xy - 0.5) * vec2(0.012, 0.030); + vec2 fj = f + jitter; + + // joint thickness (10mm of a 225mm x 75mm course). The joint is *raked*: a + // flat mortar bed with a hard arris at the brick edge. Ramping across the + // whole joint width is what makes mortar read as a painted line. + const float JX = 0.048, JY = 0.135; + float dxj = min(fj.x, 1.0 - fj.x); + float dyj = min(fj.y, 1.0 - fj.y); + float shoulder = 0.74 + 0.16 * rnd3.w; // some joints struck flush, some sharp + float ex = smoothstep(JX * shoulder, JX * 1.02, dxj); + float ey = smoothstep(JY * shoulder, JY * 1.02, dyj); + float face = min(ex, ey); // 1 = brick face, 0 = mortar + + // per-brick surface coords so the face texture never repeats + vec2 bp = vec2(fj.x, fj.y) * vec2(3.0, 1.0) + rnd.zw * 17.0; + vec2 BP = vec2(24.0); + + // ---------------- mortar ---------------- + float mSand = owFbm01(p * 20.0, P * 20.0, 4, 0.5); + vec4 mGrain = owWorley(p * 24.0, P * 24.0, 1.0); + float mortarRough = owFbm01(p * 20.0, P * 20.0, 4, 0.55); + vec3 mortarCol = mix(owSRGB(vec3(0.400, 0.388, 0.362)), owSRGB(vec3(0.278, 0.272, 0.260)), + smoothstep(0.3, 0.8, mortarRough)); + mortarCol *= 0.84 + 0.32 * mSand; + mortarCol *= 0.88 + 0.24 * owFbm01(p * 6.0, P * 6.0, 4, 0.6); + mortarCol = mix(mortarCol, owSRGB(vec3(0.235, 0.228, 0.215)), smoothstep(0.5, 0.06, mGrain.x) * 0.40); + mortarCol = mix(mortarCol, owSRGB(vec3(0.520, 0.505, 0.470)), smoothstep(0.30, 0.02, owWorley(p * 25.0 + 4.0, P * 25.0, 1.0).x) * 0.35); + + // some joints are struck flush, some are raked deep, some crumbled out. + // 0.10-0.15 of a 0.055 m relief = 5-8 mm of real recess. + float jointDepth = 0.10 + 0.05 * owFbm01(p * 1.2, P * 1.2, 3, 0.5); + float crumble = smoothstep(0.62, 0.86, owFbm01(p * 9.0 + 4.0, P * 9.0, 4, 0.5)); + jointDepth += crumble * 0.09; + // the mortar bed itself is not flat — it holds the trowel's sand texture + float mortarH = -(mSand - 0.5) * 0.018 - smoothstep(0.5, 0.0, mGrain.x) * 0.012; + + // ---------------- brick face ---------------- + float faceN = owFbm01(bp * 2.2, BP, 5, 0.5); + float faceFine = owFbm01(bp * 5.0, BP * 2.0, 4, 0.5); + vec4 facePore = owWorley(bp * 7.0, BP * 3.5, 1.0); + // Pits cluster instead of forming an even dot grid, and their size varies. + float poreCluster = smoothstep(0.42, 0.78, owFbm01(bp * 3.0 + 8.0, BP * 1.5, 4, 0.55)); + float pore = smoothstep(0.26 + 0.16 * facePore.z, 0.0, facePore.x) * step(0.55, facePore.w) * poreCluster; + + // Colour families: red stock, dark burnt header, pale sand-lime, brown. + vec3 cA = owSRGB(vec3(0.430, 0.238, 0.183)); // red stock + vec3 cB = owSRGB(vec3(0.318, 0.183, 0.150)); // deep red + vec3 cC = owSRGB(vec3(0.196, 0.132, 0.120)); // burnt header + vec3 cD = owSRGB(vec3(0.492, 0.392, 0.300)); // sandy + vec3 cE = owSRGB(vec3(0.372, 0.288, 0.218)); // brown + + vec3 brick = mix(cA, cB, rnd.z); + brick = mix(brick, cC, step(0.90, rnd.w) * 0.70); + brick = mix(brick, cD, step(0.94, rnd2.x) * 0.62); + brick = mix(brick, cE, step(0.55, rnd2.y) * 0.50); + // every brick came out of the kiln a different shade: +/-12% per brick + brick *= 0.88 + 0.24 * rnd3.x; + // within-brick banding from the extrusion + brick *= 0.86 + 0.28 * faceN; + // fine sand grain across the face — this is what reads at 0.5 m + // bp is per-brick, and a brick is only ~170 texels wide, so bp*26 was 78 + // cycles across it — 2.2 texels a cycle. This is the band that has to still + // be there at 0.5 m, so it is authored at 7 texels and given more contrast. + float faceGrain = owFbm01(bp * 8.0, BP * 4.0, 4, 0.55); + brick *= 0.87 + 0.26 * faceGrain; + brick = mix(brick, brick * 1.22, smoothstep(0.55, 0.9, faceFine) * 0.5); + // dark iron spots and sand inclusions + brick = mix(brick, brick * 0.62, pore * 0.85); + brick = mix(brick, brick * 0.72, smoothstep(0.34, 0.0, facePore.x) * step(0.86, facePore.z)); + brick = mix(brick, owSRGB(vec3(0.62, 0.58, 0.50)), smoothstep(0.86, 0.98, faceFine) * 0.35); + + float faceH = 0.72 + (faceN - 0.5) * 0.05 + (faceFine - 0.5) * 0.025 + + (rnd2.z - 0.5) * 0.05; // each brick sits proud/shy + faceH -= pore * 0.075; + + // Broken arrises: ~5% of the edge length is knocked off, deep enough to + // catch a shadow, showing pale raw clay under the fired skin. + float edgeD = min(dxj / JX, dyj / JY); + float chipNoise = owFbm01(bp * 6.0 + 3.0, BP * 3.0, 4, 0.5); + float chip = smoothstep(1.7, 0.30, edgeD) * smoothstep(0.60, 0.80, chipNoise) * step(0.66, rnd3.z); + faceH -= chip * 0.17; + brick = mix(brick, brick * 0.72 + owSRGB(vec3(0.20, 0.13, 0.09)), chip * 0.65); + + // ---------------- combine face + mortar ---------------- + // face is already a shaped profile, so no second smoothstep here: that is + // what used to smear the arris across the full joint width. + float m = face; + h = mix(0.72 - jointDepth + mortarH, faceH, m); + vec3 c = mix(mortarCol, brick, m); + // every brick came out of the kiln with a slightly different skin + float brickRough = 0.58 + 0.32 * rnd2.z + (rnd3.y - 0.5) * 0.20; + rough = mix(0.88 + 0.10 * mSand + 0.06 * (mortarRough - 0.5), + brickRough + 0.14 * faceN + 0.10 * (faceGrain - 0.5) + chip * 0.14, m); + ao = mix(0.34, 1.0, smoothstep(0.0, 0.75, face)); + ao -= chip * 0.30; + metal = 0.0; + + // mortar smeared over the brick edge by the trowel + float smear = smoothstep(0.5, 1.0, 1.0 - face) * smoothstep(0.55, 0.9, owFbm01(p * 14.0, P * 14.0, 4, 0.5)); + c = mix(c, mortarCol * 1.05, smear * 0.5); + + // ---------------- weathering over the whole wall ---------------- + // The 0.1-1 m band — see the long note in PLASTER. + float soilB = owFbm01(owWarp(p * 1.8 + 27.0, P * 1.8, 0.6, 3), P * 1.8, 4, 0.58); + soilB = clamp((soilB - 0.5) * 2.5 + 0.5, 0.0, 1.0); + c *= 0.845 + 0.33 * soilB; + + // efflorescence: salt bloom, strongest around joints + float efflo = smoothstep(0.62, 0.96, owFbm01(owWarp(p * 2.6, P * 2.6, 0.8, 3), P * 2.6, 4, 0.5)); + efflo *= mix(1.0, 0.35, m); + c = mix(c, owSRGB(vec3(0.66, 0.652, 0.632)), efflo * 0.5); + rough += efflo * 0.10; + + // soot / rain runoff — short, shallow and only ~3:1 stretched; the long runs + // are added at runtime where a real ledge sheds water. + float streak = owFbm01(vec2(p.x * 7.0, p.y * 2.3), vec2(P.x * 7.0, P.y * 2.0), 5, 0.55); + float runoff = smoothstep(0.50, 0.92, streak); + c *= 1.0 - runoff * 0.16; + + // hairline cracks stepping through the joints + float crack = owCracks(p * 2.2, P * 2.2, 0.85, 0.038, 0.58); + h -= crack * 0.10; + ao -= crack * 0.45; + c = mix(c, c * 0.35, crack * 0.7); + + // dirt in every crevice + float cavity = 1.0 - smoothstep(0.50, 0.74, h); + c = mix(c, owSRGB(vec3(0.16, 0.15, 0.14)), cavity * 0.32); + + alb = clamp(c, vec3(0.02), vec3(0.85)); + rough = clamp(rough, 0.35, 0.99); + ao = clamp(ao, 0.12, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const PLASTER = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 5.3; + + // trowel: broad sweeps, anisotropic, with a fine skim on top + vec2 sw = owShear(p * 1.5, 1.0, 3.0); + float trowel = owFbm01(sw, owShearPer(P * 1.5, 3.0), 5, 0.55); + float skim = owFbm01(p * 12.0, P * 12.0, 5, 0.5); + float micro = owFbm01(p * 24.0, P * 24.0, 3, 0.5); + float macro = owFbm01(p * 0.6, P * 0.6, 3, 0.6); + + vec3 cBase = owSRGB(vec3(0.598, 0.578, 0.538)); + vec3 cWarm = owSRGB(vec3(0.512, 0.462, 0.395)); + vec3 cGrey = owSRGB(vec3(0.382, 0.378, 0.372)); + vec3 c = mix(cBase, cWarm, smoothstep(0.3, 0.8, macro)); + c *= 0.94 + 0.12 * skim; + c = mix(c, cGrey, smoothstep(0.45, 0.95, trowel) * 0.42); + c = mix(c, cBase * 1.10, smoothstep(0.55, 0.15, trowel) * 0.30); + + h = 0.70 + (trowel - 0.5) * 0.10 + (skim - 0.5) * 0.030 + (micro - 0.5) * 0.012; + rough = 0.80 + (skim - 0.5) * 0.12 - smoothstep(0.5, 0.9, trowel) * 0.10; + ao = 1.0; + metal = 0.0; + + // ---- skim-coat laps ------------------------------------------------------ + // A plasterer works the wall in ~40 cm passes, and every pass sets a hair + // lighter or darker than the one before with a faint arris where the trowel + // lifted off. This is the mid-frequency signal that separates plaster from + // paint at 2-5 m — without it the wall is one value plus a sprinkle of specks. + vec2 lapUv = owShear(p * 0.7, 1.0, 1.0); + float lapF = lapUv.y + owFbm01(p * 1.1, P * 1.1, 3, 0.6) * 1.4; + float lapI = floor(lapF); + float lapT = fract(lapF); + float lapR = owHash11(lapI * 1.71 + uSeed * 2.3); + c *= 0.885 + 0.240 * lapR; + rough += (lapR - 0.5) * 0.10; + + /** + * THE 0.1-1 m BAND. A wall seen from 2-3 m fills the frame with about half a + * metre of itself, which is a hole in the frequency budget: the macro layer + * varies over 4-12 m and the detail map over 10 mm, so between them the + * surface has nothing and measures a standard deviation of 5 over a + * 260x240 patch — a flat colour with a sprinkle of specks. These three + * bands (damp bloom, hand-height soiling, and a soft dirt wash) sit at + * 15-90 cm and are what actually makes a plastered wall read as plaster. + */ + // NB the contrast expansion. A 4-octave fbm01 spans about 0.3-0.7, never + // 0-1, so writing 0.86 + 0.30 * n gives a +/-6% wash and not the +/-20% + // the numbers suggest — the same trap the macro layer documents. Every band + // here is re-centred and expanded before it is used. + float dampB = owFbm01(owWarp(p * 1.6 + 3.7, P * 1.6, 0.7, 3), P * 1.6, 4, 0.58); + dampB = clamp((dampB - 0.5) * 2.6 + 0.5, 0.0, 1.0); + c *= 0.80 + 0.42 * dampB; + rough += (dampB - 0.5) * 0.12; + float soil2 = owFbm01(owWarp(p * 3.4 + 21.0, P * 3.4, 0.55, 3), P * 3.4, 4, 0.55); + soil2 = clamp((soil2 - 0.5) * 2.4 + 0.5, 0.0, 1.0); + c *= 0.875 + 0.26 * soil2; + float wash = owFbm01(p * 8.0 + 6.0, P * 8.0, 4, 0.5); + wash = clamp((wash - 0.5) * 2.2 + 0.5, 0.0, 1.0); + c *= 0.925 + 0.155 * wash; + float lapEdge = (1.0 - smoothstep(0.0, 0.05, lapT)) * (0.35 + 0.65 * lapR); + h += lapEdge * 0.022 - (lapR - 0.5) * 0.014; + c *= 1.0 + lapEdge * 0.07; + + // ---- sand tooth: the 0.5-2 mm grain of the finish coat, with a matching + // height channel. Without this the wall is paint, not plaster. + // 6-9 mm float grain. The finer 1-2 mm tooth is the shared detail map's + // job: at 2.2 m over 1024 texels one texel is 2.1 mm, so anything past + // K = 22 here is a sub-texel hash that bakes as dither and mips to grey. + vec4 tooth = owWorley(p * 20.0, P * 20.0, 1.0); + float grain = smoothstep(0.46, 0.06, tooth.x); + float grainSel = 0.40 + 0.60 * step(0.32, tooth.z); + h += grain * grainSel * 0.030; + ao -= grain * 0.07; + c *= 1.0 + (grain * grainSel - 0.20) * 0.16; + rough += (tooth.z - 0.5) * 0.11 + grain * 0.05; + // dust and shadow sit in the troughs between grains + float trough = smoothstep(0.52, 0.86, tooth.x); + c = mix(c, c * 0.84, trough * 0.40); + + // pinholes from the float + vec4 ph = owWorley(p * 22.0, P * 22.0, 1.0); + float hole = smoothstep(0.24, 0.0, ph.x) * step(0.80, ph.w); + h -= hole * 0.06; + ao -= hole * 0.4; + + // hairline crazing — a fine, wide-spread net + float hair = owCracks(p * 9.0, P * 9.0, 0.9, 0.016, 0.52); + hair += owCracks(p * 16.0 + 6.0, P * 16.0, 0.95, 0.015, 0.62) * 0.5; + hair = clamp(hair, 0.0, 1.0); + h -= hair * 0.030; + ao -= hair * 0.18; + c = mix(c, c * 0.80, hair * 0.45); + + // structural cracks — few, wide, branching + float crack = owCracks(p * 4.5 + 17.0, P * 4.5, 0.8, 0.018, 0.62); + h -= crack * 0.16; + ao -= crack * 0.6; + c = mix(c, owSRGB(vec3(0.300, 0.278, 0.250)), crack * 0.8); + + // blown plaster: patches spalled off, revealing render/brick beneath + float blowMask = owFbm01(owWarp(p * 1.05 + 9.0, P * 1.05, 1.1, 3), P * 1.05, 4, 0.55); + float blow = smoothstep(0.775, 0.845, blowMask); + float blowEdge = smoothstep(0.745, 0.790, blowMask) - blow; + vec3 substrate = mix(owSRGB(vec3(0.360, 0.245, 0.195)), owSRGB(vec3(0.430, 0.400, 0.360)), + owFbm01(p * 9.0, P * 9.0, 4, 0.5)); + substrate *= 0.85 + 0.3 * owFbm01(p * 20.0, P * 20.0, 3, 0.5); + c = mix(c, substrate, blow * 0.85); + h -= blow * 0.13; + ao -= blow * 0.26; + rough += blow * 0.10; + // the lip of the blown patch is bright and sharp + c += blowEdge * 0.06; + h += blowEdge * 0.02; + + // ---- chipped patches: 6-9 cm flakes knocked off the skim, showing the darker + // browncoat. Deliberately FEWER and LARGER than a fine speckle: a dense + // sprinkle of 3 cm dark dots on a facade reads as fly dirt, not as damage, + // and it is the one thing that survives at every distance and so gives the + // whole wall a screen-space texture. + vec4 ck = owWorley(owWarp(p * 4.2 + 13.0, P * 4.2, 0.6, 3), P * 4.2, 0.95); + float ckSel = step(0.930, ck.w); + float ckSize = 0.22 + 0.20 * ck.z; + float ckShape = smoothstep(ckSize, ckSize * 0.3, + ck.x * (0.70 + 0.60 * owFbm01(p * 16.0, P * 16.0, 3, 0.5))); + float chip = ckSel * ckShape; + // The browncoat is the same family as the finish, just darker and coarser — + // a chip is a shallow flake, not a hole punched in the wall. + vec3 coat = mix(c, owSRGB(vec3(0.392, 0.336, 0.284)), 0.52); + coat *= 0.90 + 0.20 * owFbm01(p * 18.0, P * 18.0, 3, 0.5); + c = mix(c, coat, chip * 0.58); + h -= chip * 0.05; + ao -= chip * 0.26; + rough += chip * 0.09; + float ckLip = max(ckSel * (smoothstep(ckSize * 1.25, ckSize, ck.x) - ckShape), 0.0); + c *= 1.0 + ckLip * 0.10; + h += ckLip * 0.010; + + // water staining: tide marks and slow brown bleed + float stain = owFbm01(vec2(p.x * 1.6, p.y * 3.2), vec2(P.x * 1.6, P.y * 3.0), 5, 0.6); + float tide = smoothstep(0.60, 0.78, stain) * (1.0 - smoothstep(0.78, 0.94, stain)); + c = mix(c, owSRGB(vec3(0.400, 0.330, 0.245)), tide * 0.45); + c *= 1.0 - smoothstep(0.50, 0.95, stain) * 0.34; + rough += tide * 0.05; + + // black mould in the damp corners + float mould = smoothstep(0.72, 0.95, owFbm01(p * 4.0 + 25.0, P * 4.0, 5, 0.6)) * + smoothstep(0.45, 0.8, stain); + c = mix(c, owSRGB(vec3(0.085, 0.090, 0.080)), mould * 0.7); + rough += mould * 0.08; + + // grime in recesses + float cavity = 1.0 - smoothstep(0.48, 0.72, h); + c = mix(c, owSRGB(vec3(0.22, 0.21, 0.19)), cavity * 0.30); + + alb = clamp(c, vec3(0.02), vec3(0.88)); + rough = clamp(rough, 0.35, 0.99); + ao = clamp(ao, 0.15, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const TILE = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + const float N = 6.0; + vec2 p = uv * P + uSeed * 4.4; + + vec2 tp = uv * N; + vec2 id = floor(tp); + vec2 f = fract(tp); + vec4 rnd = owHash42(id + uSeed); + + // Flat grout bed with a hard arris at the tile edge: a full-width ramp is + // what makes a joint read as a drawn line instead of a recess. + const float J = 0.045; + float dxj = min(f.x, 1.0 - f.x); + float dyj = min(f.y, 1.0 - f.y); + float ex = smoothstep(J * 0.70, J * 1.02, dxj); + float ey = smoothstep(J * 0.70, J * 1.02, dyj); + float face = min(ex, ey); + + float glaze = owFbm01(f * 6.0 + rnd.xy * 21.0, vec2(48.0), 4, 0.5); + vec3 cTile = mix(owSRGB(vec3(0.700, 0.690, 0.660)), owSRGB(vec3(0.470, 0.500, 0.505)), rnd.z * 0.7); + cTile *= 0.93 + 0.13 * glaze; + cTile *= 0.92 + 0.16 * rnd.y; // per-tile batch shade + + float grout = owFbm01(p * 20.0, P * 20.0, 4, 0.5); + vec3 cGrout = owSRGB(vec3(0.400, 0.385, 0.360)) * (0.85 + 0.3 * grout); + cGrout = mix(cGrout, owSRGB(vec3(0.13, 0.13, 0.12)), 0.45); // grout is always filthy + + float m = face; + // 0.06 of a 0.03 m relief = 1.8 mm of grout recess. + h = mix(0.76 - (grout - 0.5) * 0.02, 0.82 + (rnd.w - 0.5) * 0.04, m); + vec3 c = mix(cGrout, cTile, m); + // glazed tile has to stay glossy enough to actually catch a highlight + rough = mix(0.92, 0.20 + 0.22 * glaze + (rnd.z - 0.5) * 0.14, m); + ao = mix(0.40, 1.0, smoothstep(0.0, 0.8, face)); + metal = 0.0; + + // chipped / cracked / missing tiles + float broken = step(0.90, rnd.x); + float crack = owCracks(f * 3.0 + rnd.yz * 9.0, vec2(24.0), 0.85, 0.04, 0.45) * m; + c = mix(c, c * 0.3, crack * 0.8); + h -= crack * 0.08; + ao -= crack * 0.5; + vec3 sub = owSRGB(vec3(0.330, 0.300, 0.270)); + c = mix(c, sub, broken * m * 0.9); + h -= broken * m * 0.14; + rough = mix(rough, 0.95, broken * m); + + // scuffs and traffic wear + float wear = smoothstep(0.45, 0.95, owFbm01(p * 2.0, P * 2.0, 4, 0.55)); + rough += wear * 0.20 * m; + c *= 1.0 - wear * 0.12; + + float cavity = 1.0 - smoothstep(0.68, 0.80, h); + c = mix(c, owSRGB(vec3(0.14, 0.13, 0.12)), cavity * 0.35); + + alb = clamp(c, vec3(0.02), vec3(0.85)); + rough = clamp(rough, 0.12, 0.95); + ao = clamp(ao, 0.15, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; diff --git a/src/lib/cod/materials/glsl/surfaces-ground.js b/src/lib/cod/materials/glsl/surfaces-ground.js new file mode 100644 index 00000000..cbf09824 --- /dev/null +++ b/src/lib/cod/materials/glsl/surfaces-ground.js @@ -0,0 +1,366 @@ +/** + * Ground surfaces: asphalt, sand, dirt, gravel. These are usually triplanar + * or planar-projected and are the surfaces most prone to visible tiling, so + * they carry strong low-frequency content that the macro-variation layer in + * the material shader can push around. + * + * NYQUIST BUDGET (read this before adding a band). Every generator writes + * `p = uv * 8`, so a term at `p * K` lays 8K cells across the bake, and at a + * bake of N texels that is N/(8K) texels per cell. Under ~5 texels the cell is + * not a feature, it is white noise: mip 0 shows salt-and-pepper dither and + * mip 1 has already averaged it to a flat wash. That single mistake is what + * made the whole street read as sandpaper at 3 m and as flat colour at 15 m. + * All ground bakes are 1024, so K is capped at 24 (5.3 texels) and the + * sub-millimetre read is delegated to the shared detail map, which is tiled + * ten times finer and has the texel budget for it. + */ + +export const ASPHALT = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 6.9; + + // ---- binder: dark, slightly blue-grey, sun-bleached in patches ---- + float macro = owFbm01(p * 0.55, P * 0.5, 4, 0.6); + float mid = owFbm01(p * 3.0, P * 3.0, 5, 0.5); + float fine = owFbm01(p * 16.0, P * 16.0, 4, 0.5); + + vec3 cFresh = owSRGB(vec3(0.115, 0.115, 0.122)); + vec3 cWorn = owSRGB(vec3(0.300, 0.298, 0.295)); + vec3 c = mix(cFresh, cWorn, smoothstep(0.25, 0.85, macro) * 0.85); + // Half the old fine-grain albedo contrast: the stone read belongs in the + // height/normal channels, not in a high-frequency albedo dither. + c *= 0.94 + 0.12 * fine; + + h = 0.60 + (mid - 0.5) * 0.06; + rough = 0.78 + (mid - 0.5) * 0.10 + (fine - 0.5) * 0.14; + metal = 0.0; + ao = 1.0; + + // ---- aggregate: dense angular chippings, three grades ---- + // Angularity comes from warping the worley domain: round cells become + // faceted, which is what separates asphalt from a pebble beach. + vec2 ap = owWarp(p, P, 0.10, 3); + vec4 big = owWorley(ap * 12.0, P * 12.0, 1.0); + float bigM = smoothstep(0.40, 0.16, big.x); + float bigExposed = bigM * smoothstep(0.30, 0.62, owFbm01(p * 2.2 + 3.0, P * 2.0, 4, 0.5) + big.w * 0.5); + vec4 small = owWorley(ap * 22.0 + 7.0, P * 22.0, 1.0); + float smallM = smoothstep(0.36, 0.10, small.x); + float smallExposed = smallM * step(0.30, small.w); + vec4 grit = owWorley(ap * 28.0 + 3.0, P * 28.0, 1.0); + float gritM = smoothstep(0.32, 0.06, grit.x) * step(0.45, grit.z); + + vec3 stoneA = owSRGB(vec3(0.400, 0.392, 0.378)); + vec3 stoneB = owSRGB(vec3(0.210, 0.200, 0.192)); + vec3 stoneC = owSRGB(vec3(0.560, 0.520, 0.470)); + vec3 stone = mix(stoneA, stoneB, big.z); + stone = mix(stone, stoneC, step(0.90, big.w)); + + // Stones are read by their relief and their specular, not by their tint: + // colour contrast is roughly halved and the height contribution raised. + c = mix(c, stone, bigExposed * 0.52); + c = mix(c, mix(stoneA, stoneC, small.z), smallExposed * 0.22); + c = mix(c, mix(stoneB, stoneA, grit.z), gritM * 0.14); + h += bigExposed * 0.15 * (0.6 + 0.6 * big.z) + smallExposed * 0.065 + gritM * 0.022; + rough += bigExposed * (0.10 - 0.22 * big.z) + smallExposed * (0.06 - 0.14 * small.z); + + // voids between the aggregate — where the binder has ravelled out + float voidM = smoothstep(0.50, 0.85, big.x) * smoothstep(0.28, 0.6, small.x); + h -= voidM * 0.10; + ao -= voidM * 0.14; + + // ---- tyre polish: two smooth bands where wheels track ---- + float lane = abs(fract(uv.x * 1.0 + 0.25) - 0.5) * 2.0; + float polish = (1.0 - smoothstep(0.10, 0.62, lane)) * + smoothstep(0.25, 0.65, owFbm01(vec2(p.x * 0.7, p.y * 5.0), vec2(P.x, P.y * 5.0), 4, 0.5)); + rough -= polish * 0.16; + h -= polish * 0.012; + c = mix(c, c * 0.78 + owSRGB(vec3(0.045, 0.045, 0.048)), polish * 0.45); + + // ---- patch repairs: darker rectangles-ish with a seam ---- + vec4 rep = owWorley(owWarp(p * 0.5 + 13.0, P * 0.5, 1.6, 3), P * 0.5, 0.9); + float inPatch = step(0.72, rep.w); + float patchEdge = (1.0 - smoothstep(0.0, 0.06, rep.y - rep.x)) * inPatch; + c = mix(c, cFresh * (0.85 + 0.35 * fine), inPatch * 0.20); + rough = mix(rough, 0.84, inPatch * 0.22); + h -= patchEdge * 0.07; + ao -= patchEdge * 0.20; + c = mix(c, cFresh * 0.5, patchEdge * 0.35); + // tar bleeding out of the seam, glossy + float tar = patchEdge * smoothstep(0.4, 0.7, owFbm01(p * 6.0, P * 6.0, 3, 0.5)); + rough -= tar * 0.35; + c = mix(c, owSRGB(vec3(0.055, 0.055, 0.058)), tar * 0.7); + + // ---- alligator cracking + long thermal cracks ---- + float gator = owCracks(p * 3.4, P * 3.4, 0.9, 0.032, 0.56); + float thermal = owCracks(p * 0.9 + 41.0, P * 0.9, 0.75, 0.05, 0.70); + float crack = clamp(gator + thermal, 0.0, 1.0); + h -= crack * 0.16; + ao -= crack * 0.30; + c = mix(c, owSRGB(vec3(0.045, 0.043, 0.042)), crack * 0.85); + rough += crack * 0.12; + + // ---- oil stains, dark and slightly glossy ---- + float oil = smoothstep(0.68, 0.90, owFbm01(owWarp(p * 1.8 + 31.0, P * 1.8, 0.9, 3), P * 1.8, 4, 0.55)); + c = mix(c, owSRGB(vec3(0.045, 0.043, 0.046)), oil * 0.6); + rough -= oil * 0.16; + + // ---- dust settled in the low spots ---- + float dust = smoothstep(0.55, 0.30, h) * smoothstep(0.35, 0.75, macro); + c = mix(c, owSRGB(vec3(0.420, 0.390, 0.340)), dust * 0.35); + rough += dust * 0.10; + + alb = clamp(c, vec3(0.02), vec3(0.75)); + rough = clamp(rough, 0.44, 0.99); + // see the AO note in GRAVEL: on a ground plane this channel is the shading + ao = clamp(ao, 0.68, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const SAND = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 8.2; + + // ---- wind ripples: sheared sine, gently warped so the crests meander ---- + vec2 rp = owShear(p * 1.0, 1.0, 1.0); + float warp = owFbm(p * 0.9, P * 0.9, 3, 0.55); + float ripple = sin((rp.y * 1.0 + warp * 0.55) * 6.28318); + // asymmetric profile: gentle windward slope, sharp lee crest + ripple = ripple * 0.5 + 0.5; + ripple = pow(ripple, 1.7) * 0.75 + ripple * 0.25; + float rippleAmp = smoothstep(0.20, 0.70, owFbm01(p * 0.7, P * 0.7, 3, 0.6)); + float secondary = sin((p.y * 3.0 + p.x * 1.0 + warp * 0.8) * 6.28318) * 0.5 + 0.5; + + float dune = owFbm01(p * 0.5, P * 0.5, 4, 0.6); + float mid = owFbm01(p * 5.0, P * 5.0, 5, 0.5); + float grain = owFbm01(p * 18.0, P * 18.0, 4, 0.55); + vec4 gcell = owWorley(p * 24.0, P * 24.0, 1.0); + + h = 0.50 + (dune - 0.5) * 0.16 + (mid - 0.5) * 0.05 + + (ripple - 0.5) * 0.26 * rippleAmp + (secondary - 0.5) * 0.06 * rippleAmp + + (grain - 0.5) * 0.018; + + vec3 cLight = owSRGB(vec3(0.760, 0.660, 0.480)); + vec3 cMid = owSRGB(vec3(0.610, 0.510, 0.360)); + vec3 cDamp = owSRGB(vec3(0.360, 0.290, 0.205)); + vec3 c = mix(cMid, cLight, smoothstep(0.3, 0.8, dune)); + c = mix(c, cDamp, smoothstep(0.62, 0.28, h) * 0.55); // damp in the hollows + // coarse grains collect on the crests, fines in the troughs + c = mix(c, cLight * 1.06, smoothstep(0.45, 0.85, ripple) * rippleAmp * 0.35); + c = mix(c, cMid * 0.88, smoothstep(0.45, 0.10, ripple) * rippleAmp * 0.30); + c *= 0.90 + 0.18 * grain; + // sparkle from quartz grains + c += smoothstep(0.22, 0.0, gcell.x) * step(0.86, gcell.z) * 0.10; + + rough = 0.90 + (grain - 0.5) * 0.10 - smoothstep(0.6, 0.3, h) * 0.12; + metal = 0.0; + ao = 1.0 - smoothstep(0.55, 0.25, h) * 0.10; + + // ---- pebbles and shell fragments sitting on top ---- + vec4 peb = owWorley(p * 18.0, P * 18.0, 1.0); + float pebble = smoothstep(0.30, 0.10, peb.x) * step(0.80, peb.w); + vec3 pcol = mix(owSRGB(vec3(0.400, 0.370, 0.330)), owSRGB(vec3(0.690, 0.660, 0.620)), peb.z); + c = mix(c, pcol, pebble * 0.85); + h += pebble * 0.05; + rough = mix(rough, 0.55 + 0.25 * peb.z, pebble * 0.8); + ao -= smoothstep(0.40, 0.30, peb.x) * step(0.80, peb.w) * 0.08; + + // ---- scattered dry debris / dark mineral streaks ---- + float streak = smoothstep(0.62, 0.88, owFbm01(owShear(p * 2.5, 2.0, 4.0), owShearPer(P * 2.5, 4.0), 4, 0.5)); + c = mix(c, cDamp * 1.1, streak * 0.22); + + alb = clamp(c, vec3(0.02), vec3(0.82)); + rough = clamp(rough, 0.35, 0.99); + ao = clamp(ao, 0.80, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const DIRT = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 3.4; + + float macro = owFbm01(p * 0.6, P * 0.6, 4, 0.62); + float clump = owBillow(owWarp(p * 3.0, P * 3.0, 0.6, 3), P * 3.0, 5, 0.55); + float fine = owFbm01(p * 14.0, P * 14.0, 4, 0.5); + float micro = owFbm01(p * 22.0, P * 22.0, 3, 0.5); + + vec3 cDry = owSRGB(vec3(0.430, 0.350, 0.255)); + vec3 cWet = owSRGB(vec3(0.185, 0.140, 0.100)); + vec3 cPale = owSRGB(vec3(0.560, 0.490, 0.385)); + vec3 c = mix(cDry, cPale, smoothstep(0.45, 0.9, macro)); + c = mix(c, cWet, smoothstep(0.55, 0.15, macro) * 0.8); + // Halved high-frequency albedo contrast; the read moves into height/roughness. + c *= 0.94 + 0.11 * fine; + c *= 0.975 + 0.05 * micro; + + h = 0.55 + (macro - 0.5) * 0.14 + (clump - 0.5) * 0.16 + (fine - 0.5) * 0.075; + rough = 0.88 + (fine - 0.5) * 0.14 + (micro - 0.5) * 0.10; + metal = 0.0; + ao = 1.0; + + // dried mud cracks in the flat pans + float pan = smoothstep(0.35, 0.65, macro); + float mud = owCracks(p * 2.4, P * 2.4, 0.85, 0.045, 0.35) * pan; + h -= mud * 0.16; + ao -= mud * 0.32; + c = mix(c, cWet * 0.7, mud * 0.75); + // the mud plates curl up at their edges + float plateLift = smoothstep(0.10, 0.0, mud) * pan; + h += plateLift * 0.01; + + // stones of two grades + vec4 st = owWorley(p * 11.0, P * 11.0, 1.0); + float stone = smoothstep(0.30, 0.11, st.x) * step(0.62, st.w); + vec3 scol = mix(owSRGB(vec3(0.330, 0.315, 0.295)), owSRGB(vec3(0.600, 0.575, 0.540)), st.z); + c = mix(c, scol, stone * 0.6); + h += stone * 0.085; + rough = mix(rough, 0.52 + 0.28 * st.z, stone * 0.8); + ao -= smoothstep(0.36, 0.28, st.x) * step(0.62, st.w) * 0.10; + + vec4 grit = owWorley(p * 22.0, P * 22.0, 1.0); + float gritM = smoothstep(0.26, 0.08, grit.x) * step(0.55, grit.w); + c = mix(c, mix(scol, cPale, grit.z), gritM * 0.4); + h += gritM * 0.015; + + // dead grass / organic litter + float litter = smoothstep(0.70, 0.86, owFbm01(owShear(p * 8.0, 1.0, 5.0), owShearPer(P * 8.0, 5.0), 4, 0.5)); + litter *= smoothstep(0.4, 0.8, macro); + c = mix(c, owSRGB(vec3(0.330, 0.290, 0.160)), litter * 0.5); + h += litter * 0.012; + rough += litter * 0.05; + + // sparse moss in the damp low spots + float moss = smoothstep(0.74, 0.92, owFbm01(p * 4.5 + 19.0, P * 4.5, 5, 0.6)) * smoothstep(0.5, 0.1, macro); + c = mix(c, owSRGB(vec3(0.150, 0.185, 0.105)), moss * 0.65); + + float cavity = 1.0 - smoothstep(0.40, 0.70, h); + ao -= cavity * 0.14; + + alb = clamp(c, vec3(0.02), vec3(0.72)); + rough = clamp(rough, 0.45, 0.99); + ao = clamp(ao, 0.72, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const GRAVEL = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 2.7; + + float bed = owFbm01(p * 1.3, P * 1.3, 4, 0.55); + + /** + * This is the street. Not a bed of loose chippings — compacted dust and grit + * with aggregate part-buried in it, which is what a Levantine back street + * actually is, and much more importantly it is what stops twelve metres of + * road reading as dither. + * + * Two things were wrong before. (1) The finest grade was 208 cells across a + * 512 bake — 2.5 texels, i.e. white noise, and the "grain" band on top of it + * was 480 cells across 512, literally sub-texel. (2) The interstitial dust + * was authored at 0.29 while the stone tops ran to 0.62 and then the runtime + * cavity-grime layer pushed the (low) bed down another 17% toward black. A + * 2.5:1 albedo step at a 10 mm period across the whole frame is the textbook + * recipe for salt-and-pepper. So: three grades at 34/19/9 mm (5.9 texels at + * the worst), stones separated from the bed by RELIEF and ROUGHNESS rather + * than by value, and a bed sitting mid-height so the cavity term leaves it + * alone. + */ + vec4 a = owWorley(p * 5.5, P * 5.5, 1.0); + vec4 b = owWorley(p * 10.0 + 5.0, P * 10.0, 1.0); + vec4 cSm = owWorley(p * 21.0 + 11.0, P * 21.0, 1.0); + + // Sparse: most of what you see is the compacted bed, with stones IN it. The + // old coverage was ~80% at every grade, which is a shingle beach; a used + // road shows perhaps a quarter of its aggregate. + float sA = smoothstep(0.36, 0.10, a.x) * step(0.44, a.w); + float sB = smoothstep(0.30, 0.08, b.x) * step(0.62, b.w); + float sC = smoothstep(0.24, 0.06, cSm.x) * step(0.74, cSm.w); + + // The stones live in the height field: raised relief so each one catches the + // sun on one side and shadows on the other. Half-buried, not tipped out on + // the surface — the peak-to-trough is only a few mm at world scale. + float ha = sA * 0.15 * (0.5 + a.z); + float hb = sB * 0.09 * (0.5 + b.z); + float hc = sC * 0.025; + h = 0.54 + (bed - 0.5) * 0.11 + max(max(ha, hb), hc) + 0.22 * (ha + hb); + + /** + * The stone palette has to straddle the bed value, not sit above it. With + * the bed at 0.35 and the stones running 0.29-0.51 every dark stone hid in + * the dust and only the pale ones showed, which is why the road read as + * white confetti scattered on grey rather than as aggregate. Half the + * stones are now darker than the bed and half lighter. + */ + vec3 s1 = owSRGB(vec3(0.372, 0.356, 0.332)); + vec3 s2 = owSRGB(vec3(0.232, 0.220, 0.208)); + vec3 s3 = owSRGB(vec3(0.462, 0.438, 0.400)); + vec3 s4 = owSRGB(vec3(0.352, 0.276, 0.220)); + vec3 top = mix(s1, s2, a.z); + top = mix(top, s3, step(0.78, a.w)); + top = mix(top, s4, step(0.90, b.w) * 0.7); + + // The bed is dust, and it is only a few percent off the stones sitting in it. + vec3 cBed = owSRGB(vec3(0.362, 0.336, 0.294)); + vec3 c = mix(cBed, top, clamp(sA * 0.70 + sB * 0.42 + sC * 0.16, 0.0, 1.0)); + // ~9 mm grain, 4.9 texels wide: a texture, not a dither. + float grain = owFbm01(p * 13.0, P * 13.0, 4, 0.5); + c *= 0.965 + 0.07 * grain; + + // Per-stone gloss: wet-worn pebbles glint, the dust between them does not. + // This, not albedo, is what separates a stone from the dust around it. + // Per-stone gloss, but only a little of it. Under a bright sky the IBL + // specular lobe is a big part of what a shaded ground plane returns, so a + // wide roughness spread at the aggregate period is another way of writing + // salt-and-pepper: clamping this term alone took the measured + // high-frequency deviation on the road from 2.45 to 1.68. + rough = 0.82 + 0.05 * grain + (1.0 - clamp(sA + sB, 0.0, 1.0)) * 0.06 + - sA * (0.06 + 0.07 * a.z) - sB * 0.05 * b.z; + metal = 0.0; + /** + * AO IS THE WHOLE BALLGAME ON A GROUND PLANE. A street in shadow is lit + * almost entirely by the sky, so orm.r is very nearly the only shading + * term the surface has — a 0.62:1.0 cavity ripple at the 10-30 mm aggregate + * period is therefore a 1.6:1 luminance ripple at 2 screen pixels, which is + * precisely the salt-and-pepper the critics measured. (Proved by clamping + * the albedo to a constant and the normal map to flat: the speckle survived + * both untouched, and only died when this range was compressed.) Baked + * cavity AO belongs at low frequency and low contrast; the shading of an + * individual stone is the normal map's job. + */ + ao = mix(0.87, 1.0, smoothstep(0.42, 0.66, h)); + + // fine dust filling the gaps + float dust = 1.0 - smoothstep(0.44, 0.62, h); + c = mix(c, cBed * 1.04, dust * 0.5); + rough += dust * 0.08; + ao = mix(ao, 1.0, dust * 0.3); + + // Wheel and foot traffic sweeps the loose grit into drifts and polishes bare + // lanes: 0.5-1.5 m form inside the tile, which is the scale the eye uses to + // decide whether a road is a surface or a pattern. + float drift = owFbm01(owWarp(p * 0.9 + 17.0, P * 0.9, 0.8, 3), P * 0.9, 4, 0.6); + h += (drift - 0.5) * 0.10; + c *= 0.86 + 0.28 * drift; + rough += (drift - 0.5) * 0.10; + // Dust drifts BURY the aggregate: where the drift is deep the stones go + // under it. Without this the stone density is identical over every square + // metre of a hundred-metre street, which is the tell that it is a texture. + c = mix(c, cBed * (0.92 + 0.22 * drift), smoothstep(0.55, 0.88, drift) * 0.72); + + // Dried tyre tracks and dragged-heel scuffs — long, shallow, low contrast. + float scuff = owFbm01(owShear(p * 2.2, 0.0, 6.0), owShearPer(P * 2.2, 6.0), 4, 0.5); + c *= 1.0 - smoothstep(0.55, 0.92, scuff) * 0.10; + rough -= smoothstep(0.6, 0.95, scuff) * 0.08; + + alb = clamp(c, vec3(0.02), vec3(0.78)); + rough = clamp(rough, 0.62, 0.99); + ao = clamp(ao, 0.72, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; diff --git a/src/lib/cod/materials/glsl/surfaces-metal.js b/src/lib/cod/materials/glsl/surfaces-metal.js new file mode 100644 index 00000000..990f6e75 --- /dev/null +++ b/src/lib/cod/materials/glsl/surfaces-metal.js @@ -0,0 +1,323 @@ +/** + * Metals. The single most important physical rule here: bare metal is + * metalness 1, and every oxide/paint/dirt layer on top of it is metalness 0. + * Blending metalness through the rust and chip masks is what makes these read + * as real steel rather than as grey plastic. + */ + +/** Shared: layered iron oxide. Returns rust amount [0,1] and its colour. */ +export const RUST_HELPERS = /* glsl */ ` +vec3 owRustColour(float t, float grain){ + // young rust is orange, mature rust is dark red-brown, old rust is near-black + vec3 c1 = owSRGB(vec3(0.560, 0.290, 0.110)); // fresh orange + vec3 c2 = owSRGB(vec3(0.380, 0.180, 0.085)); // mid + vec3 c3 = owSRGB(vec3(0.190, 0.100, 0.060)); // mature + vec3 c4 = owSRGB(vec3(0.640, 0.400, 0.190)); // powdery bloom + vec3 c = mix(c1, c2, smoothstep(0.15, 0.6, t)); + c = mix(c, c3, smoothstep(0.55, 1.0, t)); + c = mix(c, c4, smoothstep(0.55, 0.95, grain) * 0.45); + return c * (0.82 + 0.36 * grain); +} +`; + +export const METAL_RUST = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 7.7; + + // ---- base steel ---- + float mill = owFbm01(owShear(p * 4.0, 1.0, 6.0), owShearPer(P * 4.0, 6.0), 4, 0.5); + float fine = owFbm01(p * 22.0, P * 22.0, 4, 0.5); + vec3 steel = owSRGB(vec3(0.330, 0.335, 0.345)) * (0.90 + 0.18 * mill); + vec3 c = steel; + h = 0.72 + (mill - 0.5) * 0.02 + (fine - 0.5) * 0.01; + rough = 0.40 + (mill - 0.5) * 0.16 + (fine - 0.5) * 0.08; + metal = 1.0; + ao = 1.0; + + // ---- rust blooms: warped billow clusters, hard-edged where they flake ---- + vec2 wp = owWarp(p * 1.4, P * 1.4, 1.2, 4); + float bloom = owBillow(wp, P * 1.4, 5, 0.6); + bloom = 1.0 - bloom; // clusters, not veins + float spread = owFbm01(p * 0.7 + 12.0, P * 0.7, 3, 0.6); + float rust = smoothstep(0.36, 0.72, bloom * (0.55 + 0.85 * spread)); + float rustGrain = owFbm01(p * 26.0, P * 26.0, 4, 0.55); + float pit = owFbm01(p * 24.0, P * 24.0, 3, 0.5); + + // flaking scale: the rust lifts in plates near the edge of a bloom + float scale = owWorley(p * 16.0, P * 16.0, 1.0).x; + float flake = smoothstep(0.30, 0.10, scale) * smoothstep(0.25, 0.55, rust) * (1.0 - smoothstep(0.8, 1.0, rust)); + + // Rust *colour* is driven by how old the patch is, not by how much of it + // there is — otherwise every heavily rusted area collapses to the same brown. + float rustAge = owFbm01(p * 0.85 + 21.0, P * 0.85, 4, 0.62); + vec3 rustCol = owRustColour(rustAge * 0.8 + rust * 0.3, rustGrain); + c = mix(c, rustCol, rust); + metal = mix(1.0, 0.0, smoothstep(0.15, 0.55, rust)); + rough = mix(rough, 0.86 + 0.10 * rustGrain, smoothstep(0.1, 0.6, rust)); + h += rust * 0.11 * (0.4 + rustGrain) + flake * 0.13; + h -= smoothstep(0.5, 0.95, rust) * pit * 0.14; // deep pitting under old rust + ao -= flake * 0.30 + smoothstep(0.6, 1.0, rust) * 0.15; + + // ---- pitting straight into the steel where rust has eaten through ---- + vec4 pits = owWorley(p * 22.0, P * 22.0, 1.0); + float deep = smoothstep(0.22, 0.0, pits.x) * step(0.72, pits.w) * smoothstep(0.3, 0.8, rust); + h -= deep * 0.22; + ao -= deep * 0.45; + c = mix(c, rustCol * 0.35, deep * 0.7); + + // ---- scratches through everything, exposing bright metal ---- + float scr = owScratches(p * 3.0, P * 3.0, 12.0, 1.0, 0.60); + scr += owScratches(p * 5.0 + 8.0, P * 5.0, 9.0, -2.0, 0.66) * 0.7; + scr = clamp(scr, 0.0, 1.0) * 0.6; + c = mix(c, owSRGB(vec3(0.480, 0.485, 0.495)), scr * 0.8); + metal = mix(metal, 1.0, scr * 0.85); + rough = mix(rough, 0.24, scr * 0.7); + h -= scr * 0.010; + + // ---- grime ---- + float grime = smoothstep(0.55, 0.9, owFbm01(vec2(p.x * 5.0, p.y * 0.8), vec2(P.x * 5.0, max(P.y, 1.0)), 5, 0.55)); + c *= 1.0 - grime * 0.25; + rough += grime * 0.08; + + alb = clamp(c, vec3(0.02), vec3(0.80)); + rough = clamp(rough, 0.12, 0.99); + ao = clamp(ao, 0.15, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const METAL_PAINTED = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 11.3; + + // ---- substrate: steel with a mill finish ---- + float mill = owFbm01(owShear(p * 5.0, 1.0, 8.0), owShearPer(P * 5.0, 8.0), 4, 0.5); + vec3 steel = owSRGB(vec3(0.330, 0.335, 0.345)) * (0.88 + 0.2 * mill); + + // ---- rust that has crept under the paint ---- + float bloom = 1.0 - owBillow(owWarp(p * 1.8, P * 1.8, 1.1, 4), P * 1.8, 5, 0.6); + float rustField = smoothstep(0.60, 0.92, bloom); + float rustGrain = owFbm01(p * 22.0, P * 22.0, 4, 0.55); + vec3 rustCol = owRustColour(rustField, rustGrain); + + // ---- paint: an industrial coat with roller texture and orange peel ---- + float peel = owFbm01(p * 22.0, P * 22.0, 4, 0.5); + float roller = owFbm01(owShear(p * 2.0, 0.0, 3.0), owShearPer(P * 2.0, 3.0), 4, 0.5); + vec3 paint = uTintA * (0.90 + 0.16 * roller); + paint *= 0.96 + 0.08 * peel; + // sun-bleached on the up-facing halves + float bleach = smoothstep(0.35, 0.85, owFbm01(p * 0.8, P * 0.8, 3, 0.6)); + paint = mix(paint, paint * 1.25 + 0.03, bleach * 0.5); + + // ---- chipping: paint fails at scratches, impacts and along its own edges ---- + float chipField = owFbm01(owWarp(p * 2.6 + 4.0, P * 2.6, 0.9, 3), P * 2.6, 5, 0.55); + float chipEdge = owFbm01(p * 12.0, P * 12.0, 4, 0.5); + // Paint mostly holds: only the top of the distribution actually fails, and + // it fails hardest where rust is already lifting it from underneath. + float chipSrc = chipField * 0.60 + chipEdge * 0.20 + rustField * 0.32 + uParam.z * 0.25; + float chip = smoothstep(0.66, 0.92, chipSrc); + // small impact chips scattered around + vec4 dings = owWorley(p * 20.0, P * 20.0, 1.0); + float ding = smoothstep(0.14, 0.03, dings.x) * step(0.88, dings.w); + chip = clamp(chip + ding, 0.0, 1.0); + + // scratches that cut down to bare metal + float scr = owScratches(p * 2.5, P * 2.5, 14.0, 1.0, 0.62); + scr += owScratches(p * 4.0 + 21.0, P * 4.0, 10.0, -1.0, 0.66) * 0.8; + scr = clamp(scr, 0.0, 1.0); + + // ---- layer stack: paint over primer over rust over steel ---- + vec3 primer = owSRGB(vec3(0.470, 0.300, 0.180)); + float primerBand = smoothstep(0.0, 0.35, chip) * (1.0 - smoothstep(0.35, 0.6, chip)); + + vec3 c = paint; + float r = 0.42 + (peel - 0.5) * 0.22 + bleach * 0.16; + float mtl = 0.0; + h = 0.74 + (roller - 0.5) * 0.02 + (peel - 0.5) * 0.012; + ao = 1.0; + + c = mix(c, primer, primerBand * 0.7); + c = mix(c, rustCol, smoothstep(0.35, 0.75, chip) * (0.55 + 0.45 * rustField)); + c = mix(c, steel, smoothstep(0.75, 0.95, chip) * (1.0 - rustField) * 0.9); + r = mix(r, 0.88, smoothstep(0.3, 0.8, chip) * (0.4 + 0.6 * rustField)); + r = mix(r, 0.38, smoothstep(0.8, 1.0, chip) * (1.0 - rustField)); + mtl = mix(0.0, 1.0, smoothstep(0.78, 0.96, chip) * (1.0 - smoothstep(0.2, 0.7, rustField))); + h -= smoothstep(0.4, 0.8, chip) * 0.16; // paint has real thickness + ao -= smoothstep(0.35, 0.7, chip) * 0.22; + // the lip of a chip is a bright hard edge + float lip = smoothstep(0.30, 0.42, chip) * (1.0 - smoothstep(0.42, 0.55, chip)); + c *= 1.0 + lip * 0.15; + h += lip * 0.05; + + // scratches on top of everything + c = mix(c, owSRGB(vec3(0.500, 0.505, 0.515)), scr * 0.55); + mtl = mix(mtl, 1.0, scr * 0.6); + r = mix(r, 0.26, scr * 0.55); + + // ---- dirt and rain streaks ---- + float streak = owFbm01(vec2(p.x * 6.0, p.y * 0.7), vec2(P.x * 6.0, max(P.y, 1.0)), 5, 0.55); + float grime = smoothstep(0.52, 0.92, streak); + c *= 1.0 - grime * 0.30; + r += grime * 0.10; + mtl *= 1.0 - grime * 0.5; + // rust bleed running down from the chips + float bleed = smoothstep(0.66, 0.95, streak) * smoothstep(0.2, 0.6, rustField); + c = mix(c, owSRGB(vec3(0.360, 0.190, 0.090)), bleed * 0.45); + + float cavity = 1.0 - smoothstep(0.62, 0.78, h); + c *= 1.0 - cavity * 0.18; + + alb = clamp(c, vec3(0.02), vec3(0.85)); + rough = clamp(r, 0.14, 0.99); + metal = clamp(mtl, 0.0, 1.0); + ao = clamp(ao, 0.2, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const METAL_BRUSHED = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 15.1; + + // brushing runs along X: heavy shear so the noise stretches into fibres + vec2 bp = owShear(p, 0.0, 64.0); + vec2 BP = owShearPer(P, 64.0); + float brush1 = owFbm01(bp * 2.0, BP * 2.0, 4, 0.5); + float brush2 = owFbm01(bp * 8.0 + 3.0, BP * 8.0, 3, 0.5); + float brush3 = owFbm01(owShear(p * 4.0, 0.0, 24.0), owShearPer(P * 4.0, 24.0), 3, 0.5); + float brush = brush1 * 0.5 + brush2 * 0.32 + brush3 * 0.18; + + float macro = owFbm01(p * 0.9, P * 0.9, 3, 0.6); + + vec3 c = owSRGB(vec3(0.560, 0.565, 0.575)); + c *= 0.93 + 0.13 * brush; + c *= 0.97 + 0.06 * macro; + + metal = 1.0; + rough = 0.22 + brush * 0.24 + (macro - 0.5) * 0.06; + h = 0.78 + (brush - 0.5) * 0.012; + ao = 1.0; + + // deeper score lines + float score = owScratches(p * 1.0, P, 40.0, 0.0, 0.60); + rough += score * 0.22; + h -= score * 0.006; + c *= 1.0 - score * 0.05; + + // cross scratches from handling + float cross = owScratches(p * 3.0, P * 3.0, 8.0, 3.0, 0.70) * 0.7; + rough += cross * 0.20; + h -= cross * 0.004; + + // dents: shallow, wide, they break the reflection + float dent = owFbm01(p * 3.0 + 7.0, P * 3.0, 3, 0.6); + h += (dent - 0.5) * 0.05; + + // fingerprints and grease smudges — the thing that sells brushed metal + float smudge = smoothstep(0.58, 0.86, owFbm01(owWarp(p * 2.2 + 19.0, P * 2.2, 0.7, 3), P * 2.2, 4, 0.55)); + rough += smudge * 0.22; + c *= 1.0 - smudge * 0.06; + metal -= smudge * 0.10; + + // grime settling in + float grime = smoothstep(0.66, 0.95, owFbm01(p * 5.0, P * 5.0, 4, 0.55)); + c = mix(c, owSRGB(vec3(0.180, 0.175, 0.165)), grime * 0.35); + rough += grime * 0.18; + metal -= grime * 0.35; + + alb = clamp(c, vec3(0.02), vec3(0.88)); + rough = clamp(rough, 0.08, 0.95); + metal = clamp(metal, 0.0, 1.0); + ao = clamp(ao, 0.4, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const CORRUGATED = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + const float RIDGES = 12.0; + vec2 p = uv * P + uSeed * 6.1; + + // ---- the profile: sinusoidal ridges with a flat-ish crown ---- + float t = uv.x * RIDGES * 6.28318530718; + float wave = sin(t); + float profile = sign(wave) * pow(abs(wave), 0.72) * 0.5 + 0.5; + // panel joints every 4 ridges: one sheet laps over the next + float panel = uv.x * RIDGES / 4.0; + float panelId = floor(panel); + float lap = smoothstep(0.0, 0.06, fract(panel)) * smoothstep(0.0, 0.06, 1.0 - fract(panel)); + float panelStep = (owHash11(panelId + uSeed) - 0.5) * 0.05; + + float dents = owFbm01(p * 2.2, P * 2.2, 4, 0.6); + float fine = owFbm01(p * 11.0, P * 11.0, 4, 0.5); + + h = 0.18 + profile * 0.62 + panelStep + (dents - 0.5) * 0.07 + (fine - 0.5) * 0.012; + h -= (1.0 - lap) * 0.06; + + // ---- galvanised zinc: crystalline spangle ---- + vec4 sp = owWorley(p * 7.0, P * 7.0, 1.0); + float spangle = smoothstep(0.55, 0.05, sp.x); + vec3 zinc = owSRGB(vec3(0.520, 0.535, 0.545)); + vec3 c = mix(zinc * 0.86, zinc * 1.12, spangle * (0.3 + 0.7 * sp.z)); + c *= 0.94 + 0.12 * fine; + metal = 1.0; + rough = 0.34 + (1.0 - spangle) * 0.16 + (fine - 0.5) * 0.08; + ao = 1.0; + + // ---- rust, heavier in the valleys and at the bottom of the sheet ---- + float valley = 1.0 - profile; + float rustField = smoothstep(0.62, 0.98, + (1.0 - owBillow(owWarp(p * 1.6, P * 1.6, 1.0, 4), P * 1.6, 5, 0.6)) * + (0.58 + 0.40 * valley) + (1.0 - uv.y) * 0.16); + float rustGrain = owFbm01(p * 22.0, P * 22.0, 4, 0.55); + vec3 rustCol = owRustColour(rustField, rustGrain); + c = mix(c, rustCol, rustField); + metal = mix(metal, 0.0, smoothstep(0.15, 0.6, rustField)); + rough = mix(rough, 0.88 + 0.08 * rustGrain, smoothstep(0.1, 0.6, rustField)); + h += rustField * 0.02 * rustGrain; + + // holes rusted right through + vec4 hole = owWorley(p * 5.0 + 31.0, P * 5.0, 0.95); + float perf = smoothstep(0.10, 0.02, hole.x) * step(0.94, hole.w) * smoothstep(0.5, 0.9, rustField); + h -= perf * 0.5; + ao -= perf * 0.7; + c = mix(c, rustCol * 0.25, perf); + + // ---- fixings: hex screws with a rubber washer, two rows, on the crowns ---- + float crown = smoothstep(0.72, 0.95, profile); + vec2 fx = vec2(fract(uv.x * RIDGES) - 0.5, fract(uv.y * 3.0) - 0.5); + float fd = length(fx * vec2(1.0, RIDGES / 3.0)); + float screwRnd = owHash12(floor(vec2(uv.x * RIDGES, uv.y * 3.0)) + uSeed); + float screw = smoothstep(0.16, 0.11, fd) * crown * step(0.25, screwRnd); + float washer = smoothstep(0.24, 0.18, fd) * crown * step(0.25, screwRnd); + h += washer * 0.02 + screw * 0.035; + c = mix(c, owSRGB(vec3(0.120, 0.115, 0.110)), washer * 0.8); + c = mix(c, mix(owSRGB(vec3(0.400, 0.405, 0.410)), rustCol, rustField), screw); + rough = mix(rough, 0.85, washer * 0.8); + rough = mix(rough, 0.42 + rustField * 0.4, screw); + metal = mix(metal, 0.0, washer * 0.9); + metal = mix(metal, 1.0 - rustField, screw); + ao -= (washer - screw) * 0.35; + // rust streak weeping from each fixing + float weep = washer * 0.0 + smoothstep(0.34, 0.20, fd) * step(0.25, screwRnd) * crown * + smoothstep(0.0, 0.5, fract(uv.y * 3.0) - 0.5); + c = mix(c, owSRGB(vec3(0.330, 0.170, 0.080)), clamp(weep, 0.0, 1.0) * 0.5); + + // ---- dirt collecting in the valleys ---- + float dirt = valley * smoothstep(0.35, 0.8, owFbm01(p * 3.0, P * 3.0, 4, 0.55)); + c = mix(c, owSRGB(vec3(0.200, 0.185, 0.160)), dirt * 0.40); + rough += dirt * 0.14; + metal *= 1.0 - dirt * 0.5; + ao -= valley * 0.18; + + alb = clamp(c, vec3(0.02), vec3(0.85)); + rough = clamp(rough, 0.14, 0.99); + metal = clamp(metal, 0.0, 1.0); + ao = clamp(ao, 0.15, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; diff --git a/src/lib/cod/materials/glsl/surfaces-organic.js b/src/lib/cod/materials/glsl/surfaces-organic.js new file mode 100644 index 00000000..cdb4bec0 --- /dev/null +++ b/src/lib/cod/materials/glsl/surfaces-organic.js @@ -0,0 +1,415 @@ +/** + * Wood, fabric, sandbag/burlap, foliage, rubber, glass. + * Foliage writes its cutout mask into the height channel's companion — see + * generator.js, which routes `h` to albedo.a for parallax on most surfaces but + * to the alpha-test mask for `foliage`. + */ + +export const WOOD = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + const float PLANKS = 5.0; + vec2 p = uv * P + uSeed * 12.9; + + // ---- plank layout: rows running along X, staggered butt joints ---- + float rowF = uv.y * PLANKS; + float row = floor(rowF); + float rf = fract(rowF); + float stagger = owHash11(row + uSeed * 2.0); + float lenF = uv.x * 2.0 + stagger; // 2 boards per row lengthwise + float board = floor(lenF); + float lf = fract(lenF); + vec4 rnd = owHash42(vec2(board, row) + uSeed); + + // gaps between boards + const float GY = 0.035, GX = 0.010; + float ey = min(smoothstep(0.0, GY, rf), smoothstep(0.0, GY, 1.0 - rf)); + float ex = min(smoothstep(0.0, GX, lf), smoothstep(0.0, GX, 1.0 - lf)); + float face = min(ex, ey); + + // ---- grain: rings stretched along the board, warped, with knots ---- + vec2 gp = vec2(lf * 2.0 + rnd.x * 13.0, rf + rnd.y * 7.0); + vec2 GP = vec2(16.0, 8.0); + float warp = owFbm(vec2(gp.x * 3.0, gp.y * 12.0), vec2(GP.x * 3.0, GP.y * 12.0), 4, 0.55); + float ringCoord = gp.y * (14.0 + rnd.z * 12.0) + warp * 2.2 + rnd.w * 5.0; + + // knots pull the rings into a tight radial swirl + vec2 knotP = vec2(0.25 + rnd.x * 0.5, 0.35 + rnd.y * 0.3); + float kd = length((vec2(lf, rf) - knotP) * vec2(2.2, 1.0)); + float hasKnot = step(0.68, rnd.z); + float knotPull = hasKnot * exp(-kd * 9.0); + ringCoord = mix(ringCoord, kd * 42.0, clamp(knotPull * 1.6, 0.0, 1.0)); + + float rings = fract(ringCoord); + float ringDark = smoothstep(0.42, 0.5, rings) * (1.0 - smoothstep(0.5, 0.62, rings)); + float latewood = smoothstep(0.30, 0.52, rings); + + // fine fibre along the grain + float fibre = owFbm01(owShear(p * 6.0, 0.0, 40.0), owShearPer(P * 6.0, 40.0), 4, 0.5); + float micro = owFbm01(p * 22.0, P * 22.0, 3, 0.5); + + // ---- colour ---- + vec3 wLight = owSRGB(vec3(0.505, 0.408, 0.290)); + vec3 wMid = owSRGB(vec3(0.362, 0.272, 0.180)); + vec3 wDark = owSRGB(vec3(0.205, 0.142, 0.092)); + vec3 wGrey = owSRGB(vec3(0.372, 0.355, 0.328)); // weathered silver-grey + vec3 c = mix(wLight, wMid, rnd.w * 0.8 + latewood * 0.5); + c = mix(c, wDark, ringDark * 0.65); + c *= 0.90 + 0.18 * fibre; + c = mix(c, wDark * 0.7, clamp(knotPull * 2.2, 0.0, 1.0) * 0.8); + + // weathering: UV-bleached, silvered, worst on the exposed boards + float weather = smoothstep(0.20, 0.85, owFbm01(p * 0.8, P * 0.8, 3, 0.6)) * (0.4 + 0.6 * rnd.x); + c = mix(c, wGrey, weather * 0.68); + + float faceH = 0.74 - ringDark * 0.02 - latewood * 0.012 + (fibre - 0.5) * 0.03 + (micro - 0.5) * 0.008; + faceH += (rnd.y - 0.5) * 0.035; // boards cup and sit at different heights + faceH -= clamp(knotPull * 1.5, 0.0, 1.0) * 0.03; + + // splits and checks running along the grain + float split = owScratches(vec2(p.x, p.y) * 2.0, P * 2.0, 30.0, 0.0, 0.66) * weather; + faceH -= split * 0.10; + c = mix(c, wDark * 0.45, split * 0.7); + + // saw marks across the board + float saw = owFbm01(owShear(p * 3.0, 0.0, 1.0) * vec2(30.0, 1.0), vec2(P.x * 90.0, P.y * 3.0), 3, 0.5); + faceH += (saw - 0.5) * 0.012; + + // rounded / bashed board edges + float edgeD = min(min(rf, 1.0 - rf) / GY, min(lf, 1.0 - lf) / GX); + float bevel = 1.0 - smoothstep(0.0, 2.4, edgeD); + faceH -= bevel * 0.035; + c *= 1.0 - bevel * 0.10; + c = mix(c, wLight * 1.15, bevel * smoothstep(0.5, 0.9, owFbm01(p * 20.0, P * 20.0, 3, 0.5)) * 0.35); + + // ---- gap between boards: dark, deep ---- + float m = smoothstep(0.05, 0.7, face); + h = mix(0.44, faceH, m); + c = mix(wDark * 0.25, c, m); + rough = mix(0.95, 0.62 + 0.22 * fibre + weather * 0.20 + split * 0.15, m); + ao = mix(0.25, 1.0, smoothstep(0.0, 0.5, face)) - bevel * 0.12 * m; + metal = 0.0; + + // ---- nails ---- + vec2 nf = vec2(fract(lf * 3.0 + 0.5) - 0.5, (rf - 0.5)); + float nd = length(nf * vec2(3.0, 1.0) / vec2(3.0, 1.0) * vec2(1.0, 1.0)); + nd = length(vec2(fract(lf * 3.0 + 0.5) - 0.5, rf - 0.22) * vec2(1.4, 1.0)); + float nail = smoothstep(0.055, 0.030, nd) * m * step(0.3, rnd.w); + h -= nail * 0.02; + c = mix(c, owSRGB(vec3(0.230, 0.200, 0.170)), nail * 0.85); + rough = mix(rough, 0.55, nail); + metal = mix(metal, 0.85, nail * 0.7); + ao -= nail * 0.25; + // rust weep under the nail + float weep = smoothstep(0.11, 0.05, nd) * step(0.3, rnd.w) * smoothstep(0.0, 0.6, rf - 0.22) * m; + c = mix(c, owSRGB(vec3(0.330, 0.185, 0.095)), clamp(weep, 0.0, 1.0) * 0.4); + + // grime + float cavity = 1.0 - smoothstep(0.55, 0.78, h); + c = mix(c, owSRGB(vec3(0.120, 0.106, 0.088)), cavity * 0.45); + // ground-in dirt over the whole board + float soil = smoothstep(0.40, 0.88, owFbm01(owWarp(p * 2.2 + 5.0, P * 2.2, 0.9, 3), P * 2.2, 5, 0.6)); + c = mix(c, owSRGB(vec3(0.185, 0.160, 0.128)), soil * 0.40); + rough += soil * 0.08; + + alb = clamp(c, vec3(0.02), vec3(0.80)); + rough = clamp(rough, 0.25, 0.99); + ao = clamp(ao, 0.12, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const FABRIC = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + const float THREADS = 96.0; + vec2 p = uv * P + uSeed * 3.9; + + // ---- plain weave: warp over weft on alternating cells ---- + vec2 t = uv * THREADS; + vec2 cell = floor(t); + vec2 f = fract(t) - 0.5; + float over = mod(cell.x + cell.y, 2.0); // 0 -> warp on top, 1 -> weft on top + + float warpProfile = cos(f.x * 3.14159) ; + float weftProfile = cos(f.y * 3.14159); + float top = mix(warpProfile, weftProfile, over); + float bot = mix(weftProfile, warpProfile, over) * 0.45; + float weave = max(top, bot); + float threadId = owHash12(cell + uSeed); + + // ---- fuzz and slubs ---- + float fuzz = owFbm01(p * 12.0, P * 12.0, 3, 0.55); + float slub = owFbm01(p * 14.0, P * 14.0, 4, 0.5); + float macro = owFbm01(p * 1.2, P * 1.2, 4, 0.6); + + vec3 cA = uTintA; + vec3 cB = uTintB; + vec3 c = mix(cA, cB, threadId * 0.6 + slub * 0.4); + c *= 0.865 + 0.215 * (weave * 0.5 + 0.5); + c *= 0.960 + 0.075 * fuzz; + c *= 0.90 + 0.20 * macro; + + h = 0.55 + weave * 0.30 + (fuzz - 0.5) * 0.03 + (slub - 0.5) * 0.05; + rough = 0.86 + (1.0 - weave) * 0.08 + (fuzz - 0.5) * 0.06; + metal = 0.0; + ao = mix(0.82, 1.0, smoothstep(-0.4, 0.9, weave)); + + // ---- drape folds --------------------------------------------------------- + // Cloth under tension gathers into soft parallel ridges roughly a hand's width + // apart, wandering as they run. At the 0.26 m mapping the awnings use, 2.6 + // cycles across the tile is a ~10 cm fold. A weave alone reads as printed + // canvas; the fold field is what gives a canopy its shape between its poles. + float foldC = uv.y * 2.6 + uv.x * 0.55 + owFbm01(p * 0.9, P * 0.9, 3, 0.62) * 2.2; + float foldT = abs(fract(foldC) - 0.5) * 2.0; // 0 at crest, 1 in trough + float crest = 1.0 - foldT; + float foldR = owHash11(floor(foldC) * 2.13 + uSeed); + float fold = crest * crest * (0.55 + 0.75 * foldR); + h += (fold - 0.30) * 0.115; + c *= 0.895 + 0.21 * fold; + ao -= (1.0 - crest) * 0.14; + // the crease line itself is polished by handling and holds the dust + float creaseLine = 1.0 - smoothstep(0.0, 0.10, foldT); + rough -= creaseLine * 0.06; + c *= 1.0 + creaseLine * 0.05; + + // ---- wear: threadbare patches, fraying, pulled threads ---- + float wearField = smoothstep(0.58, 0.82, owFbm01(owWarp(p * 2.0, P * 2.0, 0.8, 3), P * 2.0, 4, 0.55)); + c = mix(c, c * 1.35 + 0.02, wearField * 0.5); + rough += wearField * 0.06; + h -= wearField * 0.05; + + float pulled = owScratches(p * 3.0, P * 3.0, 18.0, 1.0, 0.68); + h += pulled * 0.05; + c *= 1.0 - pulled * 0.10; + + // ---- stains and dust ---- + float stain = smoothstep(0.55, 0.9, owFbm01(owWarp(p * 1.5 + 7.0, P * 1.5, 1.0, 3), P * 1.5, 5, 0.6)); + c = mix(c, c * 0.42 + owSRGB(vec3(0.09, 0.08, 0.06)), stain * 0.55); + rough += stain * 0.05; + + float dust = smoothstep(0.4, 0.85, owFbm01(p * 6.0, P * 6.0, 4, 0.5)); + c = mix(c, owSRGB(vec3(0.400, 0.375, 0.335)), dust * 0.14); + + alb = clamp(c, vec3(0.02), vec3(0.85)); + rough = clamp(rough, 0.5, 0.99); + ao = clamp(ao, 0.25, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const BURLAP = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + const float THREADS = 34.0; // hessian is coarse + vec2 p = uv * P + uSeed * 4.7; + + vec2 t = uv * THREADS; + vec2 cell = floor(t); + vec2 f = fract(t) - 0.5; + float over = mod(cell.x + cell.y, 2.0); + + // hessian threads are irregular: each one has its own thickness + float twx = 0.62 + 0.30 * owHash12(vec2(cell.x, 0.0) + uSeed); + float twy = 0.62 + 0.30 * owHash12(vec2(0.0, cell.y) + uSeed * 1.7); + float warpP = cos(clamp(f.x / twx, -0.5, 0.5) * 3.14159); + float weftP = cos(clamp(f.y / twy, -0.5, 0.5) * 3.14159); + float top = mix(warpP, weftP, over); + float bot = mix(weftP, warpP, over) * 0.40; + float weave = max(top, bot); + + float fibre = owFbm01(owShear(p * 12.0, 0.0, 8.0), owShearPer(P * 12.0, 8.0), 3, 0.5); + float macro = owFbm01(p * 1.0, P * 1.0, 4, 0.62); + float dirt = owFbm01(owWarp(p * 2.5, P * 2.5, 0.8, 3), P * 2.5, 5, 0.55); + + vec3 cJute = owSRGB(vec3(0.520, 0.430, 0.275)); + vec3 cPale = owSRGB(vec3(0.640, 0.560, 0.400)); + vec3 cSoil = owSRGB(vec3(0.230, 0.180, 0.120)); + vec3 c = mix(cJute, cPale, owHash12(cell + 3.0) * 0.5 + fibre * 0.15); + c *= 0.855 + 0.235 * (weave * 0.5 + 0.5); + c *= 0.90 + 0.18 * macro; + c = mix(c, cSoil, smoothstep(0.42, 0.85, dirt) * 0.60); + + h = 0.50 + weave * 0.38 + (fibre - 0.5) * 0.05; + rough = 0.90 + (1.0 - weave) * 0.06; + metal = 0.0; + ao = mix(0.74, 1.0, smoothstep(-0.4, 0.9, weave)); + + // sun rot: bleached and frayed on the exposed side + float rot = smoothstep(0.55, 0.9, owFbm01(p * 0.7 + 11.0, P * 0.7, 3, 0.6)); + c = mix(c, cPale * 1.15, rot * 0.4); + rough += rot * 0.05; + + // loose fibres standing off the surface + float loose = owScratches(p * 4.0, P * 4.0, 10.0, 2.0, 0.70); + h += loose * 0.06; + c = mix(c, cPale, loose * 0.3); + + // spilled sand caught in the weave + float sand = smoothstep(0.5, 0.85, owFbm01(p * 12.0, P * 12.0, 4, 0.5)) * (1.0 - smoothstep(0.2, 0.7, weave)); + c = mix(c, owSRGB(vec3(0.640, 0.545, 0.390)), sand * 0.45); + + alb = clamp(c, vec3(0.02), vec3(0.80)); + rough = clamp(rough, 0.6, 0.99); + ao = clamp(ao, 0.2, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const FOLIAGE = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + const float CELLS = 5.0; + vec2 p = uv * P + uSeed * 5.9; + + // Each cell holds one leaf, rotated and scaled by its hash. Sampling the + // 3x3 neighbourhood lets leaves overlap into their neighbours' cells. + vec2 lp = uv * CELLS; + vec2 ip = floor(lp), fp = fract(lp); + + float bestCover = 0.0; + float bestDepth = -1.0; + vec3 bestCol = vec3(0.0); + float bestH = 0.0; + float bestVein = 0.0; + + for (int y = -1; y <= 1; y++){ + for (int x = -1; x <= 1; x++){ + vec2 g = vec2(float(x), float(y)); + vec2 cell = mod(ip + g, vec2(CELLS)); + vec4 r = owHash42(cell + uSeed * 2.0); + vec4 r2 = owHash42(cell * 1.7 + 9.0 + uSeed); + vec2 centre = g + 0.15 + r.xy * 0.7 - fp; + float ang = r.z * 6.28318; + vec2 q = owRot(centre, ang); + // leaf shape: an ellipse pinched at both ends + vec2 s = vec2(0.30 + r.w * 0.16, 0.13 + r2.x * 0.07); + vec2 e = q / s; + float d = length(e); + float pinch = 1.0 - 0.55 * abs(e.x) * 0.5; + float cover = smoothstep(1.02, 0.86, d / max(pinch, 0.3)); + // serrated edge + float serr = sin(atan(e.y, e.x) * 26.0) * 0.03; + cover = smoothstep(1.02 + serr, 0.88 + serr, d / max(pinch, 0.3)); + if (cover > 0.01){ + float depth = r2.y; + if (depth > bestDepth){ + float vein = 1.0 - smoothstep(0.0, 0.05, abs(e.y * s.y)); + float sideV = smoothstep(0.75, 1.0, abs(fract(e.x * 5.0 + e.y * 2.0) * 2.0 - 1.0)); + vein = clamp(vein + sideV * 0.45 * cover, 0.0, 1.0); + vec3 cYoung = owSRGB(vec3(0.180, 0.330, 0.090)); + vec3 cOld = owSRGB(vec3(0.095, 0.185, 0.060)); + vec3 cDry = owSRGB(vec3(0.390, 0.320, 0.110)); + vec3 lc = mix(cYoung, cOld, r2.z); + lc = mix(lc, cDry, smoothstep(0.55, 1.0, r2.w) * 0.8); + // blotches and mildew spots + float spots = owFbm01(p * 22.0, P * 22.0, 3, 0.5); + lc *= 0.85 + 0.30 * spots; + lc = mix(lc, cDry * 0.7, smoothstep(0.78, 0.95, spots) * 0.5); + lc = mix(lc, lc * 1.35, vein * 0.5); + bestDepth = depth; + bestCover = cover; + bestCol = lc; + bestH = 0.45 + depth * 0.35 + (1.0 - smoothstep(0.0, 1.0, d)) * 0.12 + vein * 0.05; + bestVein = vein; + } + } + } + } + + float fine = owFbm01(p * 12.0, P * 12.0, 3, 0.5); + alb = clamp(bestCol * (0.955 + 0.085 * fine), vec3(0.02), vec3(0.7)); + // h doubles as the cutout mask for foliage (see generator.js) + h = bestCover; + rough = clamp(0.62 + (1.0 - bestVein) * 0.14 + (fine - 0.5) * 0.10, 0.35, 0.95); + metal = 0.0; + ao = clamp(0.55 + bestDepth * 0.45, 0.3, 1.0); +} +`; + +export const RUBBER = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 9.6; + + // moulded pebble grain + vec4 pb = owWorley(p * 12.0, P * 12.0, 1.0); + float pebble = smoothstep(0.42, 0.10, pb.x); + float fine = owFbm01(p * 12.0, P * 12.0, 3, 0.5); + float macro = owFbm01(p * 1.5, P * 1.5, 4, 0.6); + + h = 0.60 + pebble * 0.10 + (fine - 0.5) * 0.02 + (macro - 0.5) * 0.03; + // 0.20 sRGB ~= 0.031 linear. Anything darker lands under the 0.02 albedo + // floor applied below, which clamps the entire surface flat (a black, + // detail-free rubber that violates the "no flat surfaces" bar). + vec3 c = owSRGB(vec3(0.200, 0.200, 0.206)); + c *= 0.85 + 0.25 * (pebble * 0.5 + 0.5); + c *= 0.94 + 0.10 * fine; + + rough = 0.88 - pebble * 0.06 + (fine - 0.5) * 0.08; + metal = 0.0; + ao = mix(0.6, 1.0, pebble * 0.5 + 0.5); + + // mould seam + float seam = 1.0 - smoothstep(0.0, 0.012, abs(fract(uv.y * 2.0 + 0.5) - 0.5)); + h += seam * 0.03; + c *= 1.0 + seam * 0.35; + rough -= seam * 0.10; + + // scuffs: rubber goes chalky-grey where it abrades + float scuff = smoothstep(0.55, 0.88, owFbm01(owWarp(p * 3.0, P * 3.0, 0.8, 3), P * 3.0, 4, 0.55)); + c = mix(c, owSRGB(vec3(0.220, 0.218, 0.212)), scuff * 0.45); + rough += scuff * 0.06; + h -= scuff * 0.015; + + // cracking from ozone / age + float crack = owCracks(p * 7.0, P * 7.0, 0.9, 0.028, 0.62); + h -= crack * 0.06; + c *= 1.0 - crack * 0.35; + ao -= crack * 0.35; + + // dust + float dust = smoothstep(0.5, 0.9, owFbm01(p * 8.0, P * 8.0, 4, 0.5)); + c = mix(c, owSRGB(vec3(0.290, 0.275, 0.250)), dust * 0.16); + + alb = clamp(c, vec3(0.02), vec3(0.35)); + rough = clamp(rough, 0.55, 0.99); + ao = clamp(ao, 0.3, 1.0); + h = clamp(h, 0.0, 1.0); +} +`; + +export const GLASS = /* glsl */ ` +void owSurface(vec2 uv, out vec3 alb, out float h, out float rough, out float metal, out float ao){ + const vec2 P = vec2(8.0); + vec2 p = uv * P + uSeed * 2.2; + + float smear = owFbm01(owShear(p * 3.0, 1.0, 6.0), owShearPer(P * 3.0, 6.0), 4, 0.5); + float dustF = owFbm01(p * 5.0, P * 5.0, 5, 0.55); + float spots = owWorley(p * 24.0, P * 24.0, 1.0).x; + float fine = owFbm01(p * 12.0, P * 12.0, 3, 0.5); + + // glass itself is almost black in albedo; the look comes from reflections + vec3 c = owSRGB(vec3(0.045, 0.050, 0.052)); + + float dirty = smoothstep(0.45, 0.85, dustF); + c = mix(c, owSRGB(vec3(0.300, 0.290, 0.265)), dirty * 0.35); + + rough = 0.045 + smear * 0.10 * smoothstep(0.3, 0.9, dustF) + dirty * 0.22; + rough += smoothstep(0.30, 0.05, spots) * 0.25; // water spots + rough += (fine - 0.5) * 0.02; + + // fine scratches + float scr = owScratches(p * 2.0, P * 2.0, 24.0, 1.0, 0.70); + rough += scr * 0.25; + c += scr * 0.02; + + h = 0.5 + (smear - 0.5) * 0.004; + metal = 0.0; + ao = 1.0 - dirty * 0.1; + + alb = clamp(c, vec3(0.02), vec3(0.5)); + rough = clamp(rough, 0.02, 0.7); + h = clamp(h, 0.0, 1.0); +} +`; diff --git a/src/lib/cod/materials/index.js b/src/lib/cod/materials/index.js new file mode 100644 index 00000000..ac5f2f98 --- /dev/null +++ b/src/lib/cod/materials/index.js @@ -0,0 +1,353 @@ +import * as THREE from 'three'; +import { TextureForge } from './generator.js'; +import { LIBRARY, resolveName } from './library.js'; +import { extendMaterial, DEFAULT_PARAMS } from './shader.js'; +import { bakeMasks, setMask } from './masks.js'; + +/** + * Procedural PBR texture generation and the shared material library. + * + * There are no art assets in this project: every texel is rendered on the GPU + * at boot from the noise stack in glsl/, packed into three 8-bit textures per + * surface (albedo+height / ORM / tangent normal) and handed to a + * MeshStandardMaterial extended with projection, parallax, detail, macro + * variation and weathering (see shader.js). + * + * Public API — reach it with `ctx.get('materials')`: + * + * get(name, opts?) -> THREE.Material (cached; same opts, same instance) + * getTextureSet(name, opts?)-> { albedo, normal, orm, size, worldSize } + * variant(name, opts) -> alias for get() with a fresh cache entry + * names() -> string[] + * surfaceOf(name) -> one of the ARCHITECTURE.md surface tags + * bakeMasks(geometry, opts) -> geometry with wear/grime/AO vertex masks + * setGroundLevel(y) -> where the ground-splash weathering starts + * detailNormal / macroTexture -> the shared micro/macro maps + * + * `opts` accepts anything in DEFAULT_PARAMS (scale, tint, uvMode, parallax, + * weather, …) plus `three` for raw THREE material properties and `bake` to + * force a distinct texture bake (a different paint colour, for example). + */ +export class MaterialSystem { + static id = 'materials'; + static deps = ['render']; + + constructor(opts = {}) { + /** Allows a standalone harness to drive the system without the engine. */ + this._injectedRenderer = opts.renderer ?? null; + this._sets = new Map(); // bakeKey -> texture set + this._materials = new Map(); // matKey -> THREE.Material + this._forge = null; + this._shared = null; + this._groundY = 0; + this._built = false; + this._warned = false; + this._quality = 1; + /** seconds since the last bake, for the scratch-target release below */ + this._idle = 0; + this._scratchFreed = false; + } + + async init(ctx) { + this.ctx = ctx; + const q = ctx?.config?.q; + this._anisotropy = q?.anisotropy ?? 8; + // Texture budget scales with the quality preset; 1K is the reference. + this._quality = + ctx?.config?.quality === 'low' ? 0.5 : ctx?.config?.quality === 'medium' ? 0.75 : 1; + this._tryBuild(); + } + + // ------------------------------------------------------------- internals -- + _renderer() { + if (this._injectedRenderer) return this._injectedRenderer; + const r = this.ctx?.peek?.('render'); + return r?.renderer ?? r?.getRenderer?.() ?? null; + } + + _tryBuild() { + if (this._built) return true; + const renderer = this._renderer(); + if (!renderer) { + if (!this._warned) { + console.warn('[materials] no WebGLRenderer available yet — deferring texture bake'); + this._warned = true; + } + return false; + } + const t0 = performance.now(); + this._forge = new TextureForge(renderer, { anisotropy: this._anisotropy }); + // 1K, not 512: the micro tooth is 1.6-4 mm over a 0.25 m tile, which needs + // ~6 texels per grain to survive mip 1 instead of averaging to flat grey. + const detail = this._forge.buildDetail(this._size(1024)); + const macro = this._forge.buildMacro(256); + this._shared = { + detailNormal: detail.normal, + detailAlbedo: detail.albedo, + macro: macro.albedo, + }; + this._built = true; + const ms = performance.now() - t0; + if (ms > 30) console.info(`[materials] shared maps ${ms.toFixed(0)}ms`); + return true; + } + + _size(base) { + const s = Math.max(128, Math.round((base * this._quality) / 128) * 128); + // keep it a power of two so mip chains stay clean + return 1 << Math.round(Math.log2(s)); + } + + /** + * Names resolve through the alias table. An unknown name warns and falls back + * to concrete rather than throwing — a typo in one subsystem must not take + * the whole boot down. + */ + _resolve(name) { + const key = resolveName(name); + if (LIBRARY[key]) return key; + if (!this._missing) this._missing = new Set(); + if (!this._missing.has(name)) { + this._missing.add(name); + console.warn(`[materials] unknown surface "${name}" — falling back to concrete`); + } + return 'concrete'; + } + + _bakeKey(name, bake) { + return `${name}|${bake.size}|${bake.seed}|${bake.tintA ?? ''}|${bake.tintB ?? ''}|${( + bake.param ?? [] + ).join('_')}`; + } + + /** Build (or fetch) the three packed textures for a surface. */ + getTextureSet(name, opts = {}) { + const key = this._resolve(name); + const def = LIBRARY[key]; + if (!this._tryBuild()) return null; + + const bake = { ...def.bake, ...(opts.bake ?? {}) }; + bake.size = this._size(bake.size); + const cacheKey = this._bakeKey(key, bake); + let set = this._sets.get(cacheKey); + if (set) return set; + + const t0 = performance.now(); + this._idle = 0; + this._scratchFreed = false; + set = this._forge.build({ + key, + glsl: def.glsl, + size: bake.size, + seed: bake.seed ?? 1, + worldSize: bake.worldSize, + relief: bake.relief, + tintA: bake.tintA !== undefined ? new THREE.Color(bake.tintA) : undefined, + tintB: bake.tintB !== undefined ? new THREE.Color(bake.tintB) : undefined, + param: bake.param ? new THREE.Vector4().fromArray(bake.param) : undefined, + }); + set.name = key; + this._sets.set(cacheKey, set); + const ms = performance.now() - t0; + if (ms > 40) console.info(`[materials] bake ${key} ${bake.size}px ${ms.toFixed(0)}ms`); + return set; + } + + /** + * Every bake happens while the level is loading, but the half-float scratch + * height targets the Sobel pass reads were being held for the whole session + * (~10.5 MB of VRAM for 1K/512/256). Release them once the bake burst has + * clearly finished; `TextureForge._heightRT()` recreates on demand, so a late + * bake still produces exactly the same texture, it just re-allocates first. + * + * Nothing here touches a material, a uniform or a texture that is sampled, so + * it cannot move a pixel — it only changes when a scratch buffer is freed. + */ + update(dt) { + if (this._scratchFreed || !this._forge) return; + this._idle += dt > 0.25 ? 0.25 : dt; // ignore load-hitch dt spikes + if (this._idle < 5) return; + this._scratchFreed = true; + this._forge.releaseScratch(); + } + + // ------------------------------------------------------------------ API -- + /** + * Fetch a material. Identical (name, opts) return the identical instance so + * meshes batch; pass any override to get a distinct variant. + */ + get(name, opts = {}) { + const key = this._resolve(name); + const def = LIBRARY[key]; + + const matKey = key + '|' + stableKey(opts); + const cached = this._materials.get(matKey); + if (cached) return cached; + + const set = this.getTextureSet(key, opts); + const p = { ...DEFAULT_PARAMS, ...def.mat, ...opts }; + delete p.three; + delete p.bake; + p.groundY = opts.groundY ?? this._groundY; + + const threeProps = { ...(def.three ?? {}), ...(opts.three ?? {}) }; + const usePhysical = threeProps.physical === true; + delete threeProps.physical; + + const Ctor = usePhysical ? THREE.MeshPhysicalMaterial : THREE.MeshStandardMaterial; + const mat = new Ctor({ + color: 0xffffff, + roughness: 1, + metalness: 1, + dithering: true, + }); + mat.name = matKey; + + if (set) { + mat.map = set.albedo; + mat.normalMap = set.normal; + mat.normalScale.set(1, 1); + mat.roughnessMap = set.orm; + // The height in albedo.a is only meaningful with the extension; keep the + // stock alpha path off unless the surface is actually alpha-masked. + if (!(p.alphaMask || threeProps.transparent)) mat.transparent = false; + } else if (!this._warned) { + console.warn(`[materials] "${key}" built without textures (no renderer)`); + } + + if (p.vertexMasks) mat.vertexColors = true; + applyProps(mat, threeProps); + + if (set) extendMaterial(mat, p, this._shared); + + this._materials.set(matKey, mat); + return mat; + } + + /** Explicit variant request — same as get(), reads better at the call site. */ + variant(name, opts = {}) { + return this.get(name, opts); + } + + /** All library names (aliases excluded). */ + names() { + return Object.keys(LIBRARY); + } + + /** The ARCHITECTURE.md surface tag for impact FX / audio / footsteps. */ + surfaceOf(name) { + return LIBRARY[resolveName(name)]?.surface ?? 'concrete'; + } + + /** Live-update a material's uniforms after creation. */ + tune(material, changes = {}) { + const u = material.userData?.owUniforms; + if (!u) return material; + if (changes.scale !== undefined) { + const s = material.userData.owParams.uvMode === 'mesh' ? changes.scale : 1 / changes.scale; + u.owTile.value.x = s; + u.owTile.value.y = s; + } + if (changes.tint !== undefined) u.owTintCol.value.set(changes.tint); + if (changes.parallax !== undefined) u.owParallaxP.value.x = changes.parallax; + if (changes.groundY !== undefined) u.owGroundY.value = changes.groundY; + if (changes.normalStrength !== undefined) u.owNormalAmp.value = changes.normalStrength; + if (changes.weather !== undefined) u.owWeatherP.value.fromArray(changes.weather); + return material; + } + + /** Where the ground-splash weathering band sits, in world Y. */ + setGroundLevel(y) { + this._groundY = y; + for (const m of this._materials.values()) { + const u = m.userData?.owUniforms; + if (u) u.owGroundY.value = y; + } + } + + get detailNormal() { + return this._shared?.detailNormal ?? null; + } + + get macroTexture() { + return this._shared?.macro ?? null; + } + + bakeMasks(geometry, opts) { + return bakeMasks(geometry, opts); + } + + setMask(geometry, opts) { + return setMask(geometry, opts); + } + + /** Debug: a grid of spheres/panels showing every surface in the library. */ + debugBoard(opts = {}) { + return buildDebugBoard(this, opts); + } + + dispose() { + for (const m of this._materials.values()) m.dispose(); + this._materials.clear(); + this._sets.clear(); + this._forge?.dispose(); + this._forge = null; + this._shared = null; + this._built = false; + } +} + +/** + * Assigning a hex number over a THREE.Color property silently replaces the + * Color object and produces NaN uniforms (a black material), so colour-valued + * properties have to go through .set(). + */ +function applyProps(mat, props) { + for (const k in props) { + const cur = mat[k]; + const v = props[k]; + if (cur && cur.isColor && !(v && v.isColor)) cur.set(v); + else if (cur && cur.isVector2 && Array.isArray(v)) cur.fromArray(v); + else mat[k] = v; + } + return mat; +} + +function stableKey(opts) { + const keys = Object.keys(opts).sort(); + if (!keys.length) return ''; + return keys.map((k) => `${k}=${JSON.stringify(opts[k])}`).join(','); +} + +/** + * A material test board — one sphere plus one bevelled panel per surface. + * Lives here rather than in a test file so the capture harness and any other + * subsystem can ask for it. + */ +function buildDebugBoard(system, { columns = 6, spacing = 1.25, radius = 0.42 } = {}) { + const group = new THREE.Group(); + const names = system.names(); + const sphere = new THREE.SphereGeometry(radius, 64, 48); + const panel = new THREE.BoxGeometry(0.92, 0.92, 0.14, 8, 8, 2); + system.bakeMasks(panel, { wear: 1, grime: 0.9 }); + + names.forEach((name, i) => { + const x = (i % columns) * spacing; + const y = -Math.floor(i / columns) * spacing; + const mat = system.get(name, { vertexMasks: false }); + const s = new THREE.Mesh(sphere, mat); + s.position.set(x, y, 0); + s.castShadow = s.receiveShadow = true; + group.add(s); + + const pm = system.get(name, { vertexMasks: true, localSpace: true }); + const b = new THREE.Mesh(panel, pm); + b.position.set(x, y, -0.9); + b.castShadow = b.receiveShadow = true; + group.add(b); + }); + group.userData.names = names; + return group; +} + +export { bakeMasks, setMask, LIBRARY }; diff --git a/src/lib/cod/materials/library.js b/src/lib/cod/materials/library.js new file mode 100644 index 00000000..bb4b3fc9 --- /dev/null +++ b/src/lib/cod/materials/library.js @@ -0,0 +1,405 @@ +import * as THREE from 'three'; +import { CONCRETE, BRICK, PLASTER, TILE } from './glsl/surfaces-arch.js'; +import { ASPHALT, SAND, DIRT, GRAVEL } from './glsl/surfaces-ground.js'; +import { METAL_RUST, METAL_PAINTED, METAL_BRUSHED, CORRUGATED } from './glsl/surfaces-metal.js'; +import { WOOD, FABRIC, BURLAP, FOLIAGE, RUBBER, GLASS } from './glsl/surfaces-organic.js'; + +/** + * The surface library. + * + * `bake` — how the texture set is generated (resolution, the metres the tile + * spans, and the peak-to-trough relief that sets the normal slope). + * `mat` — parameters for the material shader extension (see shader.js). + * `three` — properties applied straight to the THREE material. + * `surface` — the shared physics/FX surface vocabulary from ARCHITECTURE.md. + */ +export const LIBRARY = { + // ------------------------------------------------------------ masonry ---- + concrete: { + glsl: CONCRETE, + surface: 'concrete', + bake: { size: 1024, worldSize: 2.5, relief: 0.09, seed: 11, param: [1, 0, 0, 0] }, + mat: { + scale: 2.5, + parallax: 0.016, + detile: 0.4, + detail: [9, 0.95, 0.58, 26], + macro: [0.085, 0.62, 0.24, 0.45], + // 3-4 m pour/wash variation at real contrast plus a 12 m band, so a long + // retaining wall or a barrier run is not one value end to end. + macroBig: [2.05, 0.130, 0.028, 0], + patch: [0.28, 2.0, 0.145, -0.08], + weather: [0.42, 0.4, 0.55, 0.5], + wearColor: 0x9a978f, + dustColor: 0x8b7f6a, + grimeColor: 0x2b2823, + roughness: [0.98, -0.01, 0.24], + }, + }, + concrete_floor: { + glsl: CONCRETE, + surface: 'concrete', + bake: { size: 1024, worldSize: 2.5, relief: 0.075, seed: 47, param: [0, 1, 0, 0] }, + mat: { + scale: 3.2, + parallax: 0.01, + detile: 0, + detail: [9, 0.90, 0.52, 26], + macro: [0.075, 0.48, 0.18, 0.3], + macroRelief: 0.3, + weather: [0.55, 0.1, 0.15, 0.5], + roughness: [1.0, 0.0, 0.22], + }, + }, + brick: { + glsl: BRICK, + surface: 'concrete', + bake: { size: 1024, worldSize: 1.35, relief: 0.055, seed: 23 }, + mat: { + scale: 1.35, + // 0.12 of height range x 0.024 m = ~2.5 mm of mortar parallax + parallax: 0.024, + parallaxLayers: 24, + detile: 0, + detail: [7, 0.88, 0.48, 22], + macro: [0.09, 0.58, 0.22, 0.55], + macroBig: [1.95, 0.115, 0.03, 0], + weather: [0.4, 0.5, 0.6, 0.55], + wearColor: 0xa08678, + grimeColor: 0x241f19, + roughness: [0.98, -0.01, 0.26], + }, + }, + plaster: { + glsl: PLASTER, + surface: 'plaster', + bake: { size: 1024, worldSize: 2.2, relief: 0.06, seed: 5 }, + mat: { + scale: 2.2, + parallax: 0.014, + detile: 0.8, + detail: [10, 0.95, 0.54, 24], + // 0.085 puts the coarsest band of the macro map at ~4 m; the contrast + // expansion is what turns it from a 5% wash into a real 20% swing, and the + // second band at 0.026 zones the facade at ~13 m. Between them a 12 m + // elevation reads as damp/dry/bleached areas instead of one flat colour. + macro: [0.085, 0.72, 0.26, 0.5], + macroBig: [2.15, 0.150, 0.026, 0], + // ~18% of every facade is a replastered rectangle at +/-17% value. + // A 12 m elevation seen at 3 m is mostly ONE surface, so the only thing + // that can stop it reading as flat colour is structure at 1-4 m. + patch: [0.34, 2.2, 0.175, -0.10], + // streaks are gated by the runoff model now, so the amplitude can be real + weather: [0.34, 0.5, 0.6, 0.5], + wearColor: 0xb0a692, + dustColor: 0x9c8a6c, + grimeColor: 0x2a251d, + roughness: [0.97, -0.02, 0.26], + }, + }, + tile: { + glsl: TILE, + surface: 'concrete', + bake: { size: 1024, worldSize: 1.5, relief: 0.03, seed: 31 }, + mat: { + scale: 1.5, + // 0.06 of height range x 0.03 m = ~1.8 mm of grout recess + parallax: 0.03, + parallaxLayers: 20, + detail: [8, 0.6, 0.36, 18], + macro: [0.09, 0.40, 0.16, 0.3], + // tiled walls are laid in batches: whole areas came from a different kiln + macroBig: [1.7, 0.075, 0.032, 0], + patch: [0.14, 1.7, 0.10, -0.05], + weather: [0.3, 0.2, 0.3, 0.5], + roughness: [0.9, -0.04, 0.16], + }, + }, + + // ------------------------------------------------------------- ground ---- + asphalt: { + glsl: ASPHALT, + surface: 'concrete', + bake: { size: 1024, worldSize: 3.0, relief: 0.075, seed: 71 }, + mat: { + scale: 3.0, + parallax: 0.014, + detile: 1.0, + // micro detail is gone by 16 m, so the near ground gains detail instead + // of shimmering at range + detail: [8, 0.8, 0.42, 18], + macro: [0.062, 0.52, 0.22, 0.25], + macroRelief: 0.55, + weather: [0.45, 0.05, 0.1, 0.26], + dustColor: 0x8b8071, + grimeColor: 0x232120, + roughness: [0.98, -0.02, 0.3], + }, + }, + sand: { + glsl: SAND, + surface: 'sand', + bake: { size: 1024, worldSize: 2.5, relief: 0.10, seed: 91 }, + mat: { + uvMode: 'triplanar', + scale: 2.5, + detile: 0, + detail: [8, 0.7, 0.30, 18], + macro: [0.050, 0.44, 0.14, 0.35], + macroRelief: 0.45, + weather: [0.15, 0.0, 0.0, 0.18], + dustColor: 0xa89066, + grimeColor: 0x4c4132, + roughness: [1.0, 0.0, 0.3], + }, + }, + dirt: { + glsl: DIRT, + surface: 'dirt', + bake: { size: 1024, worldSize: 2.5, relief: 0.12, seed: 13 }, + mat: { + uvMode: 'triplanar', + scale: 2.5, + detail: [7, 0.85, 0.36, 18], + macro: [0.055, 0.48, 0.18, 0.4], + macroRelief: 0.6, + weather: [0.2, 0.0, 0.0, 0.22], + dustColor: 0x94805c, + grimeColor: 0x37301f, + roughness: [0.98, -0.02, 0.3], + }, + }, + gravel: { + glsl: GRAVEL, + // 1K, not 512: at 512 the 9 mm grade was 2.5 texels wide and baked as + // noise. Aggregate has to be resolved in the tile or it cannot be resolved + // at all — the mip chain only ever removes information. + bake: { size: 1024, worldSize: 1.6, relief: 0.055, seed: 57 }, + surface: 'dirt', + mat: { + uvMode: 'triplanar', + scale: 1.6, + detail: [6, 0.8, 0.34, 20], + macro: [0.070, 0.44, 0.2, 0.3], + macroRelief: 0.7, + // Cavity grime on a surface whose height field IS its aggregate turns + // every gap between stones into a black pit; 0.5 was most of the + // bimodal histogram the critics measured on the road. + weather: [0.2, 0.0, 0.0, 0.16], + dustColor: 0xa2947a, + grimeColor: 0x4a4238, + roughness: [0.96, -0.03, 0.28], + }, + }, + + // -------------------------------------------------------------- metal ---- + metal_rust: { + glsl: METAL_RUST, + surface: 'metal', + bake: { size: 1024, worldSize: 1.2, relief: 0.035, seed: 37 }, + mat: { + scale: 1.2, + parallax: 0.004, + detail: [9, 0.7, 0.36, 16], + macro: [0.10, 0.30, 0.14, 0.4], + weather: [0.25, 0.4, 0.5, 0.35], + wearColor: 0x8c8f93, + wearMaterial: [0.28, 1.0, 0, 0.85], + }, + }, + metal_painted: { + glsl: METAL_PAINTED, + surface: 'metal', + bake: { + size: 1024, + worldSize: 1.5, + relief: 0.018, + seed: 61, + tintA: 0x4a5340, + tintB: 0x2a2f26, + }, + mat: { + scale: 1.5, + parallax: 0.003, + detail: [10, 0.6, 0.32, 16], + macro: [0.10, 0.28, 0.14, 0.35], + weather: [0.3, 0.45, 0.35, 0.35], + wearColor: 0x8f9296, + wearMaterial: [0.3, 1.0, 0, 0.9], + // painted metal has to stay glossy enough to glint, but never mirror + roughness: [0.92, -0.03, 0.22], + }, + }, + metal_brushed: { + glsl: METAL_BRUSHED, + surface: 'metal', + bake: { size: 512, worldSize: 0.8, relief: 0.004, seed: 83 }, + mat: { + scale: 0.8, + detail: [8, 0.25, 0.15, 8], + macro: [0.09, 0.14, 0.1, 0.2], + weather: [0.15, 0.15, 0.2, 0.2], + wearColor: 0xb9bcc0, + wearMaterial: [0.16, 1.0, 0, 0.9], + }, + three: { anisotropy: 0.65, anisotropyRotation: 0, physical: true }, + }, + corrugated: { + glsl: CORRUGATED, + surface: 'metal', + bake: { size: 1024, worldSize: 2.4, relief: 0.075, seed: 29 }, + mat: { + scale: 2.4, + parallax: 0.03, + parallaxLayers: 24, + detail: [10, 0.6, 0.32, 18], + macro: [0.09, 0.26, 0.12, 0.3], + weather: [0.3, 0.5, 0.5, 0.4], + wearColor: 0x9aa0a4, + wearMaterial: [0.32, 1.0, 0, 0.85], + }, + }, + + // ------------------------------------------------------------ organic ---- + wood: { + glsl: WOOD, + surface: 'wood', + bake: { size: 1024, worldSize: 2.0, relief: 0.038, seed: 19 }, + mat: { + scale: 2.0, + parallax: 0.008, + detail: [10, 0.8, 0.42, 18], + macro: [0.085, 0.34, 0.14, 0.5], + weather: [0.3, 0.35, 0.5, 0.45], + wearColor: 0xa88b62, + wearMaterial: [0.5, 0.0, 0, 0.7], + }, + }, + fabric: { + glsl: FABRIC, + surface: 'fabric', + // The weave carries ~0.3 of the height range, so 0.011 m of relief over a + // 0.7 m tile is a ~1.5-2 mm thread bump at the 0.26 m mapping the awnings + // use — a real weave, not a painted grid. + bake: { size: 512, worldSize: 0.7, relief: 0.008, seed: 43, tintA: 0x5a5445, tintB: 0x3a3830 }, + mat: { + scale: 0.7, + detail: [6, 0.42, 0.28, 10], + // 1.4 m macro at real contrast: sun-bleached panels and damp panels + macro: [0.12, 0.34, 0.12, 0.3], + macroBig: [1.8, 0.07, 0.09, 0], + weather: [0.25, 0.2, 0.3, 0.35], + normalStrength: 1.15, + /** + * Canvas passes 18% of the beam, its underside sits ~0.75 stops under its + * top, and the drape structure is a 10 cm fold field. This is the whole + * difference between fabric and painted cardboard. + */ + cloth: [0.20, 0.72, 0.26, 0], + }, + three: { physical: true, sheen: 0.55, sheenRoughness: 0.85, sheenColor: 0x8a8272 }, + }, + burlap: { + glsl: BURLAP, + surface: 'fabric', + // hessian is coarse: a fat, visible thread bump + bake: { size: 512, worldSize: 0.5, relief: 0.018, seed: 67 }, + mat: { + scale: 0.5, + parallax: 0.003, + detail: [6, 0.4, 0.28, 9], + macro: [0.14, 0.32, 0.12, 0.35], + macroBig: [1.7, 0.06, 0.11, 0], + weather: [0.4, 0.15, 0.35, 0.4], + dustColor: 0x9c8760, + normalStrength: 1.15, + // a filled bag transmits far less than a stretched canvas + cloth: [0.06, 0.86, 0.10, 0], + }, + three: { physical: true, sheen: 0.4, sheenRoughness: 0.95, sheenColor: 0x9c8b68 }, + }, + foliage: { + glsl: FOLIAGE, + surface: 'foliage', + bake: { size: 512, worldSize: 0.6, relief: 0.02, seed: 79 }, + mat: { + uvMode: 'mesh', + scale: 1, + alphaMask: true, + detail: [4, 0.25, 0.15, 8], + macro: [0.16, 0.3, 0.08, 0.6], + weather: [0.15, 0.0, 0.0, 0.2], + }, + three: { + side: THREE.DoubleSide, + alphaTest: 0.45, + physical: true, + sheen: 0.3, + sheenRoughness: 0.8, + sheenColor: 0x9fbd6a, + }, + }, + rubber: { + glsl: RUBBER, + surface: 'rubber', + bake: { size: 512, worldSize: 0.5, relief: 0.013, seed: 97 }, + mat: { + scale: 0.45, + detail: [7, 0.62, 0.42, 13], + // A tyre stack is a dark mass low in the frame, so it has nothing but its + // own variation to read by: bleached crowns, damp black sidewalls and the + // road dust that fills the tread. Without these it is a grey lozenge. + macro: [0.16, 0.36, 0.20, 0.18], + macroBig: [1.8, 0.10, 0.11, 0], + weather: [0.40, 0.18, 0.22, 0.45], + dustColor: 0x8d8478, + grimeColor: 0x181715, + tint: 0xfffaf2, + normalStrength: 1.25, + roughness: [0.94, -0.03, 0.34], + }, + }, + glass: { + glsl: GLASS, + surface: 'glass', + bake: { size: 512, worldSize: 2.0, relief: 0.0008, seed: 3 }, + mat: { + scale: 2.0, + detail: [3, 0.06, 0.05, 6], + macro: [0.05, 0.1, 0.06, 0.1], + weather: [0.1, 0.3, 0.4, 0.15], + normalStrength: 0.35, + roughness: [0.9, -0.01, 0.03], + }, + three: { + physical: true, + transparent: true, + opacity: 0.22, + side: THREE.DoubleSide, + envMapIntensity: 1.6, + ior: 1.52, + specularIntensity: 1, + depthWrite: false, + }, + }, +}; + +/** Alias -> library key, so callers can ask for the physics surface name. */ +export const ALIASES = { + metal: 'metal_painted', + steel: 'metal_brushed', + rust: 'metal_rust', + sandbag: 'burlap', + ground: 'dirt', + road: 'asphalt', + stucco: 'plaster', + wall: 'concrete', + floor: 'concrete_floor', + plank: 'wood', + leaf: 'foliage', + window: 'glass', +}; + +export function resolveName(name) { + return LIBRARY[name] ? name : (ALIASES[name] ?? name); +} diff --git a/src/lib/cod/materials/masks.js b/src/lib/cod/materials/masks.js new file mode 100644 index 00000000..95e2ecc7 --- /dev/null +++ b/src/lib/cod/materials/masks.js @@ -0,0 +1,233 @@ +import * as THREE from 'three'; + +/** + * Curvature-driven vertex masks. + * + * Convex edges get *wear* (paint chipped off, corners rubbed back to the + * substrate); concave creases get *grime* and extra AO. Baking this per-vertex + * costs nothing at runtime and is what stops modular kit pieces from reading as + * clean extruded boxes. + * + * Writes a 3-component `color` attribute: r = wear, g = grime, b = extra AO. + * All channels default to 0, which the material shader treats as "no effect", + * so it is always safe to add. + */ + +/** One triangle's three positions, in doubles — Vector3 arithmetic in disguise. */ +const _tri = new Float64Array(9); + +/** + * The backing xyz array of a 3-component attribute. + * + * Returns `attribute.array` when the layout already is a tight xyz triple — + * which is what `getX/getY/getZ` read anyway — and otherwise copies through the + * accessors so the callers below only ever deal with one layout. + */ +function plainXYZ(attr) { + if (attr.itemSize === 3 && !attr.normalized && !attr.isInterleavedBufferAttribute) { + return attr.array; + } + const out = new Float64Array(attr.count * 3); + for (let i = 0; i < attr.count; i++) { + out[i * 3] = attr.getX(i); + out[i * 3 + 1] = attr.getY(i); + out[i * 3 + 2] = attr.getZ(i); + } + return out; +} + +export function bakeMasks(geometry, opts = {}) { + const { + wear = 1, + grime = 1, + ao = 1, + /** vertices whose convexity exceeds this are treated as a hard edge */ + edgeThreshold = 0.06, + /** extra grime on downward faces (undersides collect dirt) */ + downGrime = 0.35, + /** extra wear on upward faces (walked on, rained on) */ + upWear = 0.15, + rng = null, + } = opts; + + const pos = geometry.getAttribute('position'); + if (!pos) return geometry; + let nrm = geometry.getAttribute('normal'); + if (!nrm) { + geometry.computeVertexNormals(); + nrm = geometry.getAttribute('normal'); + } + const count = pos.count; + const index = geometry.getIndex(); + const idx = index ? index.array : null; + const triCount = idx ? idx.length / 3 : count / 3; + // Read straight out of the backing array instead of through getX/getY/getZ: + // for a plain, non-normalised, 3-component attribute those accessors *are* + // `array[i * 3 + k]`, so the values — and therefore every sum below — are + // identical. Anything exotic (interleaved / normalised / padded) is copied + // through the accessors once so the loops stay on one code path. + const posArr = plainXYZ(pos); + const nrmArr = plainXYZ(nrm); + + // Hard-edged kit geometry duplicates its vertices per face, so raw vertex + // adjacency never crosses an edge and every box would come out perfectly + // clean. Cluster by position first so adjacency (and therefore curvature) + // spans the seam. + // + // This used to build a `${x},${y},${z}` string per vertex and hash that, + // which was over half of the whole bake (measured: 50-65 ms of the 110-118 ms + // this function costs at boot, over 202k vertices). Same grouping, no strings: + // hash the quantised triple to an int, chain the buckets, and compare the + // quantised coordinates exactly on collision. Cluster ids are still handed + // out in first-seen order, so every downstream sum is bit-identical. + const cluster = new Int32Array(count); + const qx = new Float64Array(count); + const qy = new Float64Array(count); + const qz = new Float64Array(count); + const chain = new Int32Array(count); // previous cluster sharing a hash, or -1 + const head = new Map(); // hash -> most recent cluster with that hash + let clusters = 0; + for (let i = 0; i < count; i++) { + const i3 = i * 3; + const x = Math.round(posArr[i3] * 8192); + const y = Math.round(posArr[i3 + 1] * 8192); + const z = Math.round(posArr[i3 + 2] * 8192); + const h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791)) | 0; + let c = head.get(h); + if (c === undefined) c = -1; + let found = -1; + while (c >= 0) { + if (qx[c] === x && qy[c] === y && qz[c] === z) { + found = c; + break; + } + c = chain[c]; + } + if (found < 0) { + found = clusters++; + qx[found] = x; + qy[found] = y; + qz[found] = z; + chain[found] = head.has(h) ? head.get(h) : -1; + head.set(h, found); + } + cluster[i] = found; + } + + // Per cluster: the summed face normal and the summed offset to its + // neighbours. dot(avgNormal, avgOffset) < 0 means the surrounding surface + // folds *away* from the normal => a convex edge; > 0 => a concave crease. + const sumOff = new Float32Array(clusters * 3); + const hits = new Float32Array(clusters); + const spread = new Float32Array(clusters); + const cnx = new Float32Array(clusters * 3); + const nCnt = new Float32Array(clusters); + + // Same arithmetic as before, in doubles out of the raw arrays: Vector3 holds + // JS numbers, so `copy().sub().normalize()` was already float64 over + // float32-widened inputs. `|| 1` guards a degenerate edge exactly as + // Vector3.normalize() does, and the accumulation order (both offsets summed, + // then added into the float32 accumulator) is preserved. + for (let t = 0; t < triCount; t++) { + const t3 = t * 3; + const ia = idx ? idx[t3] : t3; + const ib = idx ? idx[t3 + 1] : t3 + 1; + const ic = idx ? idx[t3 + 2] : t3 + 2; + _tri[0] = posArr[ia * 3]; + _tri[1] = posArr[ia * 3 + 1]; + _tri[2] = posArr[ia * 3 + 2]; + _tri[3] = posArr[ib * 3]; + _tri[4] = posArr[ib * 3 + 1]; + _tri[5] = posArr[ib * 3 + 2]; + _tri[6] = posArr[ic * 3]; + _tri[7] = posArr[ic * 3 + 1]; + _tri[8] = posArr[ic * 3 + 2]; + for (let k = 0; k < 3; k++) { + const i = k === 0 ? ia : k === 1 ? ib : ic; + const c = cluster[i]; + const c3 = c * 3; + const p3 = k * 3; + const q3 = (k + 1) % 3 * 3; + const r3 = (k + 2) % 3 * 3; + // Unit offsets so long thin triangles don't dominate the average. + const ux = _tri[q3] - _tri[p3]; + const uy = _tri[q3 + 1] - _tri[p3 + 1]; + const uz = _tri[q3 + 2] - _tri[p3 + 2]; + const us = 1 / (Math.sqrt(ux * ux + uy * uy + uz * uz) || 1); + const vx = _tri[r3] - _tri[p3]; + const vy = _tri[r3 + 1] - _tri[p3 + 1]; + const vz = _tri[r3 + 2] - _tri[p3 + 2]; + const vs = 1 / (Math.sqrt(vx * vx + vy * vy + vz * vz) || 1); + sumOff[c3 + 0] += ux * us + vx * vs; + sumOff[c3 + 1] += uy * us + vy * vs; + sumOff[c3 + 2] += uz * us + vz * vs; + hits[c] += 2; + const n3 = i * 3; + cnx[c3 + 0] += nrmArr[n3]; + cnx[c3 + 1] += nrmArr[n3 + 1]; + cnx[c3 + 2] += nrmArr[n3 + 2]; + nCnt[c] += 1; + } + } + // How much the normals at a cluster disagree — 0 on a flat face, high on a + // crease. This separates "on an edge" from "merely tilted". + const curve = new Float32Array(clusters); + for (let c = 0; c < clusters; c++) { + const nl = Math.hypot(cnx[c * 3], cnx[c * 3 + 1], cnx[c * 3 + 2]); + spread[c] = Math.min(1, Math.max(0, 1 - nl / Math.max(1, nCnt[c]))); + if (nl > 1e-6 && hits[c] > 0) { + const k = 1 / (nl * hits[c]); + curve[c] = + (cnx[c * 3] * sumOff[c * 3] + + cnx[c * 3 + 1] * sumOff[c * 3 + 1] + + cnx[c * 3 + 2] * sumOff[c * 3 + 2]) * + k; + } + } + + const colors = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + const c = cluster[i]; + const mean = curve[c]; + const crease = Math.min(1, spread[c] / 0.18); + // convex -> mean < 0, concave -> mean > 0 + const convex = crease * Math.min(1, Math.max(0, (-mean - edgeThreshold) / 0.22)); + const concave = crease * Math.min(1, Math.max(0, (mean - edgeThreshold) / 0.22)); + const ny = nrmArr[i * 3 + 1]; + const up = Math.max(0, ny); + const down = Math.max(0, -ny); + + let w = convex * wear + up * up * upWear * wear; + let g = concave * grime + down * downGrime * grime; + let o = concave * ao; + + if (rng) { + const j = 0.85 + rng.float() * 0.3; + w *= j; + g *= 2 - j; + } + colors[i * 3 + 0] = Math.min(1, w); + colors[i * 3 + 1] = Math.min(1, g); + colors[i * 3 + 2] = Math.min(1, o); + } + + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + return geometry; +} + +/** + * Push wear/grime straight onto an already-authored colour attribute without + * recomputing curvature — used by callers that know their own topology. + */ +export function setMask(geometry, { wear = 0, grime = 0, ao = 0 } = {}) { + const pos = geometry.getAttribute('position'); + if (!pos) return geometry; + const colors = new Float32Array(pos.count * 3); + for (let i = 0; i < pos.count; i++) { + colors[i * 3 + 0] = wear; + colors[i * 3 + 1] = grime; + colors[i * 3 + 2] = ao; + } + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + return geometry; +} diff --git a/src/lib/cod/materials/shader.js b/src/lib/cod/materials/shader.js new file mode 100644 index 00000000..03a7152f --- /dev/null +++ b/src/lib/cod/materials/shader.js @@ -0,0 +1,890 @@ +import * as THREE from 'three'; + +/** + * onBeforeCompile extension for MeshStandardMaterial / MeshPhysicalMaterial. + * + * Adds, on top of three's standard PBR shading: + * - world / object planar + triplanar projection (no UVs required on the mesh) + * - parallax occlusion mapping driven by the height packed in albedo.a + * - a micro detail-normal layer that fades out with distance + * - macro low-frequency variation (the anti-tiling multiply) + * - stochastic two-scale de-tiling with height-preserving blending + * - procedural weathering: top-face dust, rain streaks, ground splash + * - cavity grime and vertex-colour driven edge wear / dirt masks + * + * Everything is a #define so a material only pays for the features it enables. + * + * Vertex colour mask contract (all default to 0 = "no effect", so a mesh with + * no colour attribute is unaffected): + * r = edge wear, g = grime, b = extra AO, a = per-instance tint variation + */ + +const PARS_VERTEX = /* glsl */ ` +varying vec3 vOwWPos; +varying vec3 vOwWNrm; +#ifdef OW_OBJECT_SPACE + varying vec3 vOwOPos; + varying vec3 vOwONrm; + varying mat3 vOwP2V; +#endif +#ifdef OW_PARALLAX + varying vec3 vOwViewDirP; +#endif +`; + +const MAIN_VERTEX = /* glsl */ ` +{ + mat4 owModel = modelMatrix; + #ifdef USE_BATCHING + owModel = owModel * batchingMatrix; + #endif + #ifdef USE_INSTANCING + owModel = owModel * instanceMatrix; + #endif + vec4 owWP = owModel * vec4( transformed, 1.0 ); + vOwWPos = owWP.xyz; + vOwWNrm = normalize( mat3( owModel ) * objectNormal ); + + #ifdef OW_OBJECT_SPACE + vOwOPos = transformed; + vOwONrm = normalize( objectNormal ); + mat3 owR = mat3( owModel ); + owR[ 0 ] = normalize( owR[ 0 ] ); + owR[ 1 ] = normalize( owR[ 1 ] ); + owR[ 2 ] = normalize( owR[ 2 ] ); + vOwP2V = mat3( viewMatrix ) * owR; + #endif + + #ifdef OW_PARALLAX + #ifdef OW_OBJECT_SPACE + vOwViewDirP = ( inverse( owModel ) * vec4( cameraPosition, 1.0 ) ).xyz - transformed; + #else + vOwViewDirP = cameraPosition - owWP.xyz; + #endif + #endif +} +`; + +const PARS_FRAGMENT = /* glsl */ ` +varying vec3 vOwWPos; +varying vec3 vOwWNrm; +#ifdef OW_OBJECT_SPACE + varying vec3 vOwOPos; + varying vec3 vOwONrm; + varying mat3 vOwP2V; +#endif +#ifdef OW_PARALLAX + varying vec3 vOwViewDirP; +#endif + +uniform sampler2D owDetailNrm; +uniform sampler2D owDetailTex; // rgb = micro albedo variation, a = micro height +uniform sampler2D owMacroTex; +uniform vec4 owTile; // xy = scale (tiles per metre, or uv multiplier), zw = offset +uniform vec4 owDetailP; // x tile, y normal amt, z albedo amt, w fade distance +uniform vec4 owMacroP; // x scale, y albedo amt, z rough amt, w hue amt +uniform vec4 owMacroBig; // x contrast, y big-band amt, z big-band scale, w unused +uniform vec4 owPatchP; // x coverage, y cell metres, z albedo delta, w rough delta +uniform vec4 owClothP; // x transmission, y underside darkening, z fold amt, w unused +uniform vec4 owParallaxP; // x depth (m), y fade start, z fade end, w max layers +uniform vec4 owWeatherP; // x dust, y streak, z splash height, w cavity grime +uniform vec4 owWearP; // x wear amt, y grime amt, z vcol AO amt, w curvature +uniform vec3 owTintCol; +uniform vec3 owDustCol; +uniform vec3 owGrimeCol; +uniform vec3 owRustCol; +uniform vec4 owWearMat; // x rough, y metal, z reserved, w tint amount +uniform vec3 owWearCol; +uniform vec4 owRoughP; // x scale, y offset, z detile amount, w minimum +uniform float owNormalAmp; +uniform float owGroundY; +uniform float owAoAmt; +uniform float owMacroRelief; + +// Explicit-gradient sampling keeps the mip selection correct through the +// parallax march; OW_NOGRAD falls back to implicit derivatives. +#ifdef OW_NOGRAD + #define OW_TEX( t, uv, dx, dy ) texture2D( t, uv ) +#else + #define OW_TEX( t, uv, dx, dy ) textureGrad( t, uv, dx, dy ) +#endif + +/** + * One directional light's contribution to fabric transmission. A macro with a + * literal index rather than a loop: GLSL ES 1.00 will not index a uniform array + * of structs with a running variable, and the light count is 2 (sun + moon). + * + * owBackLit = beam landing on the face we are NOT looking at. + * owFwd = forward-scatter lobe, brightest looking nearly along the beam. + */ +#define OW_CLOTH_LIGHT( IDX ) { \ + IncidentLight owCl; \ + getDirectionalLightInfo( directionalLights[ IDX ], owCl ); \ + float owBackLit = max( 0.0, -dot( normal, owCl.direction ) ); \ + float owFwd = max( 0.0, dot( geometryViewDir, -owCl.direction ) ); \ + owTrans += owCl.color * ( owBackLit * ( 0.30 + 0.90 * owFwd * owFwd ) ); \ +} + +// filled by the surface evaluation, consumed by the chunk overrides below +vec4 owAlbedo; +vec3 owORM; // ao, rough, metal +vec3 owNormalV; // view-space shading normal +float owHeightS; + +float owHash11( float x ){ + float p = fract( x * 0.1031 ); + p *= p + 33.33; + p *= p + p; + return fract( p ); +} + +/** + * Runoff staining below a source. + * + * Real rain streaks start at something — a sill, a broken gutter, a slab edge — + * and die out a metre or so below it. sAxis is the horizontal coordinate along + * the wall, y the world height. Returns .x = 0..1: fades in over the first 15 cm + * below the source and out over the next 1.5 m, in discrete columns, so a wall + * gets a handful of dark runs rather than a uniform vertical grain. + * .y carries the per-column random used to pick rusted fixings. + */ +vec3 owRunoff( float sAxis, float y, float wobble ){ + float u = sAxis * 1.55; + float cell = floor( u ); // ~65 cm source columns + float lat = fract( u ); + float r0 = owHash11( cell * 1.37 + 3.1 ); + float r1 = owHash11( cell * 2.71 + 11.7 ); + // Only some columns have anything dripping down them. + float srcAmt = smoothstep( 0.30, 0.62, r0 ) * ( 0.55 + 0.45 * r1 ); + // Feathered across the column, so a run has soft sides instead of cell walls. + float bell = sin( lat * 3.14159265 ); + srcAmt *= bell * bell * ( 0.8 + 0.45 * r0 ); + // Sources sit roughly one storey apart, jittered per column. + const float SPACING = 2.85; + float jitter = r1 * 1.2 + r0 * 0.5; + float srcY = ( floor( ( y + jitter ) / SPACING ) + 1.0 ) * SPACING - jitter + wobble * 0.2; + float below = srcY - y; + float run = smoothstep( 0.0, 0.15, below ) * ( 1.0 - smoothstep( 0.15, 1.65, below ) ); + return vec3( clamp( run * srcAmt, 0.0, 1.0 ), r1, below ); +} + +mat3 owTangentFrame( vec3 eye, vec3 n, vec2 uv ){ + vec3 q0 = dFdx( eye ), q1 = dFdy( eye ); + vec2 s0 = dFdx( uv ), s1 = dFdy( uv ); + vec3 q1p = cross( q1, n ); + vec3 q0p = cross( n, q0 ); + vec3 T = q1p * s0.x + q0p * s1.x; + vec3 B = q1p * s0.y + q0p * s1.y; + float det = max( dot( T, T ), dot( B, B ) ); + float sc = ( det == 0.0 ) ? 0.0 : inversesqrt( det ); + return mat3( T * sc, B * sc, n ); +} + +struct OwFrame { vec2 uv; vec3 T; vec3 B; vec3 N; }; + +OwFrame owAxisFrame( vec3 p, vec3 n, int axis ){ + vec3 s = mix( vec3( -1.0 ), vec3( 1.0 ), step( 0.0, n ) ); + OwFrame f; + if ( axis == 0 ){ + f.uv = vec2( -p.z * s.x, p.y ); + f.T = vec3( 0.0, 0.0, -s.x ); f.B = vec3( 0.0, 1.0, 0.0 ); f.N = vec3( s.x, 0.0, 0.0 ); + } else if ( axis == 1 ){ + f.uv = vec2( p.x, -p.z * s.y ); + f.T = vec3( 1.0, 0.0, 0.0 ); f.B = vec3( 0.0, 0.0, -s.y ); f.N = vec3( 0.0, s.y, 0.0 ); + } else { + f.uv = vec2( p.x * s.z, p.y ); + f.T = vec3( s.z, 0.0, 0.0 ); f.B = vec3( 0.0, 1.0, 0.0 ); f.N = vec3( 0.0, 0.0, s.z ); + } + f.uv = f.uv * owTile.xy + owTile.zw; + return f; +} + +/** Re-anchor an axis frame onto the true interpolated normal. */ +void owOrthonormalise( inout OwFrame f, vec3 n ){ + f.N = n; + f.T = normalize( f.T - n * dot( n, f.T ) ); + f.B = cross( n, f.T ); +} + +/** + * Parallax occlusion mapping. Marches the height field stored in albedo.a + * and returns the displaced uv. Layer count follows the grazing angle and + * the whole effect fades out with distance. + */ +vec2 owPOM( vec2 uv, vec3 vt, vec2 ddx, vec2 ddy, float depth, float fade ){ + if ( depth <= 0.0 || fade <= 0.001 ) return uv; + float nl = mix( owParallaxP.w, 8.0, clamp( abs( vt.z ), 0.0, 1.0 ) ); + nl = max( nl * fade, 4.0 ); + float layer = 1.0 / nl; + vec2 P = ( vt.xy / max( abs( vt.z ), 0.30 ) ) * depth * fade; + vec2 dUv = P * layer; + + float cur = 0.0; + vec2 c = uv; + float d = 1.0 - OW_TEX( map, c, ddx, ddy ).a; + for ( int i = 0; i < 48; i ++ ){ + if ( cur >= d || float( i ) >= nl ) break; + c -= dUv; + d = 1.0 - OW_TEX( map, c, ddx, ddy ).a; + cur += layer; + } + vec2 prev = c + dUv; + float after = d - cur; + float before = ( 1.0 - OW_TEX( map, prev, ddx, ddy ).a ) - cur + layer; + float w = clamp( after / max( after - before, 1e-4 ), 0.0, 1.0 ); + return mix( c, prev, w ); +} + +/** Height-preserving blend of two texture samples (kills the mushy 50% lerp). */ +void owHeightBlend( inout vec4 a, inout vec3 ormA, inout vec3 nA, + vec4 b, vec3 ormB, vec3 nB, float t ){ + float wa = ( 1.0 - t ) + a.a * 0.6; + float wb = t + b.a * 0.6; + float k = max( wa, wb ) - 0.18; + wa = max( wa - k, 0.0 ); + wb = max( wb - k, 0.0 ); + float inv = 1.0 / max( wa + wb, 1e-4 ); + a = ( a * wa + b * wb ) * inv; + ormA = ( ormA * wa + ormB * wb ) * inv; + nA = normalize( ( nA * wa + nB * wb ) * inv ); +} +`; + +const MAIN_FRAGMENT = /* glsl */ ` +{ + float owDist = length( vViewPosition ); + float owFaceDir = gl_FrontFacing ? 1.0 : -1.0; + vec3 owNw = normalize( vOwWNrm ) * owFaceDir; + + #ifdef OW_OBJECT_SPACE + vec3 owP = vOwOPos; + vec3 owNp = normalize( vOwONrm ) * owFaceDir; + mat3 owP2V = vOwP2V; + #else + vec3 owP = vOwWPos; + vec3 owNp = owNw; + mat3 owP2V = mat3( viewMatrix ); + #endif + + vec4 alb; vec3 orm; vec3 nT; vec3 nShade; + // Micro (sub-millimetre) height from the shared detail set, -1..1. This is the + // aggregate / plaster-tooth / grit read at 0.5 m; it fades out with distance so + // the near ground gains detail instead of shimmering. + float owMicro = 0.0; + float owDetFade = 0.0; + + #ifdef OW_TRIPLANAR + + vec3 an = abs( owNp ); + vec3 w = pow( an, vec3( 5.0 ) ); + w /= max( w.x + w.y + w.z, 1e-4 ); + + OwFrame fx = owAxisFrame( owP, owNp, 0 ); + OwFrame fy = owAxisFrame( owP, owNp, 1 ); + OwFrame fz = owAxisFrame( owP, owNp, 2 ); + + vec4 ax = texture2D( map, fx.uv ); + vec4 ay = texture2D( map, fy.uv ); + vec4 az = texture2D( map, fz.uv ); + alb = ax * w.x + ay * w.y + az * w.z; + + vec3 ox = texture2D( roughnessMap, fx.uv ).rgb; + vec3 oy = texture2D( roughnessMap, fy.uv ).rgb; + vec3 oz = texture2D( roughnessMap, fz.uv ).rgb; + orm = ox * w.x + oy * w.y + oz * w.z; + + vec3 nx = texture2D( normalMap, fx.uv ).xyz * 2.0 - 1.0; + vec3 ny = texture2D( normalMap, fy.uv ).xyz * 2.0 - 1.0; + vec3 nz = texture2D( normalMap, fz.uv ).xyz * 2.0 - 1.0; + nx.xy *= owNormalAmp; ny.xy *= owNormalAmp; nz.xy *= owNormalAmp; + vec3 wnx = fx.T * nx.x + fx.B * nx.y + fx.N * nx.z; + vec3 wny = fy.T * ny.x + fy.B * ny.y + fy.N * ny.z; + vec3 wnz = fz.T * nz.x + fz.B * nz.y + fz.N * nz.z; + vec3 nP = normalize( wnx * w.x + wny * w.y + wnz * w.z ); + + // detail, projected on the dominant plane only (one extra fetch) + OwFrame fd = fz; + if ( an.y > max( an.x, an.z ) ) fd = fy; + else if ( an.x > an.z ) fd = fx; + vec2 detUv = fd.uv * owDetailP.x; + float detFade = 1.0 - smoothstep( owDetailP.w * 0.45, owDetailP.w, owDist ); + owDetFade = detFade; + vec3 dn = texture2D( owDetailNrm, detUv ).xyz * 2.0 - 1.0; + vec3 dW = fd.T * dn.x + fd.B * dn.y + fd.N * dn.z; + nP = normalize( nP + ( dW - fd.N * dot( dW, fd.N ) ) * owDetailP.y * detFade ); + vec4 dTex = texture2D( owDetailTex, detUv ); + owMicro = ( dTex.a - 0.5 ) * 2.0; + alb.rgb *= 1.0 + ( owMicro * 0.95 + ( dTex.r - 0.5 ) * 1.25 ) * owDetailP.z * detFade; + orm.r *= 1.0 - max( -owMicro, 0.0 ) * 0.30 * owDetailP.z * detFade; + + nShade = normalize( owP2V * nP ); + owHeightS = clamp( alb.a + owMicro * 0.16 * detFade, 0.0, 1.0 ); + + #else + + #ifdef OW_MESH_UV + vec2 baseUv = vMapUv * owTile.xy + owTile.zw; + mat3 tbnV = owTangentFrame( -vViewPosition, normalize( vNormal ) * owFaceDir, baseUv ); + OwFrame f; + f.uv = baseUv; + f.T = tbnV[ 0 ]; f.B = tbnV[ 1 ]; f.N = tbnV[ 2 ]; + #else + int axis = ( abs( owNp.x ) > abs( owNp.y ) ) + ? ( ( abs( owNp.x ) > abs( owNp.z ) ) ? 0 : 2 ) + : ( ( abs( owNp.y ) > abs( owNp.z ) ) ? 1 : 2 ); + OwFrame f = owAxisFrame( owP, owNp, axis ); + owOrthonormalise( f, owNp ); + #endif + + vec2 ddx = dFdx( f.uv ); + vec2 ddy = dFdy( f.uv ); + vec2 uv = f.uv; + + #ifdef OW_PARALLAX + #ifdef OW_MESH_UV + vec3 Vp = normalize( vViewPosition ); + #else + vec3 Vp = normalize( vOwViewDirP ); + #endif + vec3 vt = normalize( vec3( dot( Vp, f.T ), dot( Vp, f.B ), dot( Vp, f.N ) ) ); + float pFade = 1.0 - smoothstep( owParallaxP.y, owParallaxP.z, owDist ); + uv = owPOM( uv, vt, ddx, ddy, owParallaxP.x, pFade ); + #endif + + alb = OW_TEX( map, uv, ddx, ddy ); + orm = OW_TEX( roughnessMap, uv, ddx, ddy ).rgb; + nT = OW_TEX( normalMap, uv, ddx, ddy ).xyz * 2.0 - 1.0; + nT.xy *= owNormalAmp; + + #ifdef OW_DETILE + // Second sample of the same texture, rotated and rescaled, blended by a + // low-frequency mask: breaks the repeat without a second texture set. + vec2 uv2 = vec2( uv.x * 0.803 - uv.y * 0.596, uv.x * 0.596 + uv.y * 0.803 ) * 0.617 + + vec2( 0.37, 0.71 ); + vec2 ddx2 = vec2( ddx.x * 0.803 - ddx.y * 0.596, ddx.x * 0.596 + ddx.y * 0.803 ) * 0.617; + vec2 ddy2 = vec2( ddy.x * 0.803 - ddy.y * 0.596, ddy.x * 0.596 + ddy.y * 0.803 ) * 0.617; + vec4 alb2 = OW_TEX( map, uv2, ddx2, ddy2 ); + vec3 orm2 = OW_TEX( roughnessMap, uv2, ddx2, ddy2 ).rgb; + vec3 n2 = OW_TEX( normalMap, uv2, ddx2, ddy2 ).xyz * 2.0 - 1.0; + n2.xy *= owNormalAmp; + #endif + + // ---- micro detail normal, faded by distance ---- + float detFade = 1.0 - smoothstep( owDetailP.w * 0.45, owDetailP.w, owDist ); + owDetFade = detFade; + vec3 dn = OW_TEX( owDetailNrm, uv * owDetailP.x, ddx * owDetailP.x, ddy * owDetailP.x ).xyz * 2.0 - 1.0; + nT = normalize( vec3( nT.xy + dn.xy * owDetailP.y * detFade, nT.z ) ); + #ifdef OW_DETILE + n2 = normalize( vec3( n2.xy + dn.xy * owDetailP.y * detFade, n2.z ) ); + float dtm = clamp( ( texture2D( owMacroTex, ( owP.xz + owP.y * 0.7 ) * owMacroP.x * 5.0 + 0.21 ).g - 0.36 ) * 2.4, 0.0, 1.0 ); + owHeightBlend( alb, orm, nT, alb2, orm2, n2, dtm * owRoughP.z ); + #endif + // Sub-millimetre aggregate / tooth / grit: the height channel of the shared + // micro set drives an albedo speckle *and* the cavity height, so the layer + // shades instead of just tinting. + vec4 dTex = OW_TEX( owDetailTex, uv * owDetailP.x, ddx * owDetailP.x, ddy * owDetailP.x ); + owMicro = ( dTex.a - 0.5 ) * 2.0; + alb.rgb *= 1.0 + ( owMicro * 0.95 + ( dTex.r - 0.5 ) * 1.25 ) * owDetailP.z * detFade; + // Aggregate reads dark in its troughs even in full sun, because a trough + // is a tiny occluded pocket. Modulating only the albedo gives a washed + // pattern; darkening the cavity as well is what makes it read as depth. + orm.r *= 1.0 - max( -owMicro, 0.0 ) * 0.30 * owDetailP.z * detFade; + + owHeightS = clamp( alb.a + owMicro * 0.16 * detFade, 0.0, 1.0 ); + #ifdef OW_MESH_UV + nShade = normalize( f.T * nT.x + f.B * nT.y + f.N * nT.z ); + #else + nShade = normalize( owP2V * ( f.T * nT.x + f.B * nT.y + f.N * nT.z ) ); + #endif + + #endif + + // ------------------------------------------------ macro variation ---- + vec2 macroUv = mix( vec2( vOwWPos.x + vOwWPos.z * 0.63, vOwWPos.y ), vOwWPos.xz, + step( 0.62, abs( owNw.y ) ) ); + float owUpFace = step( 0.62, abs( owNw.y ) ); + vec4 mac1 = texture2D( owMacroTex, macroUv * owMacroP.x ); + vec4 mac2 = texture2D( owMacroTex, macroUv * owMacroP.x * 0.211 + 0.37 ); + // fbm never spans 0..1, so averaging two bands collapses toward 0.5 and the + // "anti-tiling" multiply becomes a 5% wash. owMacroBig.x expands the contrast + // back out before it is used, which is what lets a 12 m facade break up. + float macro = clamp( ( mac1.r * 0.55 + mac2.b * 0.45 - 0.5 ) * owMacroBig.x + 0.5, 0.0, 1.0 ); + alb.rgb *= mix( 1.0, 0.55 + 0.92 * macro, owMacroP.y ); + // A second, much larger band (8-16 m features): the difference between one + // sun-bleached end of a facade and the damp end, which is the signal that + // survives at 40 m when everything finer has mipped away. + if ( owMacroBig.y > 0.0 ) { + vec2 bigUv = macroUv * owMacroBig.z; + float big = texture2D( owMacroTex, bigUv ).r * 0.62 + + texture2D( owMacroTex, bigUv * 0.37 + 0.61 ).b * 0.38; + big = clamp( ( big - 0.5 ) * 2.3, -1.0, 1.0 ); + alb.rgb *= 1.0 + big * owMacroBig.y; + orm.g = clamp( orm.g - big * owMacroBig.y * 0.55, 0.0, 1.0 ); + } + alb.rgb *= mix( vec3( 1.0 ), vec3( 1.05, 1.0, 0.93 ), ( mac2.r - 0.5 ) * owMacroP.w ); + // Roughness has to vary or nothing in the frame ever glints: a broad patch + // term plus a tighter one, both signed, plus the micro tooth. + orm.g = clamp( orm.g + ( mac1.g - 0.5 ) * owMacroP.z + + ( mac1.a - 0.5 ) * 0.16 + - owMicro * 0.07 * owDetFade, 0.0, 1.0 ); + + #ifdef OW_MACRO_RELIEF + // Ruts, drifts and shallow patches at 1-4 m. The tile can't carry anything + // this large, so the shading normal is tilted by the gradient of the macro + // map — stones and swales then catch the sun instead of reading as dither. + vec2 mUv = macroUv * owMacroP.x; + float mhx = texture2D( owMacroTex, mUv + vec2( 0.035, 0.0 ) ).b; + float mhy = texture2D( owMacroTex, mUv + vec2( 0.0, 0.035 ) ).b; + vec2 mg = ( vec2( mhx, mhy ) - mac1.b ) * owMacroRelief * owUpFace; + vec3 tiltW = vec3( -mg.x, 0.0, -mg.y ); + tiltW -= owNw * dot( owNw, tiltW ); + nShade = normalize( nShade + mat3( viewMatrix ) * tiltW ); + alb.rgb *= 1.0 - ( mac1.b - 0.5 ) * 0.16 * owUpFace; + #endif + + // Horizontal coordinate along a wall, shared by the patch and runoff layers. + float owVert = smoothstep( 0.72, 0.34, abs( owNw.y ) ); + float owSAxis = vOwWPos.z * owNw.x - vOwWPos.x * owNw.z; + + // ------------------------------------------------- repair patches ---- + #ifdef OW_PATCH + { + // Somebody has replastered part of this wall. A repair is a RECTANGLE in the + // plane of the facade, a few percent off the surrounding mix in value, a + // little smoother because it is newer, and it has a trowel edge — a small + // raised ridge where the new render was feathered out. Covering ~10% of each + // facade with these is what stops a 12 m wall reading as one flat colour. + float cw = max( owPatchP.y, 0.4 ); + vec2 pc = vec2( owSAxis, vOwWPos.y ) / cw; + // wander the lattice so the cells are not a visible grid + pc += ( vec2( mac2.r, mac2.g ) - 0.5 ) * 0.35; + vec2 cid = floor( pc ); + vec2 cf = pc - cid; + float r0 = owHash11( cid.x * 7.31 + cid.y * 13.77 + 5.1 ); + float r1 = owHash11( cid.x * 3.17 + cid.y * 9.41 + 21.3 ); + float r2 = owHash11( cid.x * 11.93 + cid.y * 4.73 + 37.7 ); + float r3 = owHash11( cid.x * 5.51 + cid.y * 17.29 + 53.9 ); + float has = step( 1.0 - clamp( owPatchP.x, 0.0, 1.0 ), r0 ); + vec2 lo = vec2( 0.05 + r1 * 0.30, 0.05 + r2 * 0.30 ); + vec2 hi = vec2( 0.95 - r2 * 0.26, 0.95 - r3 * 0.26 ); + float fe = 0.028 + 0.030 * r1; // ~3-6 cm of trowel feather + vec2 a0 = smoothstep( lo, lo + fe, cf ); + vec2 a1 = 1.0 - smoothstep( hi - fe, hi, cf ); + float pm = a0.x * a0.y * a1.x * a1.y * has * owVert; + if ( pm > 0.001 ) { + float sgn = r3 > 0.48 ? 1.0 : -1.0; + alb.rgb *= 1.0 + sgn * owPatchP.z * pm; + // A cement repair is greyer and cooler than the render around it; a patch + // in the original mix that has weathered separately goes warmer. Value + // alone reads as a lighting artefact — it needs the hue shift too. + vec3 pTint = sgn > 0.0 ? vec3( 0.975, 0.988, 1.020 ) : vec3( 1.030, 1.008, 0.968 ); + alb.rgb *= mix( vec3( 1.0 ), pTint, pm ); + // a fresh coat has lost the mould and the fine crazing of the old wall + orm.g = clamp( orm.g + owPatchP.w * pm, 0.0, 1.0 ); + // the trowel edge: a bright arris where the new render feathers out + float lip = pm * ( 1.0 - pm ) * 4.0; + alb.rgb *= 1.0 + lip * 0.13; + owHeightS = clamp( owHeightS + pm * 0.07 + lip * 0.05, 0.0, 1.0 ); + } + } + #endif + + // ------------------------------------------------------ weathering ---- + #ifdef OW_WEATHER + float up = clamp( owNw.y, 0.0, 1.0 ); + float dust = up * up * owWeatherP.x * smoothstep( 0.30, 0.80, mac1.b * 0.7 + mac2.g * 0.5 ); + alb.rgb = mix( alb.rgb, owDustCol, dust * 0.75 ); + orm.g = clamp( orm.g + dust * 0.30, 0.0, 1.0 ); + orm.b *= 1.0 - dust * 0.85; + nShade = normalize( mix( nShade, normalize( owP2V * owNp ), dust * 0.35 ) ); + + // ---- rain runoff ------------------------------------------------------- + // Streaks live on near-vertical faces only, below a source, and are 3-8 cm + // wide with roughly a 3:1 vertical stretch. (A 10:1 stretch of a value-noise + // channel over a whole wall is a wood-grain generator, not weathering.) + float vert = owVert; + float sAxis = owSAxis; + float sN = texture2D( owMacroTex, vec2( sAxis * 0.46, vOwWPos.y * 0.155 ) ).a; + float sFine = texture2D( owMacroTex, vec2( sAxis * 1.35 + 0.4, vOwWPos.y * 0.42 ) ).g; + vec3 runoff = owRunoff( sAxis, vOwWPos.y, sN - 0.5 ); + float streak = clamp( owWeatherP.y * 2.2, 0.0, 1.15 ) * vert * runoff.x + * smoothstep( 0.30, 0.66, sN * 0.72 + sFine * 0.38 ); + streak = clamp( streak, 0.0, 1.0 ); + #ifdef OW_VCOL_MASKS + // The world knows exactly where the water comes off — buildings.js places a + // runoff strip under every sill, shopfront head and cornice with the grime + // mask driven to ~1 at its source (see util.runoffStreak). A mask that high + // only ever comes from something authored as a stain, so it drives the run + // outright instead of merely modulating the procedural columns. + float owStainM = smoothstep( 0.58, 0.98, vColor.g ); + streak = clamp( streak * ( 0.45 + 0.75 * clamp( vColor.g * 1.5 + vColor.b * 0.6, 0.0, 1.0 ) ) + + owStainM * vert + * ( 0.55 + 0.45 * smoothstep( 0.20, 0.70, sN * 0.6 + sFine * 0.55 ) ), + 0.0, 1.0 ); + #endif + // A wet-then-dried run on render is a real 20-35% drop in albedo: at 10% it + // is invisible from across the street, which is the whole point of a streak. + vec3 runCol = mix( alb.rgb * 0.72, owGrimeCol, 0.26 ); + // Rust bleed under metal fixings — brackets, rebar ends, gutter straps. + // strongest right under the fixing, thinning as it runs down + float rust = clamp( step( 0.86, runoff.y ) * 0.9 + orm.b * 0.5, 0.0, 1.0 ) + * ( 0.30 + 0.70 * ( 1.0 - smoothstep( 0.1, 0.9, runoff.z ) ) ); + runCol = mix( runCol, mix( alb.rgb * 0.94, owRustCol, 0.5 ), rust ); + alb.rgb = mix( alb.rgb, runCol, streak ); + orm.g = clamp( orm.g + streak * 0.09, 0.0, 1.0 ); + orm.b *= 1.0 - streak * 0.35; + + // ---- ground splash ---------------------------------------------------- + // A hard dirt band in the bottom ~20 cm plus thinning splatter above it. + float hAbove = vOwWPos.y - owGroundY; + float band = 1.0 - smoothstep( 0.02, 0.22, hAbove ); + float spray = 1.0 - smoothstep( 0.10, max( owWeatherP.z, 1e-3 ), hAbove ); + float splash = vert * max( band, spray * spray * 0.85 ) * step( 1e-4, owWeatherP.z ); + // Broken up at 1-2 m, but with a floor so the base of every wall darkens. + splash *= 0.55 + 0.45 * smoothstep( 0.25, 0.72, mac1.b * 0.7 + mac2.g * 0.4 ); + // Dust and rain-thrown dirt, not soot: a blend of the two weathering colours. + vec3 splashCol = mix( owGrimeCol, owDustCol * 0.9, 0.35 ); + alb.rgb = mix( alb.rgb * ( 1.0 - splash * 0.35 ), splashCol, splash * 0.42 ); + orm.g = clamp( orm.g + splash * 0.16 - band * vert * 0.10, 0.0, 1.0 ); + orm.r *= 1.0 - splash * 0.18; + orm.b *= 1.0 - splash * 0.7; + + // ---- dust wedge at the wall / ground junction ------------------------- + // A wall does not meet the ground on a line: wind and foot traffic pile a + // 25-40 cm wedge of the ground's own dust against it, and the value of that + // wedge is most of the way from the wall to the road. Without it every + // wall/ground junction in the frame is a razor cut. + float wedgeH = 0.26 + 0.18 * ( mac1.r * 0.6 + mac2.b * 0.7 ); + float wedge = vert * ( 1.0 - smoothstep( wedgeH * 0.25, wedgeH, hAbove ) ); + wedge *= wedge * ( 0.7 + 0.5 * smoothstep( 0.2, 0.8, mac2.g ) ); + wedge = clamp( wedge, 0.0, 1.0 ) * step( 1e-4, owWeatherP.z ); + alb.rgb = mix( alb.rgb, owDustCol, wedge * 0.46 ); + orm.g = clamp( orm.g + wedge * 0.07, 0.0, 1.0 ); + orm.b *= 1.0 - wedge * 0.9; + // dust is loose powder: kill the sharp tile relief inside the wedge + nShade = normalize( mix( nShade, normalize( owP2V * owNp ), wedge * 0.45 ) ); + #endif + + // ------------------------------------------- cavity + vertex masks ---- + float cav = 1.0 - owHeightS; + alb.rgb = mix( alb.rgb, owGrimeCol, cav * cav * owWeatherP.w ); + orm.r *= 1.0 - cav * owWeatherP.w * 0.5; + + #ifdef OW_VCOL_MASKS + // Wear is broken up by the macro noise and biased to the high points of the + // height field, so an edge rubs through in patches rather than as a band. + // + // The height bias is deliberately shallow (0.55 -> 1.0, not 0 -> 1). On a + // prop the mask is a thin band along an arris and the bias just decides + // which grains inside that band rub through. On a large surface whose + // height field IS its aggregate — a road, a gravel yard — a 0 -> 1 bias + // turns the wear layer into a per-stone brightener, and since the mask is + // painted over the whole plane every stone crown in the frame lights up. + // That was most of the road's salt-and-pepper histogram. + float wearN = smoothstep( 0.25, 0.85, mac1.b * 0.65 + mac2.a * 0.55 ); + float wearM = vColor.r * owWearP.x * ( 0.55 + 0.45 * smoothstep( 0.30, 0.80, owHeightS ) ) + * ( 0.25 + 1.15 * wearN ); + wearM = clamp( wearM, 0.0, 1.0 ); + alb.rgb = mix( alb.rgb, owWearCol, wearM * owWearMat.w ); + orm.g = mix( orm.g, owWearMat.x, wearM ); + orm.b = mix( orm.b, owWearMat.y, wearM ); + float grimeM = vColor.g * owWearP.y * ( 0.35 + 0.65 * cav ) * ( 0.45 + 0.9 * mac2.g ); + alb.rgb = mix( alb.rgb, owGrimeCol, grimeM * 0.8 ); + orm.g = clamp( orm.g + grimeM * 0.22, 0.0, 1.0 ); + orm.b *= 1.0 - grimeM * 0.8; + orm.r *= 1.0 - vColor.b * owWearP.z; + #endif + + #ifdef OW_CLOTH + // The underside of a stretched canopy is never the same value as its top: it + // sits in its own shadow, it collects soot off the street, and the only sun + // that reaches it comes through the weave. Matching the two values is what + // makes fabric read as painted card with a knife edge. + float owDown = smoothstep( 0.10, -0.70, owNw.y ); + alb.rgb *= mix( 1.0, owClothP.y, owDown ); + orm.g = clamp( orm.g + owDown * 0.05, 0.0, 1.0 ); + // 8-14 cm drape structure. The tile carries the weave and the camo blotches + // but nothing at the scale of a fold, so the shading normal is tilted by the + // gradient of the macro band — the cloth then catches the sun in ridges. + if ( owClothP.z > 0.0 ) { + vec2 fUv = vec2( vOwWPos.x + vOwWPos.z * 0.63, vOwWPos.y * 0.7 + vOwWPos.z * 0.4 ) * 3.4; + float f0 = texture2D( owMacroTex, fUv ).b; + float fx = texture2D( owMacroTex, fUv + vec2( 0.05, 0.0 ) ).b; + float fy = texture2D( owMacroTex, fUv + vec2( 0.0, 0.05 ) ).b; + vec3 tiltC = vec3( -( fx - f0 ), -( fy - f0 ), 0.0 ) * owClothP.z * 9.0; + nShade = normalize( nShade + vec3( tiltC.x, tiltC.y, 0.0 ) ); + alb.rgb *= 1.0 - ( f0 - 0.5 ) * owClothP.z * 0.9; + } + #endif + + // ------------------------------------------------------------ tint ---- + alb.rgb *= owTintCol; + // owRoughP.w is a per-surface floor: tile, glass and painted metal must stay + // glossy enough to actually catch a highlight. + orm.g = clamp( orm.g * owRoughP.x + owRoughP.y, max( owRoughP.w, 0.015 ), 1.0 ); + + owAlbedo = alb; + owORM = orm; + owNormalV = nShade; +} + +diffuseColor.rgb *= owAlbedo.rgb; +#ifdef OW_ALPHA_MASK + diffuseColor.a *= owAlbedo.a; +#endif +`; + +/** + * Fabric transmission. + * + * A sunlit canvas awning is not opaque: 15-25% of the beam comes through it, so + * from underneath you see a glowing sheet whose folds read as density and whose + * edge is bright. That single term is most of what makes cloth read as cloth + * rather than as painted card. + * + * It sums over every directional light rather than reusing `directLight` (which + * after the loop holds whichever light was added *last* — the moon, here), and + * it is occluded by the baked cavity/AO term so a canopy inside an arcade does + * not glow. + */ +const CLOTH_LIGHT = /* glsl */ ` +#if defined( OW_CLOTH ) && ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct ) +{ + vec3 owTrans = vec3( 0.0 ); + OW_CLOTH_LIGHT( 0 ) + #if NUM_DIR_LIGHTS > 1 + OW_CLOTH_LIGHT( 1 ) + #endif + #if NUM_DIR_LIGHTS > 2 + OW_CLOTH_LIGHT( 2 ) + #endif + reflectedLight.directDiffuse += owTrans * diffuseColor.rgb + * ( owClothP.x * clamp( owORM.r, 0.0, 1.0 ) ); +} +#endif +`; + +/** Chunk overrides applied after the main injection. */ +const OVERRIDES = [ + ['#include ', '// vertex colours are masks here, see OW_VCOL_MASKS'], + ['#include ', '#include \n' + CLOTH_LIGHT], + ['#include ', 'float roughnessFactor = roughness * owORM.g;'], + ['#include ', 'float metalnessFactor = metalness * owORM.b;'], + ['#include ', 'normal = owNormalV;'], + [ + '#include ', + /* glsl */ ` + { + float ambientOcclusion = ( owORM.r - 1.0 ) * owAoAmt + 1.0; + reflectedLight.indirectDiffuse *= ambientOcclusion; + #if defined( USE_CLEARCOAT ) + clearcoatSpecularIndirect *= ambientOcclusion; + #endif + #if defined( USE_SHEEN ) + sheenSpecularIndirect *= ambientOcclusion; + #endif + #if defined( USE_ENVMAP ) && defined( STANDARD ) + // Specular occlusion on top of an already AO-heavy cavity map wipes out + // every glint on detailed geometry, so it only gets 60% of the term. + float dotNV = saturate( dot( geometryNormal, geometryViewDir ) ); + float aoSpec = mix( 1.0, clamp( ambientOcclusion, 0.0, 1.0 ), 0.6 ); + reflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, aoSpec, material.roughness ); + #endif + }`, + ], +]; + +export const DEFAULT_PARAMS = { + /** 'planar' (world dominant axis) | 'triplanar' | 'mesh' */ + uvMode: 'planar', + /** project in the object's local space instead of world space */ + localSpace: false, + /** metres per texture tile */ + scale: 2, + /** uv offset */ + offset: [0, 0], + /** parallax depth in metres; 0 disables */ + parallax: 0, + parallaxFade: [6, 14], + parallaxLayers: 22, + /** detail layer: tiles-per-base-tile, normal strength, albedo strength, fade metres */ + detail: [11, 0.55, 0.35, 16], + /** + * Metres the shared detail tile should span in the world. + * + * detail[0] is expressed *per base tile*, which silently ties the micro + * layer's world scale to the macro layer's. A prop-scale variant such as + * `wood_prop` (scale 0.55 m) with detail[0] = 10 was mapping the 0.25 m + * detail bake into 55 mm — every 1.6 mm grain became 0.35 mm, i.e. under one + * pixel at 0.5 m, so the entire micro layer filtered away to nothing and + * every prop read as flat colour up close. That is measurable: cranking + * detail[2] from 0.42 to 2.5 on the market stall changed the frame by + * nothing at all. + * + * So detail[0] is now DERIVED from `scale` unless this is set to 0, which + * keeps the micro tooth at a fixed physical size no matter how the surface + * is mapped. 0.26 m matches the bake's authored worldSize of 0.25 m. + */ + detailWorld: 0.26, + /** macro: world scale, albedo strength, roughness strength, hue strength */ + macro: [0.045, 0.35, 0.1, 0.35], + /** + * Macro contrast expansion plus a second, much larger band: + * [ contrast, bigAmplitude, bigWorldScale, unused ]. 1/bigWorldScale is the + * period of the macro texture in metres, and its coarsest band is a third of + * that — so 0.028 gives ~12 m features. + */ + macroBig: [1, 0, 0.03, 0], + /** + * Repair patches on vertical faces: [ coverage 0..1, cell metres, + * albedo delta, roughness delta ]. 0 coverage disables the layer. + */ + patch: [0, 2.6, 0.12, -0.08], + /** + * Fabric: [ transmission 0..1, underside albedo multiplier, fold amount, + * unused ]. transmission 0 and multiplier 1 disable the whole cloth layer. + */ + cloth: [0, 1, 0, 0], + /** macro-gradient normal tilt on up-facing surfaces (ruts / drifts); 0 = off */ + macroRelief: 0, + /** de-tiling second-sample blend amount (0 disables the extra fetches) */ + detile: 0, + /** weathering: dust, rain streaks, ground-splash height, cavity grime */ + weather: [0.35, 0.3, 0.55, 0.4], + groundY: 0, + /** vertex-colour masks: wear, grime, extra AO, unused */ + wear: [0.5, 0.7, 0.5, 0], + /** + * [ roughness, METALNESS, unused, tint amount ] where the wear mask is 1. + * + * The metalness used to default to 0.5, so every worn edge on concrete, + * plaster, brick, timber, hessian and the road turned half metal and picked + * up a specular tint it has no business having. Only the metal library + * entries — which set their own wearMaterial — should ever raise this. + */ + wearMaterial: [0.42, 0.0, 0, 0.5], + wearColor: 0x8d8b86, + dustColor: 0x6b6154, + grimeColor: 0x2a2620, + rustColor: 0x6d3a1c, + tint: 0xffffff, + normalStrength: 1, + /** roughness [ scale, offset, minimum ] */ + roughness: [1, 0, 0.06], + aoStrength: 1, + alphaMask: false, + vertexMasks: false, + noGrad: false, +}; + +/** THREE.Color already converts hex (sRGB) into the linear working space. */ +function col(v) { + return v instanceof THREE.Color ? v.clone() : new THREE.Color(v); +} + +/** + * Install the extension on a material. + * @param {THREE.MeshStandardMaterial} material + * @param {object} p merged parameters (see DEFAULT_PARAMS) + * @param {object} shared { detailNormal, macro } + */ +export function extendMaterial(material, p, shared) { + // Mesh-UV mode treats `scale` as a repeat count; projected modes treat it as + // metres per tile. + const tileScale = p.uvMode === 'mesh' ? p.scale : 1 / p.scale; + + /** + * Keep the micro tooth at a fixed size in metres (see DEFAULT_PARAMS.detailWorld). + * + * Only for surfaces mapped at 0.3 m or coarser — i.e. architecture, ground + * and world props. A viewmodel part is mapped at 0.02-0.12 m and wants its + * detail an order of magnitude finer than a wall's; forcing 0.26 m on it + * would put a 2 mm aggregate tooth on a bolt carrier. + */ + const dw = p.detailWorld ?? DEFAULT_PARAMS.detailWorld; + const detailTiles = + p.uvMode === 'mesh' || !(dw > 0) || p.scale < 0.3 + ? p.detail[0] + : Math.max(1.2, p.scale / dw); + + const u = { + owDetailNrm: { value: shared.detailNormal }, + owDetailTex: { value: shared.detailAlbedo ?? shared.detailNormal }, + owMacroTex: { value: shared.macro }, + owTile: { value: new THREE.Vector4(tileScale, tileScale, p.offset[0], p.offset[1]) }, + owDetailP: { + value: new THREE.Vector4(detailTiles, p.detail[1], p.detail[2], p.detail[3]), + }, + owMacroP: { value: new THREE.Vector4(...p.macro) }, + owMacroBig: { value: new THREE.Vector4(...(p.macroBig ?? DEFAULT_PARAMS.macroBig)) }, + owPatchP: { value: new THREE.Vector4(...(p.patch ?? DEFAULT_PARAMS.patch)) }, + owClothP: { value: new THREE.Vector4(...(p.cloth ?? DEFAULT_PARAMS.cloth)) }, + owParallaxP: { + value: new THREE.Vector4(p.parallax, p.parallaxFade[0], p.parallaxFade[1], p.parallaxLayers), + }, + owWeatherP: { value: new THREE.Vector4(...p.weather) }, + owWearP: { value: new THREE.Vector4(...p.wear) }, + owTintCol: { value: col(p.tint) }, + owDustCol: { value: col(p.dustColor) }, + owGrimeCol: { value: col(p.grimeColor) }, + owRustCol: { value: col(p.rustColor ?? DEFAULT_PARAMS.rustColor) }, + owWearCol: { value: col(p.wearColor) }, + owWearMat: { value: new THREE.Vector4(...p.wearMaterial) }, + owRoughP: { + value: new THREE.Vector4( + p.roughness[0], + p.roughness[1], + p.detile, + p.roughness[2] ?? DEFAULT_PARAMS.roughness[2] + ), + }, + owNormalAmp: { value: p.normalStrength }, + owGroundY: { value: p.groundY }, + owAoAmt: { value: p.aoStrength }, + owMacroRelief: { value: p.macroRelief ?? 0 }, + }; + + const defines = {}; + if (p.uvMode === 'triplanar') defines.OW_TRIPLANAR = ''; + else if (p.uvMode === 'mesh') defines.OW_MESH_UV = ''; + if (p.localSpace) defines.OW_OBJECT_SPACE = ''; + if (p.parallax > 0 && p.uvMode !== 'triplanar') defines.OW_PARALLAX = ''; + if (p.detile > 0 && p.uvMode !== 'triplanar') defines.OW_DETILE = ''; + if (p.weather[0] > 0 || p.weather[1] > 0 || p.weather[2] > 0) defines.OW_WEATHER = ''; + if ((p.patch?.[0] ?? 0) > 0) defines.OW_PATCH = ''; + if ((p.cloth?.[0] ?? 0) > 0 || (p.cloth?.[1] ?? 1) < 1) defines.OW_CLOTH = ''; + if ((p.macroRelief ?? 0) > 0) defines.OW_MACRO_RELIEF = ''; + if (p.vertexMasks) defines.OW_VCOL_MASKS = ''; + if (p.alphaMask) defines.OW_ALPHA_MASK = ''; + if (p.noGrad) defines.OW_NOGRAD = ''; + + Object.assign(material.defines ?? (material.defines = {}), defines); + material.userData.owUniforms = u; + material.userData.owParams = p; + + const key = Object.keys(defines).sort().join('|'); + material.customProgramCacheKey = () => 'ow:' + key; + + material.onBeforeCompile = (shader) => { + Object.assign(shader.uniforms, u); + + shader.vertexShader = shader.vertexShader + .replace('#include ', '#include \n' + PARS_VERTEX) + .replace('#include ', '#include \n' + MAIN_VERTEX); + + // The pars block must land *after* three has declared map / normalMap / + // roughnessMap, so it hooks the last pars include rather than . + let fs = shader.fragmentShader + .replace( + '#include ', + '#include \n' + PARS_FRAGMENT + ) + .replace('#include ', MAIN_FRAGMENT); + + for (const [find, repl] of OVERRIDES) fs = fs.replace(find, repl); + shader.fragmentShader = fs; + }; + + material.needsUpdate = true; + return material; +} diff --git a/src/lib/cod/math.js b/src/lib/cod/math.js new file mode 100644 index 00000000..947f28a5 --- /dev/null +++ b/src/lib/cod/math.js @@ -0,0 +1,400 @@ +/** + * Allocation-free geometric kernel for the physics system. + * + * Every routine here takes scalar components and writes into a caller-supplied + * "out" record, so the hot paths (BVH traversal, capsule sweeps, contact + * generation) never touch the allocator. Records are plain objects with fixed + * shapes so V8 keeps them monomorphic. + * + * Conventions + * - Right-handed, Y-up, metres. + * - A capsule is the Minkowski sum of a segment (p0..p1) and a sphere of + * radius r. p0/p1 are the *sphere centres*, not the tips. + * - Triangle winding is CCW when seen from the front face; the geometric + * normal is normalize(cross(b-a, c-a)). + */ + +export const EPS = 1e-9; + +export function clamp(v, lo, hi) { + return v < lo ? lo : v > hi ? hi : v; +} + +/** A closest-feature record. Reused everywhere; never allocated per query. */ +export function makeClosest() { + return { d2: 0, ax: 0, ay: 0, az: 0, bx: 0, by: 0, bz: 0, s: 0, t: 0 }; +} + +/** A raycast/sweep result record. */ +export function makeHitRecord() { + return { + hit: false, + t: 0, + px: 0, + py: 0, + pz: 0, + nx: 0, + ny: 1, + nz: 0, + tri: -1, + surface: 0, + object: -1, + frontFace: true, + body: null, + }; +} + +/* ------------------------------------------------------------------ */ +/* Ray primitives */ +/* ------------------------------------------------------------------ */ + +/** + * Möller–Trumbore. Returns the ray parameter t (distance if dir is unit) or + * -1 on miss. Does not cull backfaces — penetration needs exit hits. + * `out.frontFace` is written when out is supplied. + */ +export function rayTriangle( + ox, oy, oz, dx, dy, dz, + ax, ay, az, bx, by, bz, cx, cy, cz, + out +) { + const e1x = bx - ax, e1y = by - ay, e1z = bz - az; + const e2x = cx - ax, e2y = cy - ay, e2z = cz - az; + const px = dy * e2z - dz * e2y; + const py = dz * e2x - dx * e2z; + const pz = dx * e2y - dy * e2x; + const det = e1x * px + e1y * py + e1z * pz; + if (det > -1e-12 && det < 1e-12) return -1; // parallel + const inv = 1 / det; + const tx = ox - ax, ty = oy - ay, tz = oz - az; + const u = (tx * px + ty * py + tz * pz) * inv; + if (u < -1e-6 || u > 1.000001) return -1; + const qx = ty * e1z - tz * e1y; + const qy = tz * e1x - tx * e1z; + const qz = tx * e1y - ty * e1x; + const v = (dx * qx + dy * qy + dz * qz) * inv; + if (v < -1e-6 || u + v > 1.000001) return -1; + const t = (e2x * qx + e2y * qy + e2z * qz) * inv; + if (out) out.frontFace = det > 0; + return t; +} + +/** + * Slab test against an AABB using precomputed reciprocal direction. + * Returns the entry distance, or Infinity on miss. Handles rays starting + * inside the box (returns 0). + */ +export function rayAabb( + ox, oy, oz, ix, iy, iz, + minx, miny, minz, maxx, maxy, maxz, + tmax +) { + let t0 = (minx - ox) * ix; + let t1 = (maxx - ox) * ix; + let lo = t0 < t1 ? t0 : t1; + let hi = t0 < t1 ? t1 : t0; + t0 = (miny - oy) * iy; + t1 = (maxy - oy) * iy; + const lo1 = t0 < t1 ? t0 : t1; + const hi1 = t0 < t1 ? t1 : t0; + if (lo1 > lo) lo = lo1; + if (hi1 < hi) hi = hi1; + t0 = (minz - oz) * iz; + t1 = (maxz - oz) * iz; + const lo2 = t0 < t1 ? t0 : t1; + const hi2 = t0 < t1 ? t1 : t0; + if (lo2 > lo) lo = lo2; + if (hi2 < hi) hi = hi2; + if (hi < 0 || lo > hi || lo > tmax) return Infinity; + return lo < 0 ? 0 : lo; +} + +/* ------------------------------------------------------------------ */ +/* Closest-feature queries */ +/* ------------------------------------------------------------------ */ + +/** Ericson, Real-Time Collision Detection §5.1.5. Writes out.b* = point on tri. */ +export function closestPtPointTriangle( + px, py, pz, + ax, ay, az, bx, by, bz, cx, cy, cz, + out +) { + const abx = bx - ax, aby = by - ay, abz = bz - az; + const acx = cx - ax, acy = cy - ay, acz = cz - az; + const apx = px - ax, apy = py - ay, apz = pz - az; + const d1 = abx * apx + aby * apy + abz * apz; + const d2 = acx * apx + acy * apy + acz * apz; + if (d1 <= 0 && d2 <= 0) { + out.bx = ax; out.by = ay; out.bz = az; + return; + } + const bpx = px - bx, bpy = py - by, bpz = pz - bz; + const d3 = abx * bpx + aby * bpy + abz * bpz; + const d4 = acx * bpx + acy * bpy + acz * bpz; + if (d3 >= 0 && d4 <= d3) { + out.bx = bx; out.by = by; out.bz = bz; + return; + } + const vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) { + const v = d1 / (d1 - d3); + out.bx = ax + abx * v; out.by = ay + aby * v; out.bz = az + abz * v; + return; + } + const cpx = px - cx, cpy = py - cy, cpz = pz - cz; + const d5 = abx * cpx + aby * cpy + abz * cpz; + const d6 = acx * cpx + acy * cpy + acz * cpz; + if (d6 >= 0 && d5 <= d6) { + out.bx = cx; out.by = cy; out.bz = cz; + return; + } + const vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) { + const w = d2 / (d2 - d6); + out.bx = ax + acx * w; out.by = ay + acy * w; out.bz = az + acz * w; + return; + } + const va = d3 * d6 - d5 * d4; + if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { + const w = (d4 - d3) / (d4 - d3 + (d5 - d6)); + out.bx = bx + (cx - bx) * w; out.by = by + (cy - by) * w; out.bz = bz + (cz - bz) * w; + return; + } + const denom = 1 / (va + vb + vc); + const v = vb * denom; + const w = vc * denom; + out.bx = ax + abx * v + acx * w; + out.by = ay + aby * v + acy * w; + out.bz = az + abz * v + acz * w; +} + +/** + * Closest points between segments p1q1 and p2q2 (Ericson §5.1.9). + * Writes out.a* (on segment 1), out.b* (on segment 2), out.s/out.t, out.d2. + */ +export function closestPtSegSeg( + p1x, p1y, p1z, q1x, q1y, q1z, + p2x, p2y, p2z, q2x, q2y, q2z, + out +) { + const dx1 = q1x - p1x, dy1 = q1y - p1y, dz1 = q1z - p1z; + const dx2 = q2x - p2x, dy2 = q2y - p2y, dz2 = q2z - p2z; + const rx = p1x - p2x, ry = p1y - p2y, rz = p1z - p2z; + const a = dx1 * dx1 + dy1 * dy1 + dz1 * dz1; + const e = dx2 * dx2 + dy2 * dy2 + dz2 * dz2; + const f = dx2 * rx + dy2 * ry + dz2 * rz; + let s, t; + if (a <= EPS && e <= EPS) { + s = 0; t = 0; + } else if (a <= EPS) { + s = 0; + t = clamp(f / e, 0, 1); + } else { + const c = dx1 * rx + dy1 * ry + dz1 * rz; + if (e <= EPS) { + t = 0; + s = clamp(-c / a, 0, 1); + } else { + const b = dx1 * dx2 + dy1 * dy2 + dz1 * dz2; + const denom = a * e - b * b; + s = denom !== 0 ? clamp((b * f - c * e) / denom, 0, 1) : 0; + t = (b * s + f) / e; + if (t < 0) { + t = 0; + s = clamp(-c / a, 0, 1); + } else if (t > 1) { + t = 1; + s = clamp((b - c) / a, 0, 1); + } + } + } + const ax = p1x + dx1 * s, ay = p1y + dy1 * s, az = p1z + dz1 * s; + const bx = p2x + dx2 * t, by = p2y + dy2 * t, bz = p2z + dz2 * t; + out.ax = ax; out.ay = ay; out.az = az; + out.bx = bx; out.by = by; out.bz = bz; + out.s = s; out.t = t; + const ex = ax - bx, ey = ay - by, ez = az - bz; + out.d2 = ex * ex + ey * ey + ez * ez; + return out.d2; +} + +const _tmp = makeClosest(); + +/** + * Squared distance between segment p0p1 and triangle abc, plus the closest + * point pair (out.a* on the segment, out.b* on the triangle). + * + * This is the single most important routine in the system: capsule sweeps, + * capsule overlap, ragdoll bone collision and rigid-body probes all reduce to + * it. Cost is ~5 sub-queries worst case, early-outs on intersection. + */ +export function segTriangleClosest( + p0x, p0y, p0z, p1x, p1y, p1z, + ax, ay, az, bx, by, bz, cx, cy, cz, + out +) { + // Plane straddle test first: if the segment crosses the triangle interior the + // distance is exactly zero and we can skip the five edge/vertex sub-queries. + const abx = bx - ax, aby = by - ay, abz = bz - az; + const acx = cx - ax, acy = cy - ay, acz = cz - az; + const nx = aby * acz - abz * acy; + const ny = abz * acx - abx * acz; + const nz = abx * acy - aby * acx; + const d0 = nx * (p0x - ax) + ny * (p0y - ay) + nz * (p0z - az); + const d1 = nx * (p1x - ax) + ny * (p1y - ay) + nz * (p1z - az); + if ((d0 > 0) !== (d1 > 0)) { + const denom = d0 - d1; + if (denom !== 0) { + const u = d0 / denom; + const ix = p0x + (p1x - p0x) * u; + const iy = p0y + (p1y - p0y) * u; + const iz = p0z + (p1z - p0z) * u; + // barycentric inside test + const vx = ix - ax, vy = iy - ay, vz = iz - az; + const d00 = abx * abx + aby * aby + abz * abz; + const d01 = abx * acx + aby * acy + abz * acz; + const d11 = acx * acx + acy * acy + acz * acz; + const d20 = vx * abx + vy * aby + vz * abz; + const d21 = vx * acx + vy * acy + vz * acz; + const den = d00 * d11 - d01 * d01; + if (den !== 0) { + const v = (d11 * d20 - d01 * d21) / den; + const w = (d00 * d21 - d01 * d20) / den; + if (v >= 0 && w >= 0 && v + w <= 1) { + out.d2 = 0; + out.ax = ix; out.ay = iy; out.az = iz; + out.bx = ix; out.by = iy; out.bz = iz; + out.s = u; out.t = 0; + return 0; + } + } + } + } + + let best = Infinity; + + // segment endpoints vs triangle face + closestPtPointTriangle(p0x, p0y, p0z, ax, ay, az, bx, by, bz, cx, cy, cz, _tmp); + let ex = p0x - _tmp.bx, ey = p0y - _tmp.by, ez = p0z - _tmp.bz; + let d = ex * ex + ey * ey + ez * ez; + if (d < best) { + best = d; + out.ax = p0x; out.ay = p0y; out.az = p0z; + out.bx = _tmp.bx; out.by = _tmp.by; out.bz = _tmp.bz; + out.s = 0; + } + closestPtPointTriangle(p1x, p1y, p1z, ax, ay, az, bx, by, bz, cx, cy, cz, _tmp); + ex = p1x - _tmp.bx; ey = p1y - _tmp.by; ez = p1z - _tmp.bz; + d = ex * ex + ey * ey + ez * ez; + if (d < best) { + best = d; + out.ax = p1x; out.ay = p1y; out.az = p1z; + out.bx = _tmp.bx; out.by = _tmp.by; out.bz = _tmp.bz; + out.s = 1; + } + + // segment vs the three triangle edges + d = closestPtSegSeg(p0x, p0y, p0z, p1x, p1y, p1z, ax, ay, az, bx, by, bz, _tmp); + if (d < best) { + best = d; + out.ax = _tmp.ax; out.ay = _tmp.ay; out.az = _tmp.az; + out.bx = _tmp.bx; out.by = _tmp.by; out.bz = _tmp.bz; + out.s = _tmp.s; + } + d = closestPtSegSeg(p0x, p0y, p0z, p1x, p1y, p1z, bx, by, bz, cx, cy, cz, _tmp); + if (d < best) { + best = d; + out.ax = _tmp.ax; out.ay = _tmp.ay; out.az = _tmp.az; + out.bx = _tmp.bx; out.by = _tmp.by; out.bz = _tmp.bz; + out.s = _tmp.s; + } + d = closestPtSegSeg(p0x, p0y, p0z, p1x, p1y, p1z, cx, cy, cz, ax, ay, az, _tmp); + if (d < best) { + best = d; + out.ax = _tmp.ax; out.ay = _tmp.ay; out.az = _tmp.az; + out.bx = _tmp.bx; out.by = _tmp.by; out.bz = _tmp.bz; + out.s = _tmp.s; + } + + out.d2 = best; + return best; +} + +/* ------------------------------------------------------------------ */ +/* Analytic sweeps used for dynamic (non-BVH) proxies */ +/* ------------------------------------------------------------------ */ + +/** Ray vs sphere. Returns entry distance or -1. */ +export function raySphere(ox, oy, oz, dx, dy, dz, cx, cy, cz, r, maxDist) { + const mx = ox - cx, my = oy - cy, mz = oz - cz; + const b = mx * dx + my * dy + mz * dz; + const c = mx * mx + my * my + mz * mz - r * r; + if (c > 0 && b > 0) return -1; + const disc = b * b - c; + if (disc < 0) return -1; + const sq = Math.sqrt(disc); + let t = -b - sq; + if (t < 0) t = -b + sq; // origin inside + if (t < 0 || t > maxDist) return -1; + return t; +} + +/** + * Ray vs capsule (segment a..b, radius r). Returns distance or -1. + * Solved as ray-vs-infinite-cylinder clipped by the two end spheres. + */ +export function rayCapsule( + ox, oy, oz, dx, dy, dz, + ax, ay, az, bx, by, bz, r, maxDist +) { + const abx = bx - ax, aby = by - ay, abz = bz - az; + const aox = ox - ax, aoy = oy - ay, aoz = oz - az; + const abd = abx * dx + aby * dy + abz * dz; + const abo = abx * aox + aby * aoy + abz * aoz; + const abab = abx * abx + aby * aby + abz * abz; + if (abab < EPS) return raySphere(ox, oy, oz, dx, dy, dz, ax, ay, az, r, maxDist); + const m = abd / abab; + const n = abo / abab; + const qx = dx - abx * m, qy = dy - aby * m, qz = dz - abz * m; + const sx = aox - abx * n, sy = aoy - aby * n, sz = aoz - abz * n; + const A = qx * qx + qy * qy + qz * qz; + const B = 2 * (qx * sx + qy * sy + qz * sz); + const C = sx * sx + sy * sy + sz * sz - r * r; + let best = -1; + if (A > EPS) { + const disc = B * B - 4 * A * C; + if (disc >= 0) { + const sq = Math.sqrt(disc); + let t = (-B - sq) / (2 * A); + if (t < 0) t = (-B + sq) / (2 * A); + if (t >= 0 && t <= maxDist) { + const k = n + t * m; + if (k >= 0 && k <= 1) best = t; + } + } + } else if (C <= 0) { + best = 0; // ray parallel to axis and already inside the cylinder + } + const t1 = raySphere(ox, oy, oz, dx, dy, dz, ax, ay, az, r, maxDist); + if (t1 >= 0 && (best < 0 || t1 < best)) best = t1; + const t2 = raySphere(ox, oy, oz, dx, dy, dz, bx, by, bz, r, maxDist); + if (t2 >= 0 && (best < 0 || t2 < best)) best = t2; + return best; +} + +/** Ray vs oriented box. `inv` is the world->local matrix elements (Matrix4.elements). */ +export function rayObb(ox, oy, oz, dx, dy, dz, inv, hx, hy, hz, maxDist) { + const lx = inv[0] * ox + inv[4] * oy + inv[8] * oz + inv[12]; + const ly = inv[1] * ox + inv[5] * oy + inv[9] * oz + inv[13]; + const lz = inv[2] * ox + inv[6] * oy + inv[10] * oz + inv[14]; + const ldx = inv[0] * dx + inv[4] * dy + inv[8] * dz; + const ldy = inv[1] * dx + inv[5] * dy + inv[9] * dz; + const ldz = inv[2] * dx + inv[6] * dy + inv[10] * dz; + const t = rayAabb( + lx, ly, lz, + 1 / (ldx || 1e-30), 1 / (ldy || 1e-30), 1 / (ldz || 1e-30), + -hx, -hy, -hz, hx, hy, hz, + maxDist + ); + return t === Infinity ? -1 : t; +} diff --git a/src/lib/cod/player/EmbodiedController.test.ts b/src/lib/cod/player/EmbodiedController.test.ts new file mode 100644 index 00000000..35658859 --- /dev/null +++ b/src/lib/cod/player/EmbodiedController.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect } from 'vitest'; +import { BoxGeometry, Mesh, MeshBasicMaterial } from 'three'; +import { EmbodiedController, type EmbodiedInput } from './EmbodiedController'; + +function box( + w: number, + h: number, + d: number, + x: number, + y: number, + z: number +): Mesh { + const m = new Mesh(new BoxGeometry(w, h, d), new MeshBasicMaterial()); + m.position.set(x, y, z); + m.updateWorldMatrix(true, false); + return m; +} + +// Floor whose top face sits at y = 0 (40×2×40 centred at y = −1), matching the +// controller's Y=0 ground convention. +const floor = () => box(40, 2, 40, 0, -1, 0); + +const input = (o: Partial = {}): EmbodiedInput => ({ + forward: 0, + right: 0, + jump: false, + sprint: false, + crouch: false, + prone: false, + mount: false, + yaw: 0, + ...o, +}); + +const DT = 1 / 60; +const stepN = (c: EmbodiedController, n: number) => { + for (let i = 0; i < n; i++) c.step(DT); +}; + +describe('EmbodiedController', () => { + it('bakes the supplied meshes into a non-empty BVH', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }]); + expect(c.triCount).toBeGreaterThan(0); + c.dispose(); + }); + + it('gravity settles the body onto the floor', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }], { + spawn: { x: 0, y: 2, z: 0 }, // dropped from 2 m + }); + c.setInput(input()); + stepN(c, 90); // ~1.5 s + expect(c.position.y).toBeLessThan(0.15); + expect(c.position.y).toBeGreaterThan(-0.15); + expect(c.grounded).toBe(true); + c.dispose(); + }); + + it('jump raises the body, then it falls back to the floor', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }]); + c.teleport(0, 0, 0); // settle grounded + c.setInput(input()); + c.step(DT); + expect(c.grounded).toBe(true); + + // Hold jump briefly to launch, capturing the apex. + c.setInput(input({ jump: true })); + let peak = 0; + for (let i = 0; i < 6; i++) { + c.step(DT); + peak = Math.max(peak, c.position.y); + } + expect(peak).toBeGreaterThan(0.3); // clearly airborne + + // Release and let gravity bring it home. + c.setInput(input()); + stepN(c, 120); // ~2 s + expect(c.position.y).toBeLessThan(0.15); + expect(c.grounded).toBe(true); + c.dispose(); + }); + + it('crouches (lower eye) and a low ceiling blocks standing back up', () => { + // Ceiling slab underside at y ≈ 1.1 over the origin. + const c = EmbodiedController.fromMeshes( + [ + { mesh: floor(), surface: 'dirt' }, + { mesh: box(4, 0.3, 4, 0, 1.25, 0), surface: 'concrete' }, + ], + { spawn: { x: 5, y: 0, z: 0 } } // spawn in the OPEN (no ceiling) as stand + ); + + // Crouch in the open, then move under the ceiling (crouched fits: crown 1.0 < 1.1). + c.setInput(input({ crouch: true })); + stepN(c, 8); + expect(c.stance).toBe('crouch'); + expect(c.eyeHeight).toBeLessThan(1.3); // eye glided down from 1.6 toward 1.0 + c.teleport(0, 0, 0); // now under the ceiling, still crouched + + // Attempt to stand (release → press): blocked by the ceiling, stays crouched. + c.setInput(input({ crouch: false })); + c.step(DT); + c.setInput(input({ crouch: true })); + stepN(c, 4); + expect(c.stance).toBe('crouch'); + c.dispose(); + }); + + it('a wall blocks forward movement (collide-and-slide)', () => { + // Wall slab at x ∈ [2.75, 3.25]. + const c = EmbodiedController.fromMeshes([ + { mesh: floor(), surface: 'dirt' }, + { mesh: box(0.5, 3, 12, 3, 1.5, 0), surface: 'concrete' }, + ]); + c.teleport(0, 0, 0); + // yaw = −π/2 makes "forward" point +x (toward the wall). + c.setInput(input({ forward: 1, yaw: -Math.PI / 2 })); + stepN(c, 180); // ~3 s of walking into the wall + expect(c.position.x).toBeGreaterThan(1); // it did travel toward the wall + expect(c.position.x).toBeLessThan(2.5); // but was stopped short of x = 2.75 + c.dispose(); + }); + + it('mounting the bike toggles riding and covers more ground than walking', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }]); + // Walk forward (yaw = −π/2 → +x) for 3 s. + c.teleport(0, 0, 0); + c.setInput(input({ forward: 1, yaw: -Math.PI / 2 })); + stepN(c, 180); + const walkX = c.position.x; + + // Mount (edge on B), seeding the bike heading toward +x (yaw −π/2), then ride + // forward for the same 3 s. On the bike, forward drives ALONG the heading. + c.teleport(0, 0, 0); + c.setInput(input({ mount: true, yaw: -Math.PI / 2 })); + c.step(DT); + expect(c.riding).toBe(true); + c.setInput(input({ forward: 1, yaw: -Math.PI / 2 })); + stepN(c, 180); + const bikeX = c.position.x; + + expect(bikeX).toBeGreaterThan(walkX * 1.4); // clearly faster on wheels + + // Dismount toggles back to on-foot. + c.setInput(input({ mount: true })); + c.step(DT); + expect(c.riding).toBe(false); + c.dispose(); + }); + + it('only mounts the bike when standing next to it (no conjuring)', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }]); + // Bike is parked at spawn (0,0,0). Stand far away and press B → no mount. + c.teleport(20, 0, 20); + expect(c.nearBike).toBe(false); + c.setInput(input({ mount: true })); + c.step(DT); + expect(c.riding).toBe(false); + + // Return to the parked bike; now B mounts. + c.setInput(input({ mount: false })); + c.step(DT); + c.teleport(0, 0, 1); // within mountRadius of the bike at the origin + expect(c.nearBike).toBe(true); + c.setInput(input({ mount: true })); + c.step(DT); + expect(c.riding).toBe(true); + + // Dismount parks the bike where you got off. + c.setInput(input({ mount: false })); + c.step(DT); + c.teleport(7, 0, 3); + c.setInput(input({ mount: true })); + c.step(DT); + expect(c.riding).toBe(false); + expect(c.bikePosition.x).toBeCloseTo(7, 1); + expect(c.bikePosition.z).toBeCloseTo(3, 1); + c.dispose(); + }); + + it('bike steering only bites while rolling (no pivot-in-place); on foot facingYaw tracks look', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }]); + c.teleport(0, 0, 0); + + // On foot, the facing IS the look yaw (no steering decoupling). + c.setInput(input({ yaw: 0.3 })); + c.step(DT); + expect(c.facingYaw).toBeCloseTo(0.3, 5); + + // Mount, then hold D (right = +1) = steer RIGHT: heading DECREASES (camera + // looks −sin h,−cos h, so a smaller heading rotates toward +X = screen-right). + c.setInput(input({ mount: true, yaw: 0 })); + c.step(DT); + expect(c.riding).toBe(true); + const h0 = c.facingYaw; + + // Steer with NO throttle: standing still, the front wheel can't turn the bike + // (non-holonomic - no pivot in place). + c.setInput(input({ right: 1, yaw: 0 })); + stepN(c, 60); + expect(c.facingYaw).toBeCloseTo(h0, 3); + + // Roll forward AND steer right (W+D): the heading now turns right (decreases). + c.setInput(input({ forward: 1, right: 1, yaw: 0 })); + stepN(c, 60); + const hRight = c.facingYaw; + expect(hRight).toBeLessThan(h0 - 0.1); + + // …and holding A (right = −1) = steer LEFT turns it back the other way. + c.setInput(input({ forward: 1, right: -1, yaw: 0 })); + stepN(c, 60); + expect(c.facingYaw).toBeGreaterThan(hRight); + c.dispose(); + }); + + it('the parked bike blocks walk-through once you have stepped clear of it', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }]); + // Bike is parked at the origin (spawn). Walk out +x to arm it as solid… + c.teleport(0, 0, 0); + c.setInput(input({ forward: 1, yaw: -Math.PI / 2 })); // forward → +x + stepN(c, 120); + expect(c.position.x).toBeGreaterThan(1.2); // clear of the bike core + + // …then walk back −x straight at it: blocked short of the core, not through. + c.setInput(input({ forward: 1, yaw: Math.PI / 2 })); // forward → −x + stepN(c, 240); + expect(c.position.x).toBeGreaterThan(0.6); // stopped ~ (0.5 + 0.4) out + expect(c.nearBike).toBe(true); // still close enough to mount + c.dispose(); + }); + + it('does not punt you off the bike on the frame you dismount (arm-gated)', () => { + const c = EmbodiedController.fromMeshes([{ mesh: floor(), surface: 'dirt' }]); + c.teleport(0, 0, 1); + c.setInput(input({ mount: true, yaw: 0 })); + c.step(DT); + expect(c.riding).toBe(true); + // Dismount (edge-triggered: release, then press): the bike parks at your + // feet; you must NOT be shoved away. + c.setInput(input({ mount: false })); + c.step(DT); + c.setInput(input({ mount: true })); + c.step(DT); + expect(c.riding).toBe(false); + const before = { x: c.position.x, z: c.position.z }; + c.setInput(input()); // stand still a moment + stepN(c, 20); + expect(Math.hypot(c.position.x - before.x, c.position.z - before.z)).toBeLessThan(0.2); + c.dispose(); + }); + + it('cameraDistance pulls the chase cam in when a wall is behind, else full', () => { + const c = EmbodiedController.fromMeshes([ + { mesh: floor(), surface: 'dirt' }, + { mesh: box(0.5, 6, 12, 4, 3, 0), surface: 'concrete' }, // wall x∈[3.75,4.25] + ]); + // Toward −x: open air → the full requested pull-back. + expect(c.cameraDistance(0, 1.7, 0, -1, 0, 0, 8, 0.3)).toBeCloseTo(8, 3); + // Toward +x: the wall at x≈3.75 clips it, so it stops short (minus pad). + const blocked = c.cameraDistance(0, 1.7, 0, 1, 0, 0, 8, 0.3); + expect(blocked).toBeGreaterThan(2); + expect(blocked).toBeLessThan(4); + c.dispose(); + }); + + it('sprint (Shift) covers clearly more ground than a plain walk', () => { + const walkThenMeasure = (sprint: boolean) => { + const c = EmbodiedController.fromMeshes([ + { mesh: floor(), surface: 'dirt' }, + ]); + c.teleport(0, 0, 0); + c.setInput(input({ forward: 1, sprint, yaw: -Math.PI / 2 })); // +x + stepN(c, 120); + const x = c.position.x; + c.dispose(); + return x; + }; + const walk = walkThenMeasure(false); + const sprint = walkThenMeasure(true); + expect(sprint).toBeGreaterThan(walk * 1.5); // ~2.2× by default; guard the boost + }); + + it('crouch is slower than walking, and prone is slower than crouch', () => { + const travel = (stance: 'stand' | 'crouch' | 'prone') => { + const c = EmbodiedController.fromMeshes([ + { mesh: floor(), surface: 'dirt' }, + ]); + c.teleport(0, 0, 0); + // Stance is an edge-triggered toggle: press once to enter, then hold + move. + if (stance !== 'stand') { + c.setInput(input({ [stance]: true } as Partial)); + c.step(DT); + expect(c.stance).toBe(stance); + } + c.setInput( + input({ + forward: 1, + yaw: -Math.PI / 2, + crouch: stance === 'crouch', + prone: stance === 'prone', + }) + ); + stepN(c, 120); + const x = c.position.x; + c.dispose(); + return x; + }; + const stand = travel('stand'); + const crouch = travel('crouch'); + const prone = travel('prone'); + expect(crouch).toBeLessThan(stand); + expect(prone).toBeLessThan(crouch); + }); + + it('collide() ejects a feet position poking through a façade', () => { + // A building footprint x,z ∈ [−3, 3]. + const c = EmbodiedController.fromMeshes([ + { mesh: box(6, 20, 6, 0, 10, 0), surface: 'concrete' }, + ]); + const pos = { x: 3.1, y: 0, z: 0 }; // capsule pokes through the +x wall + c.collide(pos, 0.4); + expect(pos.x).toBeGreaterThan(3.1); // shoved back out + expect(pos.y).toBe(0); // y left for the caller's ground snap + c.dispose(); + }); +}); diff --git a/src/lib/cod/player/EmbodiedController.ts b/src/lib/cod/player/EmbodiedController.ts new file mode 100644 index 00000000..f5f65230 --- /dev/null +++ b/src/lib/cod/player/EmbodiedController.ts @@ -0,0 +1,506 @@ +// EmbodiedController — the framework-agnostic first-person body, extracted from +// components/game/CodSkeleton's `FirstPersonWorld` so BOTH the cod-skeleton demo +// and the digital-twin diorama's Walk mode drive the same proven loop (harvest, +// not embed). +// +// It owns a swept-capsule `CharacterController` over a static BVH plus the +// fixed-step gravity/move integration, stand/crouch/prone stances (ceiling- +// checked via `setHeight`/`canFit`), sprint, and the glided eye height. It is +// pure CoD + math: it takes prebuilt THREE meshes only to bake the collider, and +// exposes plain-number state — no React/R3F/three-scene dependency. Callers feed +// a NORMALIZED input struct each frame (so each maps its own key convention: +// CodSkeleton off `e.key`, the twin Rig off `e.code`), read `eyePosition()` for +// the camera, and own look (yaw/pitch) themselves. + +import { StaticWorld } from '../bvh'; +import { CharacterController } from '../character'; +import { MASK } from '../surfaces'; +import { makeHitRecord } from '../math'; +import type { Mesh } from 'three'; + +export type Stance = 'stand' | 'crouch' | 'prone'; + +export interface StanceCfg { + /** Capsule height (feet→crown) for this stance, metres. */ + height: number; + /** Camera eye height above the feet, metres. */ + eye: number; + /** Speed as a fraction of the base walk speed. */ + speedRatio: number; + /** Footstep gait tag. */ + gait: string; + /** Head-bob scale. */ + bobScale: number; + /** Footstep-dust scale. */ + dustScale: number; +} + +/** Sprint = a modifier applied to the standing stance while moving forward. */ +export interface SprintCfg { + speedRatio: number; + gait: string; + bobScale: number; + dustScale: number; +} + +/** Normalized per-frame input. `crouch`/`prone` are HELD flags; the controller + * edge-detects the press internally (auto-repeat-safe), so callers just pass the + * current key state. */ +export interface EmbodiedInput { + /** −1 (back) … +1 (forward). */ + forward: number; + /** −1 (left) … +1 (right). */ + right: number; + jump: boolean; + sprint: boolean; + crouch: boolean; + prone: boolean; + /** Held; edge-detected to toggle the bike (vehicle) locomotion profile. */ + mount: boolean; + /** Look yaw (radians); movement basis is derived from it. */ + yaw: number; +} + +/** Vehicle (bicycle) locomotion: faster, with momentum — you accelerate up to + * speed and coast down instead of stopping instantly. */ +export interface BikeCfg { + /** Cruise top speed, m/s. */ + speed: number; + /** Pedal-hard (sprint) top speed, m/s. */ + sprint: number; + /** Seated eye height, m. */ + eye: number; + /** Time constant to reach target speed while pedalling (s). */ + accelTau: number; + /** Rolling-resistance time constant while coasting (s) — bigger = longer roll. */ + brakeTau: number; + /** How close (m) the player must be to the parked bike to mount it. */ + mountRadius: number; + /** Max steering rate, rad/s — the cap A/D can turn the heading at (reached only + * once rolling; see turnGain). */ + turnRate: number; + /** How steering authority ramps with roll speed, rad/s per m/s. The heading + * turns at min(turnRate, turnGain · speed), so at a standstill (speed 0) the + * front wheel does NOT rotate the bike — it needs forward motion to bite + * (a bicycle is non-holonomic; you can't pivot in place). */ + turnGain: number; +} + +export interface EmbodiedConfig { + radius?: number; + /** Standing capsule height; defaults to `stances.stand.height`. */ + height?: number; + stepHeight?: number; + /** Downward acceleration, m/s² (negative). */ + gravity?: number; + /** Jump take-off speed, m/s. */ + jump?: number; + /** Base walk speed, m/s (stance/sprint ratios scale it). */ + walkSpeed?: number; + /** Physics tick, seconds. */ + fixedStep?: number; + stances?: Record; + sprint?: SprintCfg; + /** Bike locomotion profile; omit to use the defaults. */ + bike?: BikeCfg; + spawn?: { x: number; y: number; z: number }; + /** Fired when the player state label changes — a foot stance + * (`stand`/`crouch`/`prone`) or `bike`. A raise blocked by a ceiling does NOT + * fire. Wire a HUD / event bus here. */ + onStanceChange?: (label: Stance | 'bike') => void; +} + +/** Human-scale defaults for the real-metre digital twin. */ +const DEFAULT_STANCE: Record = { + stand: { height: 1.85, eye: 1.7, speedRatio: 1, gait: 'walk', bobScale: 1, dustScale: 1 }, + crouch: { height: 1.0, eye: 1.0, speedRatio: 0.5, gait: 'crouch', bobScale: 0.5, dustScale: 0.4 }, + prone: { height: 0.5, eye: 0.45, speedRatio: 0.28, gait: 'crouch', bobScale: 0.2, dustScale: 0.25 }, +}; +// base 3.3 × 2.2 ≈ 7.3 m/s sprint (games run ~2–4× real pace: a flat monitor's +// narrow FOV kills the optic-flow that conveys speed, so real 1.4 m/s reads as a +// crawl — CoD-ish jog-default is the fix). +const DEFAULT_SPRINT: SprintCfg = { speedRatio: 2.2, gait: 'sprint', bobScale: 1.4, dustScale: 1.6 }; +// Bike: cruise ~14 m/s, pedal-hard ~22; ~1 s to reach speed, ~2.5 s coast; mount +// within 2.5 m of where it's parked. +const DEFAULT_BIKE: BikeCfg = { + speed: 16, + sprint: 28, + eye: 1.5, + accelTau: 0.35, // snappy pick-up — reaches cruise in ~1 s (was a sluggish 1.0) + brakeTau: 1.5, // a short roll on release, not a long glide + mountRadius: 3, // a touch more forgiving to walk up and mount + turnRate: 2.2, // steering-rate CAP (rad/s), reached only while rolling + turnGain: 0.5, // steering authority ramps with speed; 0 at a standstill +}; + +/** Collision radius of the parked bike as a walk-through obstacle, m (a bike is + * ~0.5 m wide). It only blocks the inner core, well inside `mountRadius`, so B + * can still mount from arm's length. */ +const BIKE_COLLIDE_RADIUS = 0.5; + +const ZERO_INPUT: EmbodiedInput = { + forward: 0, + right: 0, + jump: false, + sprint: false, + crouch: false, + prone: false, + mount: false, + yaw: 0, +}; + +export interface Vec3Like { + x: number; + y: number; + z: number; +} + +export class EmbodiedController { + readonly world: StaticWorld; + private readonly cc: CharacterController; + + private readonly stances: Record; + private readonly sprintCfg: SprintCfg; + private readonly bike: BikeCfg; + private readonly walkSpeed: number; + private readonly fixedStep: number; + private readonly gravity: number; + private readonly jumpSpeed: number; + private readonly onStanceChange?: (label: Stance | 'bike') => void; + + private input: EmbodiedInput = ZERO_INPUT; + private prevCrouch = false; + private prevProne = false; + private prevMount = false; + private riding_ = false; + /** The parked bike's world position + facing (a real object you return to). */ + private readonly bikePos_ = { x: 0, y: 0, z: 0 }; + private bikeYaw_ = 0; + /** The parked bike blocks walk-through, but only once you've stepped clear of + * it — so spawning/dismounting on top of it never punts you. Re-armed (false) + * every time it's (re)parked. */ + private bikeArmed_ = false; + /** Reused hit record for the chase-cam raycast (no per-frame allocation). */ + private readonly camHit_ = makeHitRecord(); + /** Capsule radius (matches the CharacterController); the bike-collision push + * uses it to keep the body's edge out of the bike core. */ + private readonly radius_: number; + /** Live steered heading while riding (rad); A/D turns it, W/S drives along it. */ + private bikeHeading_ = 0; + private accum = 0; + private eye: number; + + stance: Stance = 'stand'; + /** Distance the capsule travelled during the last `step()` (footstep cadence). */ + movedThisFrame = 0; + /** Feel snapshot for the last `step()` — mirror onto camera-feel/audio/dust. */ + gait = 'walk'; + bobScale = 1; + dustScale = 1; + + private constructor(world: StaticWorld, config: EmbodiedConfig) { + this.world = world; + this.stances = config.stances ?? DEFAULT_STANCE; + this.sprintCfg = config.sprint ?? DEFAULT_SPRINT; + this.bike = config.bike ?? DEFAULT_BIKE; + this.walkSpeed = config.walkSpeed ?? 3.3; + this.fixedStep = config.fixedStep ?? 1 / 120; + this.gravity = config.gravity ?? -22; + this.jumpSpeed = config.jump ?? 7; + this.onStanceChange = config.onStanceChange; + + const stand = this.stances.stand; + this.cc = new CharacterController(world, { + radius: config.radius ?? 0.4, + height: config.height ?? stand.height, + stepHeight: config.stepHeight ?? 0.4, + mask: MASK.CHARACTER, + position: config.spawn ?? { x: 0, y: 0, z: 0 }, + }); + this.radius_ = config.radius ?? 0.4; + this.eye = stand.eye; + // Bike starts parked at the spawn point (mountable from frame 0). + const sp = config.spawn ?? { x: 0, y: 0, z: 0 }; + this.bikePos_.x = sp.x; + this.bikePos_.y = sp.y; + this.bikePos_.z = sp.z; + } + + /** Build from prebuilt THREE meshes (world-space; `bakeMesh` reads matrixWorld). + * Each spec's `surface` tags footing/footstep audio. */ + static fromMeshes( + specs: ReadonlyArray<{ mesh: Mesh | null | undefined; surface: string | number }>, + config: EmbodiedConfig = {} + ): EmbodiedController { + const world = new StaticWorld(); + for (const s of specs) if (s.mesh) world.addMesh(s.mesh, s.surface); + world.build(); + return new EmbodiedController(world, config); + } + + /** Feet position (authoritative transform). */ + get position(): Vec3Like { + return this.cc.position; + } + get grounded(): boolean { + return this.cc.grounded; + } + /** Downward impact speed on the frame of landing (m/s) — for camera-feel. */ + get landingSpeed(): number { + return this.cc.landingSpeed; + } + get groundSurfaceName(): string { + return this.cc.groundSurfaceName; + } + /** Current glided eye height above the feet. */ + get eyeHeight(): number { + return this.eye; + } + /** True while on the bike (vehicle locomotion profile). */ + get riding(): boolean { + return this.riding_; + } + /** The parked bike's world position (feet-level). */ + get bikePosition(): Vec3Like { + return this.bikePos_; + } + /** The parked bike's facing (radians). */ + get bikeYaw(): number { + return this.bikeYaw_; + } + /** Facing for the third-person model + chase cam: the bike's steered heading + * while riding, else the camera look yaw. */ + get facingYaw(): number { + return this.riding_ ? this.bikeHeading_ : this.input.yaw; + } + /** On foot AND within mountRadius of the parked bike → B will mount it. */ + get nearBike(): boolean { + if (this.riding_) return false; + const dx = this.cc.position.x - this.bikePos_.x; + const dz = this.cc.position.z - this.bikePos_.z; + return dx * dx + dz * dz <= this.bike.mountRadius * this.bike.mountRadius; + } + + /** Park the (dismounted) bike at a world pose — e.g. seed it at spawn. */ + parkBike(x: number, y: number, z: number, yaw = 0): void { + this.bikePos_.x = x; + this.bikePos_.y = y; + this.bikePos_.z = z; + this.bikeYaw_ = yaw; + this.bikeArmed_ = false; // becomes solid only after you step clear of it + } + get triCount(): number { + return this.world.triCount; + } + + setInput(input: EmbodiedInput): void { + this.input = input; + } + + /** Place the body (clears velocity, depenetrates, probes ground). */ + teleport(x: number, y: number, z: number): void { + this.cc.teleport(x, y, z); + } + + private applyStance(next: Stance): void { + if (next === this.stance) return; + // A raise blocked by a low ceiling (canFit → setHeight false) keeps the + // current stance — you can't stand up under an overhang. + if (!this.cc.setHeight(this.stances[next].height)) return; + this.stance = next; + this.onStanceChange?.(this.stance); + } + + /** Advance the body by `dt` seconds. Returns the distance travelled. */ + step(dt: number): number { + const inp = this.input; + const cc = this.cc; + + // Edge-triggered bike mount/dismount. The bike is a real parked object: + // dismounting parks it where you got off; mounting only works when you're + // standing next to it (no conjuring it from thin air). + if (inp.mount && !this.prevMount) { + if (this.riding_) { + this.riding_ = false; + this.bikePos_.x = cc.position.x; + this.bikePos_.y = cc.position.y; + this.bikePos_.z = cc.position.z; + this.bikeYaw_ = this.bikeHeading_; + this.bikeArmed_ = false; // don't punt yourself off the bike you just left + this.onStanceChange?.(this.stance); + } else if (this.nearBike) { + this.riding_ = true; + this.bikeHeading_ = inp.yaw; // start pointing where you were looking + if (this.stance !== 'stand') this.applyStance('stand'); + this.onStanceChange?.('bike'); + } + // else: too far from the parked bike — nothing happens. + } + this.prevMount = inp.mount; + + // Foot stance toggles (C/X) — ignored while riding. + if (!this.riding_) { + if (inp.crouch && !this.prevCrouch) { + this.applyStance(this.stance === 'crouch' ? 'stand' : 'crouch'); + } + if (inp.prone && !this.prevProne) { + this.applyStance(this.stance === 'prone' ? 'stand' : 'prone'); + } + } + this.prevCrouch = inp.crouch; + this.prevProne = inp.prone; + + const cfg = this.stances[this.stance]; + const sprinting = + !this.riding_ && this.stance === 'stand' && inp.sprint && inp.forward > 0; + + let speed: number; + let eyeTarget: number; + if (this.riding_) { + speed = inp.sprint ? this.bike.sprint : this.bike.speed; + eyeTarget = this.bike.eye; + this.gait = 'roll'; + this.bobScale = 0.25; + this.dustScale = 0.6; + } else { + speed = this.walkSpeed * (sprinting ? this.sprintCfg.speedRatio : cfg.speedRatio); + eyeTarget = cfg.eye; + this.gait = sprinting ? this.sprintCfg.gait : cfg.gait; + this.bobScale = sprinting ? this.sprintCfg.bobScale : cfg.bobScale; + this.dustScale = sprinting ? this.sprintCfg.dustScale : cfg.dustScale; + } + + const sy = Math.sin(inp.yaw); + const cy = Math.cos(inp.yaw); + const fwd = inp.forward; + const str = inp.right; + + // Bike momentum: approach the target velocity (accelerate while pedalling, + // roll on when coasting) instead of snapping — the "vehicle" feel. On foot, + // velocity snaps (crisp FPS control). Steering-only (no throttle) coasts. + const moving = this.riding_ ? fwd !== 0 : fwd !== 0 || str !== 0; + const bikeK = + 1 - + Math.exp(-this.fixedStep / (moving ? this.bike.accelTau : this.bike.brakeTau)); + + // Fixed-step accumulator → framerate-independent feel. Clamp dt so a + // backgrounded tab can't explode the tick count. + this.accum += Math.min(dt, 0.1); + let moved = 0; + while (this.accum >= this.fixedStep) { + this.accum -= this.fixedStep; + cc.velocity.y += this.gravity * this.fixedStep; + if (this.riding_) { + // Bicycle: A/D steers the heading, W/S throttles ALONG it — no strafe, + // reverse allowed — with momentum. NON-HOLONOMIC: the heading only turns + // as the bike ROLLS — the turn rate scales with current ground speed + // (capped at turnRate), so at a standstill A/D does nothing. You can't + // pivot in place; the front wheel needs forward motion to consume the + // steering. Sign: the camera looks (−sin h, −cos h), so an INCREASING + // heading turns screen-LEFT — D (right = +1) must turn RIGHT, so it + // DECREASES the heading, hence the minus. + const rollSpeed = Math.hypot(cc.velocity.x, cc.velocity.z); + const turn = Math.min(this.bike.turnRate, this.bike.turnGain * rollSpeed); + this.bikeHeading_ -= turn * str * this.fixedStep; + const tx = -Math.sin(this.bikeHeading_) * speed * fwd; + const tz = -Math.cos(this.bikeHeading_) * speed * fwd; + cc.velocity.x += (tx - cc.velocity.x) * bikeK; + cc.velocity.z += (tz - cc.velocity.z) * bikeK; + } else { + // On foot: camera-relative omnidirectional WASD, snapped (crisp). + // forward = (−sy, 0, −cy), right = (cy, 0, −sy) + let wx = cy * str - sy * fwd; + let wz = -sy * str - cy * fwd; + const wl = Math.hypot(wx, wz); + if (wl > 1e-6) { + wx = (wx / wl) * speed; + wz = (wz / wl) * speed; + } else { + wx = 0; + wz = 0; + } + cc.velocity.x = wx; + cc.velocity.z = wz; + } + // Jump only on foot, standing. + if (inp.jump && cc.grounded && !this.riding_ && this.stance === 'stand') { + cc.velocity.y = this.jumpSpeed; + } + moved += cc.move( + cc.velocity.x * this.fixedStep, + cc.velocity.y * this.fixedStep, + cc.velocity.z * this.fixedStep + ); + } + this.movedThisFrame = moved; + + // Parked-bike collision: on foot the bike is a solid object you can't walk + // through. It arms (becomes solid) only once you've stepped clear of its + // core, so spawning or dismounting on top of it never shoves you. The push + // goes through cc.move so it slides along walls instead of tunnelling you + // into one. Mounting is unaffected: the blocked core (BIKE_COLLIDE_RADIUS) + // is well inside mountRadius. + if (!this.riding_) { + const dxb = cc.position.x - this.bikePos_.x; + const dzb = cc.position.z - this.bikePos_.z; + const d = Math.hypot(dxb, dzb); + const solidR = BIKE_COLLIDE_RADIUS + this.radius_; + if (!this.bikeArmed_) { + if (d > solidR + 0.2) this.bikeArmed_ = true; // stepped clear → now solid + } else if (d > 1e-4 && d < solidR) { + const corr = solidR - d; + cc.move((dxb / d) * corr, 0, (dzb / d) * corr); + } + } + + // Glide the eye toward the target (smooth stance / mount transitions). + this.eye += (eyeTarget - this.eye) * (1 - Math.exp(-dt / 0.09)); + return moved; + } + + /** Write the camera eye position (feet + glided eye) into `out`; returns it. */ + eyePosition(out: Vec3Like): Vec3Like { + out.x = this.cc.position.x; + out.y = this.cc.position.y + this.eye; + out.z = this.cc.position.z; + return out; + } + + /** Longest unobstructed distance a third-person camera can pull back along the + * unit direction (dx,dy,dz) from the eye (ox,oy,oz) before it would clip world + * geometry. Raycasts the static world and stops `pad` metres short of the + * first hit, so the chase cam tucks in against a wall behind you instead of + * seeing through it. Returns `dist` unobstructed. */ + cameraDistance( + ox: number, + oy: number, + oz: number, + dx: number, + dy: number, + dz: number, + dist: number, + pad = 0.3 + ): number { + const out = this.camHit_; + if ( + this.world.raycast(ox, oy, oz, dx, dy, dz, dist, MASK.CHARACTER, out) && + out.hit + ) { + return Math.max(0.25, out.t - pad); + } + return dist; + } + + /** Kinematic-caller seam (unboarded follow): push a feet position out of solids. + * Leaves `pos.y` for the caller's own ground snap. */ + collide(pos: Vec3Like, _r?: number): void { + this.cc.setPosition(pos.x, pos.y, pos.z); + this.cc.depenetrate(); + pos.x = this.cc.position.x; + pos.z = this.cc.position.z; + } + + dispose(): void { + this.world.dispose(); + } +} diff --git a/src/lib/cod/player/useCameraFeel.test.ts b/src/lib/cod/player/useCameraFeel.test.ts new file mode 100644 index 00000000..1b7e2a57 --- /dev/null +++ b/src/lib/cod/player/useCameraFeel.test.ts @@ -0,0 +1,82 @@ +/** + * useCameraFeel — behavior test. + * + * The springs are pure math (no GPU/DOM), so unlike the audio/fx hooks this runs + * for real in jsdom and asserts actual camera motion: head-bob oscillation while + * walking, no static offset at rest, and a landing dip that recovers. + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useCameraFeel } from './useCameraFeel'; + +const BASE_Y = 1.55; +const DT = 1 / 60; +const WALK = 4.5 * DT; // metres travelled per frame while walking (~4.5 m/s) + +function baseCam() { + return { position: { x: 0, y: BASE_Y, z: 10 } }; +} + +describe('useCameraFeel', () => { + it('head-bob oscillates camera.y above and below the base while walking', () => { + const { result } = renderHook(() => useCameraFeel()); + const { apply } = result.current; + const cc = { grounded: true, landingSpeed: 0 }; + const offsets: number[] = []; + for (let i = 0; i < 120; i++) { + const cam = baseCam(); + apply(cam, cc, WALK, DT, 0); + offsets.push(cam.position.y - BASE_Y); + } + expect(Math.max(...offsets)).toBeGreaterThan(0.01); // bobs up + expect(Math.min(...offsets)).toBeLessThan(-0.01); // and down + }); + + it('settles to ~no offset once the camera stops', () => { + const { result } = renderHook(() => useCameraFeel()); + const { apply } = result.current; + const cc = { grounded: true, landingSpeed: 0 }; + for (let i = 0; i < 60; i++) apply(baseCam(), cc, WALK, DT, 0); // walk + for (let i = 0; i < 180; i++) apply(baseCam(), cc, 0, DT, 0); // then stop + const cam = baseCam(); + apply(cam, cc, 0, DT, 0); + expect(Math.abs(cam.position.y - BASE_Y)).toBeLessThan(0.005); + }); + + it('landing dips the camera below the base, then recovers', () => { + const { result } = renderHook(() => useCameraFeel()); + const { apply } = result.current; + apply(baseCam(), { grounded: true, landingSpeed: 0 }, 0, DT, 0); // grounded baseline + apply(baseCam(), { grounded: false, landingSpeed: 0 }, 0, DT, 0); // airborne + + const landed = { grounded: true, landingSpeed: 6 }; + let minOffset = 0; + for (let i = 0; i < 90; i++) { + const cam = baseCam(); + apply(cam, landed, 0, DT, 0); // stays grounded → kicks once, then settles + minOffset = Math.min(minOffset, cam.position.y - BASE_Y); + } + expect(minOffset).toBeLessThan(-0.02); // dipped down on impact + + const cam = baseCam(); + apply(cam, landed, 0, DT, 0); + expect(Math.abs(cam.position.y - BASE_Y)).toBeLessThan(0.01); // recovered + }); + + it('bobScale multiplies the head-bob amplitude', () => { + const cc = { grounded: true, landingSpeed: 0 }; + const peak = (scale: number): number => { + const { result } = renderHook(() => useCameraFeel()); + const { apply } = result.current; + let max = 0; + for (let i = 0; i < 120; i++) { + const cam = baseCam(); + apply(cam, cc, WALK, DT, 0, scale); + max = Math.max(max, cam.position.y - BASE_Y); + } + return max; + }; + expect(peak(2)).toBeGreaterThan(peak(1) * 1.5); // ~2×, with margin + }); +}); diff --git a/src/lib/cod/player/useCameraFeel.ts b/src/lib/cod/player/useCameraFeel.ts new file mode 100644 index 00000000..ceef42b2 --- /dev/null +++ b/src/lib/cod/player/useCameraFeel.ts @@ -0,0 +1,113 @@ +'use client'; + +import { useCallback, useRef } from 'react'; +// Vendored, framework-agnostic Claude-of-Duty springs (MIT — see +// src/lib/cod/NOTICE.md). Pure damped-oscillator math, no THREE, no DOM. +import { Spring } from '@/lib/cod/springs'; + +// Feel constants (metres / radians). +const BOB_FREQ = 5.5; // bob phase per metre walked (~2 vertical bobs per ~2.2 m stride) +const BOB_AMP = 0.035; // vertical head-bob amplitude +const BOB_LAT = 0.02; // lateral sway amplitude +const LAND_SCALE = 0.018; // landing dip per m/s of impact speed +const LAND_MAX = 0.12; // max landing dip +const AMP_TAU = 0.12; // bob amplitude ease-in/out time constant (s) + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +interface CameraLike { + position: { x: number; y: number; z: number }; +} +interface ControllerLike { + grounded: boolean; + /** Downward impact speed on the frame of landing (m/s). */ + landingSpeed: number; +} + +interface FeelState { + landing: Spring; + bobPhase: number; + bobAmp: number; + prevGrounded: boolean; +} + +export interface CameraFeel { + /** + * Apply head-bob + landing punch to `camera` for this frame. Call AFTER the + * base camera transform is set. `moved` = distance travelled this frame, `dt` = + * frame delta (s), `yaw` = camera yaw (for the lateral-sway direction). + */ + apply: ( + camera: CameraLike, + cc: ControllerLike, + moved: number, + dt: number, + yaw: number, + bobScale?: number + ) => void; +} + +/** + * First-person camera feel harvested from Claude-of-Duty's `Spring`. + * + * - Head-bob: a distance-keyed sinusoid (synced to the footstep cadence), eased + * in/out with movement so a stopped camera has no static offset. + * - Landing punch: on the airborne→grounded frame the camera dips by an amount + * scaled by `cc.landingSpeed`, then a damped `Spring` snaps it back (a slight + * under-damped overshoot reads as weight). + * + * Pure math on the camera transform — no GPU, no DOM — so it is SSR-safe and + * runs fully in jsdom (the unit test drives real behavior). Driven imperatively + * from the movement loop, like `useFootsteps`/`useFootstepDust`. + */ +export function useCameraFeel(): CameraFeel { + const s = useRef({ + landing: new Spring(13, 0.5, 0), + bobPhase: 0, + bobAmp: 0, + prevGrounded: true, + }).current; + + const apply = useCallback( + ( + camera: CameraLike, + cc: ControllerLike, + moved: number, + dt: number, + yaw: number, + bobScale = 1 + ) => { + if (!camera || !cc) return; + + // Landing punch: instant dip on the airborne→grounded frame, spring back. + if (cc.grounded && !s.prevGrounded) { + s.landing.set(-clamp(cc.landingSpeed * LAND_SCALE, 0, LAND_MAX)); + } + s.prevGrounded = cc.grounded; + s.landing.step(dt); // integrates toward target 0 + + // Head-bob amplitude eases in/out with movement (no static offset at rest). + const wantAmp = cc.grounded && moved > 1e-4 ? 1 : 0; + const k = dt > 0 ? 1 - Math.exp(-dt / AMP_TAU) : 1; + s.bobAmp += (wantAmp - s.bobAmp) * k; + s.bobPhase += moved * BOB_FREQ; + + const amp = s.bobAmp * bobScale; + const bobY = Math.sin(s.bobPhase) * BOB_AMP * amp; + const bobX = Math.cos(s.bobPhase * 0.5) * BOB_LAT * amp; + + // Lateral sway along the camera's right vector (from yaw). + const rx = Math.cos(yaw); + const rz = -Math.sin(yaw); + + camera.position.y += s.landing.value + bobY; + camera.position.x += rx * bobX; + camera.position.z += rz * bobX; + }, + [s] + ); + + return { apply }; +} diff --git a/src/lib/cod/sky/NOTICE.md b/src/lib/cod/sky/NOTICE.md new file mode 100644 index 00000000..42de759a --- /dev/null +++ b/src/lib/cod/sky/NOTICE.md @@ -0,0 +1,11 @@ +# Vendored from Claude-of-Duty (MIT) — procedural sky + +Source: https://github.com/mshumer/Claude-of-Duty (Matt Shumer), MIT (see ../LICENSE). + +Procedural atmospheric sky + IBL env map (`three`-only, GLSL3, zero art assets, +no HDRI). Vendored the 8 framework-agnostic files for a STATIC sky + env bake: +fullscreen, atmosphere, noise, stars, clouds, luts, celestial, dome. `index.js` +(the OVERWATCH shell) is NOT vendored — its ctx wiring is replaced by `driver.js` +(buildSharedUniforms + updateCelestial ports). `volumetrics.js` is dropped +(light shafts depend on CoD's post chain). Env map = equirect blit → PMREM → +scene.environment; the dome is a renderOrder -10000 background mesh. diff --git a/src/lib/cod/sky/atmosphere.js b/src/lib/cod/sky/atmosphere.js new file mode 100644 index 00000000..88256be6 --- /dev/null +++ b/src/lib/cod/sky/atmosphere.js @@ -0,0 +1,334 @@ +/** + * Physical atmosphere model. + * + * This is Bruneton's scattering integral evaluated the way Hillaire 2020 + * ("A Scalable and Production Ready Sky and Atmosphere Rendering Technique") + * does it: three small LUTs instead of a per-pixel double raymarch. + * + * transmittance 256 x 64 T(altitude, cos zenith) baked once + * multiscatter 32 x 32 psi_ms(altitude, cos zenith) baked once + * sky-view 384 x 192 L(azimuth, altitude) rebaked when the sun moves + * + * Media, in Hillaire's units (lengths in megametres, coefficients in Mm^-1): + * Rayleigh exponential, scale height 8 km, sigma_s = (5.802, 13.558, 33.1) + * Mie exponential, scale height 1.2 km, sigma_s = 3.996, sigma_a = 4.40 + * Ozone tent centred at 25 km, sigma_a = (0.650, 1.881, 0.085) + * + * The ozone layer is not a nicety: it is what removes the green from the deep + * zenith blue and what turns the twilight band violet instead of brown. + * + * --------------------------------------------------------------------------- + * PHOTOMETRIC SCALE — read this before changing a number anywhere in src/sky/ + * --------------------------------------------------------------------------- + * The renderer's fallback sun is intensity 4.3 and its fallback sky env peaks + * around 0.34, so the engine's working unit is roughly "25 klx". We adopt that + * exactly, because every other subsystem has been tuned against it: + * + * 1 light intensity unit = SCENE_LUX (25000) lux + * 1 framebuffer radiance unit = SCENE_LUX cd/m^2 + * + * Derive the second from the first and do not guess at it, because a factor of + * pi here is 1.65 stops of sky. three's Lambert BRDF carries the 1/pi: a white + * surface facing a light of intensity I writes b = I/pi into the buffer, while + * its physical radiance is L = (I * SCENE_LUX) / pi cd/m^2. Therefore + * L = b * SCENE_LUX, and a scattering integral that already evaluates a + * *radiance* — which sigma_s * P(theta) * E is, once E is expressed in scene + * light units — is written to the buffer as-is. It must NOT be multiplied by pi + * on the way out. + * + * That multiplication used to be here, and it is exactly why every daylight + * shot read as milk: the sky came out 1.65 stops hotter than the surfaces it was + * lighting, so a sunlit stucco wall was *darker* than the sky behind it, the + * cumulus deck was darker than the gap between the clouds, and the AgX shoulder + * dumped what was left of the hue. The cloud decks and the ground bounce divide + * their irradiance by pi to reach the same convention. + * + * Consequences, all of which fall out of the model rather than being dialled in: + * extraterrestrial solar illuminance 128 klx -> 5.12 units + * noon sun after atmospheric extinction -> ~3.9 units (matches 4.3) + * clear zenith sky ~1500 cd/m^2 -> ~0.06 radiance units + * sunlit stucco (albedo 0.4, 45 deg) -> ~0.32 radiance units + * whole-sky diffuse illuminance -> ~15% of the sun + */ + +export const SCENE_LUX = 25000; + +/** Extraterrestrial solar illuminance, in scene light units. */ +export const SUN_ILLUMINANCE_TOP = 128000 / SCENE_LUX; // 5.12 + +/** + * Moonlight, in scene light units. A real full moon is 0.27 lux — 1e-5 units — + * which is four stops below anything a display can show alongside a muzzle + * flash. Every shipped game renders "day for night" instead; this is that + * decision, made once, in one place, rather than smeared across the shaders. + * 0.30, not 0.115. The street this lights has twenty-two sodium lamps in it at + * intensity 14, and at 0.115 the moon delivered 0.037 — so every surface in the + * night frame took its colour from the practicals and the frame came out warm + * from edge to edge with a deep blue sky over it and no cool content anywhere + * in the world below. Night reads as night when the AMBIENT is cool and the + * lamps are warm POOLS inside it; that is a ratio, and this is the side of the + * ratio the sky owns. + * Ratios *within* the night (moon disc : moonlit sky : moonlit ground) stay + * physical, so the frame still behaves like a photograph of a moonlit street. + */ +export const MOON_ILLUMINANCE_NIGHT = 0.30; + +export const ATMO = { + groundRadiusMM: 6.36, + atmosphereRadiusMM: 6.46, + /** Viewer altitude. 200 m puts us above the thickest aerosol, like a city. */ + viewAltitudeMM: 0.0002, + rayleigh: [5.802, 13.558, 33.1], + rayleighScaleHeightKM: 8.0, + mieScattering: 3.996, + mieAbsorption: 4.4, + mieScaleHeightKM: 1.2, + ozone: [0.65, 1.881, 0.085], + ozoneCentreKM: 25.0, + ozoneWidthKM: 15.0, + groundAlbedo: 0.24, +}; + +// --------------------------------------------------------------------------- +// GLSL +// --------------------------------------------------------------------------- + +/** + * Format a JS number as a GLSL float *literal*. + * + * `${8.0}` stringifies to "8", which GLSL ES 3.00 types as an int — and + * `float / int` is a compile error, not an implicit conversion. Every number + * interpolated into a shader in this subsystem goes through here. + */ +export const f = (n) => (Number.isInteger(n) ? Number(n).toFixed(1) : String(Number(n))); + +/** Constants, media sampling, sphere intersection, phase functions. */ +export const ATMOSPHERE_GLSL = /* glsl */ ` +#ifndef SKY_ATMOSPHERE +#define SKY_ATMOSPHERE + +const float SK_PI = 3.141592653589793; +const float SK_GROUND_R = ${f(ATMO.groundRadiusMM)}; +const float SK_TOP_R = ${f(ATMO.atmosphereRadiusMM)}; +const vec3 SK_RAYLEIGH = vec3( ${f(ATMO.rayleigh[0])}, ${f(ATMO.rayleigh[1])}, ${f(ATMO.rayleigh[2])} ); +const float SK_MIE_S = ${f(ATMO.mieScattering)}; +const float SK_MIE_A = ${f(ATMO.mieAbsorption)}; +const vec3 SK_OZONE = vec3( ${f(ATMO.ozone[0])}, ${f(ATMO.ozone[1])}, ${f(ATMO.ozone[2])} ); +const float SK_GROUND_ALBEDO = ${f(ATMO.groundAlbedo)}; +const float SK_ISO_PHASE = 0.07957747154594767; // 1/(4pi) + +/** Aerosol multiplier — clear day 1.0, hazy 3+. Baked into every LUT. */ +uniform float uMieScale; +/** vec3( 0, groundRadius + viewAltitude, 0 ) */ +uniform vec3 uViewPos; + +float skSafeAcos( float x ) { return acos( clamp( x, -1.0, 1.0 ) ); } + +/** Nearest positive hit of a ray against a sphere centred on the origin. */ +float skRaySphere( vec3 ro, vec3 rd, float rad ) { + float b = dot( ro, rd ); + float c = dot( ro, ro ) - rad * rad; + if ( c > 0.0 && b > 0.0 ) return -1.0; + float d = b * b - c; + if ( d < 0.0 ) return -1.0; + if ( d > b * b ) return ( -b + sqrt( d ) ); + return -b - sqrt( d ); +} + +void skMedium( vec3 pos, out vec3 rayleighS, out float mieS, out vec3 extinction ) { + float altKM = ( length( pos ) - SK_GROUND_R ) * 1000.0; + float rDen = exp( -altKM / ${f(ATMO.rayleighScaleHeightKM)} ); + float mDen = exp( -altKM / ${f(ATMO.mieScaleHeightKM)} ); + rayleighS = SK_RAYLEIGH * rDen; + mieS = SK_MIE_S * uMieScale * mDen; + float mieA = SK_MIE_A * uMieScale * mDen; + vec3 ozone = SK_OZONE * max( 0.0, + 1.0 - abs( altKM - ${f(ATMO.ozoneCentreKM)} ) / ${f(ATMO.ozoneWidthKM)} ); + extinction = rayleighS + vec3( mieS + mieA ) + ozone; +} + +/** Cornette-Shanks, the well-behaved cousin of Henyey-Greenstein. */ +float skMiePhase( float cosTheta ) { + const float g = 0.8; + const float k = 3.0 / ( 8.0 * SK_PI ) * ( 1.0 - g * g ) / ( 2.0 + g * g ); + return k * ( 1.0 + cosTheta * cosTheta ) / pow( 1.0 + g * g - 2.0 * g * cosTheta, 1.5 ); +} + +float skRayleighPhase( float cosTheta ) { + return 3.0 / ( 16.0 * SK_PI ) * ( 1.0 + cosTheta * cosTheta ); +} + +/** Henyey-Greenstein — used by the ground fog, exposed here so both agree. */ +float skHG( float cosTheta, float g ) { + float g2 = g * g; + float d = max( 1e-4, 1.0 + g2 - 2.0 * g * cosTheta ); + return ( 1.0 - g2 ) / ( 4.0 * SK_PI * d * sqrt( d ) ); +} + +#endif +`; + +/** Transmittance LUT lookup. Shared parameterisation with the bake. */ +export const TRANSMITTANCE_LOOKUP_GLSL = /* glsl */ ` +#ifndef SKY_TLUT +#define SKY_TLUT +uniform sampler2D uTransmittanceLut; + +vec2 skLutUv( vec3 pos, vec3 dir ) { + float h = length( pos ); + float mu = dot( dir, pos / h ); + return vec2( + clamp( 0.5 + 0.5 * mu, 0.0, 1.0 ), + clamp( ( h - SK_GROUND_R ) / ( SK_TOP_R - SK_GROUND_R ), 0.0, 1.0 ) ); +} + +/** Transmittance from pos along dir out to the top of the atmosphere. */ +vec3 skTransmittance( vec3 pos, vec3 dir ) { + return texture( uTransmittanceLut, skLutUv( pos, dir ) ).rgb; +} +#endif +`; + +export const MULTISCATTER_LOOKUP_GLSL = /* glsl */ ` +#ifndef SKY_MSLUT +#define SKY_MSLUT +uniform sampler2D uMultiScatterLut; +vec3 skMultiScatter( vec3 pos, vec3 lightDir ) { + return texture( uMultiScatterLut, skLutUv( pos, lightDir ) ).rgb; +} +#endif +`; + +/** + * Single + multiple scattering along a view ray, for two light sources at once + * (sun and moon). Sharing the loop means the moon costs two LUT taps per step + * rather than a second raymarch, which is what makes a physically lit night sky + * affordable at all. + * + * Returns radiance in scene units, already multiplied by the light irradiances. + * The direct solar/lunar disc is deliberately excluded — the dome adds it with + * limb darkening at full screen resolution instead of at LUT resolution. + */ +export const SCATTER_GLSL = /* glsl */ ` +#ifndef SKY_SCATTER +#define SKY_SCATTER +vec3 skRaymarchSky( + vec3 pos, vec3 rayDir, + vec3 sunDir, vec3 sunIrr, + vec3 moonDir, vec3 moonIrr, + float steps ) { + + float topT = skRaySphere( pos, rayDir, SK_TOP_R ); + float groundT = skRaySphere( pos, rayDir, SK_GROUND_R ); + float tMax = groundT < 0.0 ? topT : groundT; + if ( tMax <= 0.0 ) return vec3( 0.0 ); + + float cS = dot( rayDir, sunDir ); + float cM = dot( rayDir, moonDir ); + float mieS = skMiePhase( cS ), rayS = skRayleighPhase( cS ); + float mieM = skMiePhase( cM ), rayM = skRayleighPhase( cM ); + + vec3 lum = vec3( 0.0 ); + vec3 trans = vec3( 1.0 ); + float t = 0.0; + + for ( float i = 0.0; i < steps; i += 1.0 ) { + // 0.3 rather than 0.5 biases samples toward the dense lower atmosphere, + // which is where all the interesting colour is. + float nt = ( ( i + 0.3 ) / steps ) * tMax; + float dt = nt - t; + t = nt; + vec3 p = pos + t * rayDir; + + vec3 rs; float ms; vec3 ext; + skMedium( p, rs, ms, ext ); + vec3 sampleT = exp( -dt * ext ); + + vec3 tSun = skTransmittance( p, sunDir ); + vec3 psiSun = skMultiScatter( p, sunDir ); + vec3 inScatter = ( rs * ( rayS * tSun + psiSun ) + ms * ( mieS * tSun + psiSun ) ) * sunIrr; + + vec3 tMoon = skTransmittance( p, moonDir ); + vec3 psiMoon = skMultiScatter( p, moonDir ); + inScatter += ( rs * ( rayM * tMoon + psiMoon ) + ms * ( mieM * tMoon + psiMoon ) ) * moonIrr; + + // Analytic integration of the segment (Hillaire eq. 8): exact for constant + // media over dt, and unlike a midpoint sum it never overshoots when the + // optical depth of a step is large. + lum += trans * ( inScatter - inScatter * sampleT ) / max( ext, vec3( 1e-8 ) ); + trans *= sampleT; + } + // No pi here. The integral above is sigma_s * P(theta) * E with E in scene + // light units, which *is* a radiance in the buffer's own convention (see the + // photometric note at the top of this file). Multiplying by pi puts the sky + // 1.65 stops above the surfaces it lights, which is what made every exterior + // read as white milk with clouds darker than the sky behind them. + return lum; +} +#endif +`; + +// --------------------------------------------------------------------------- +// CPU side — the same model, for the sun/moon DirectionalLight colours +// --------------------------------------------------------------------------- + +function mediumJs(altKM, mieScale, out) { + const rDen = Math.exp(-altKM / ATMO.rayleighScaleHeightKM); + const mDen = Math.exp(-altKM / ATMO.mieScaleHeightKM); + const mie = (ATMO.mieScattering + ATMO.mieAbsorption) * mieScale * mDen; + const oz = Math.max(0, 1 - Math.abs(altKM - ATMO.ozoneCentreKM) / ATMO.ozoneWidthKM); + out[0] = ATMO.rayleigh[0] * rDen + mie + ATMO.ozone[0] * oz; + out[1] = ATMO.rayleigh[1] * rDen + mie + ATMO.ozone[1] * oz; + out[2] = ATMO.rayleigh[2] * rDen + mie + ATMO.ozone[2] * oz; + return out; +} + +const _ext = [0, 0, 0]; + +/** + * Per-channel transmittance from the viewer to space along a direction whose + * cosine with the local zenith is `mu`. Same integral as the GPU LUT bake, so + * the sun's DirectionalLight colour and the sky it hangs in cannot disagree. + * Runs ~48 steps; called only when the sun actually moves. + */ +export function transmittanceToSpace(mu, mieScale = 1, out = [0, 0, 0]) { + const R = ATMO.groundRadiusMM + ATMO.viewAltitudeMM; + const top = ATMO.atmosphereRadiusMM; + // Ray from (0,R,0) with vertical component mu. Path length to the top shell. + const disc = R * R * mu * mu - R * R + top * top; + if (disc <= 0) { + out[0] = out[1] = out[2] = 0; + return out; + } + const tTop = -R * mu + Math.sqrt(disc); + // Below the horizon the ground blocks us entirely. + const gDisc = R * R * mu * mu - R * R + ATMO.groundRadiusMM * ATMO.groundRadiusMM; + if (mu < 0 && gDisc > 0) { + out[0] = out[1] = out[2] = 0; + return out; + } + const N = 48; + const dt = tTop / N; + let od0 = 0; + let od1 = 0; + let od2 = 0; + for (let i = 0; i < N; i++) { + const t = (i + 0.5) * dt; + // |(0,R,0) + t*dir| with dir.y = mu + const h = Math.sqrt(R * R + t * t + 2 * R * t * mu); + const altKM = (h - ATMO.groundRadiusMM) * 1000; + mediumJs(Math.max(0, altKM), mieScale, _ext); + od0 += _ext[0] * dt; + od1 += _ext[1] * dt; + od2 += _ext[2] * dt; + } + out[0] = Math.exp(-od0); + out[1] = Math.exp(-od1); + out[2] = Math.exp(-od2); + return out; +} + +/** Rec.709 luminance — used to split transmittance into colour + intensity. */ +export function luminance(rgb) { + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; +} diff --git a/src/lib/cod/sky/celestial.js b/src/lib/cod/sky/celestial.js new file mode 100644 index 00000000..ea512c88 --- /dev/null +++ b/src/lib/cod/sky/celestial.js @@ -0,0 +1,135 @@ +import * as THREE from 'three'; + +/** + * Where the sun and moon actually are. + * + * Standard spherical astronomy: declination from the day of year, hour angle + * from local solar time, then the altitude/azimuth transform for the site + * latitude. Nothing is hand-placed, so the shots that matter come out of one + * consistent sky rather than three separate art passes. + * + * Site and date are chosen so the graded times land where the shot list says + * they should (lat 45N, summer solstice, sunset at 19.71): + * + * 16.50 sun +32.0 deg, azimuth 272 (due west) — hard afternoon key + * 19.20 sun +4.6 deg, azimuth 299 (WNW) — golden hour, disc in frame + * 01.50 sun -18.6 deg — full night + * moon +21.7 deg, azimuth 288 (W) — half-lit, in frame + * + * Azimuth convention: 0 = north = -Z, 90 = east = +X. `northAngle` rotates the + * whole celestial sphere for art direction without touching the astronomy. + */ + +export const SITE = { + latitudeDeg: 45.0, + dayOfYear: 172, // summer solstice + /** Rotates north in world space. 0 keeps north at -Z. */ + northAngleDeg: 0, + /** + * Moon hour angle offset from the sun, degrees, and lunar declination. + * + * 244 / +28 (the moon's real declination limit) puts the moon at altitude 22 / + * azimuth 288 at 01:30, which is INSIDE the night shot's frustum — the old + * 216.8 / +12 put it at azimuth 250, twenty degrees off the left edge, so the + * one frame that exists to show a moonlit street had no moon in it. At this + * declination it is also 58% illuminated, so the terminator reads and the disc + * is a sphere rather than a flat white dot. + */ + moonHourOffsetDeg: 244.0, + moonDeclinationDeg: 28.0, +}; + +const DEG = Math.PI / 180; + +/** Solar declination, Cooper's approximation. */ +export function solarDeclination(dayOfYear) { + return 23.44 * DEG * Math.sin(((2 * Math.PI) / 365) * (284 + dayOfYear)); +} + +/** + * Altitude/azimuth for a body at a given hour angle and declination. + * `hourAngle` in radians, 0 at local meridian, positive in the afternoon. + */ +export function altAz(hourAngle, declination, latitudeDeg, out = { alt: 0, az: 0 }) { + const lat = latitudeDeg * DEG; + const sinLat = Math.sin(lat); + const cosLat = Math.cos(lat); + const sinD = Math.sin(declination); + const cosD = Math.cos(declination); + const sinAlt = sinLat * sinD + cosLat * cosD * Math.cos(hourAngle); + const alt = Math.asin(THREE.MathUtils.clamp(sinAlt, -1, 1)); + const cosAlt = Math.cos(alt); + let cosAz = 0; + if (cosAlt > 1e-6 && cosLat > 1e-6) { + cosAz = (sinD - sinAlt * sinLat) / (cosAlt * cosLat); + } + let az = Math.acos(THREE.MathUtils.clamp(cosAz, -1, 1)); + // Hour angle positive = past the meridian = western half of the sky. + if (Math.sin(hourAngle) > 0) az = 2 * Math.PI - az; + out.alt = alt; + out.az = az; + return out; +} + +/** World-space unit vector from altitude/azimuth. Points *toward* the body. */ +export function dirFromAltAz(alt, az, northAngleRad, out) { + const a = az + northAngleRad; + const ca = Math.cos(alt); + return out.set(ca * Math.sin(a), Math.sin(alt), -ca * Math.cos(a)).normalize(); +} + +/** + * Full celestial state for an hour of the day. + * `sun`/`moon` are unit world directions pointing at the body. + */ +export class Celestial { + constructor(site = SITE) { + this.site = { ...site }; + this.sun = new THREE.Vector3(0, 1, 0); + this.moon = new THREE.Vector3(0, -1, 0); + this.sunAlt = 0; + this.sunAz = 0; + this.moonAlt = 0; + this.moonAz = 0; + /** Illuminated fraction of the lunar disc, 0..1. */ + this.moonPhase = 1; + /** Angular separation sun-moon; drives the terminator on the disc. */ + this.moonElongation = Math.PI; + this._aa = { alt: 0, az: 0 }; + this._m = new THREE.Matrix4(); + this._tilt = new THREE.Matrix4(); + } + + setHour(hour) { + const s = this.site; + const north = s.northAngleDeg * DEG; + const decl = solarDeclination(s.dayOfYear); + const H = (hour - 12) * 15 * DEG; + + altAz(H, decl, s.latitudeDeg, this._aa); + this.sunAlt = this._aa.alt; + this.sunAz = this._aa.az; + dirFromAltAz(this.sunAlt, this.sunAz, north, this.sun); + + const Hm = H + s.moonHourOffsetDeg * DEG; + altAz(Hm, s.moonDeclinationDeg * DEG, s.latitudeDeg, this._aa); + this.moonAlt = this._aa.alt; + this.moonAz = this._aa.az; + dirFromAltAz(this.moonAlt, this.moonAz, north, this.moon); + + this.moonElongation = Math.acos(THREE.MathUtils.clamp(this.sun.dot(this.moon), -1, 1)); + this.moonPhase = 0.5 * (1 - Math.cos(this.moonElongation)); + + // Equatorial -> world rotation for the starfield: the sky turns 15 deg/hour + // about the polar axis, which is tilted from vertical by (90 - latitude). + const polarTilt = (90 - s.latitudeDeg) * DEG; + this._m.makeRotationY(-H + north); + this._m.premultiply(this._tilt.makeRotationX(polarTilt)); + return this; + } + + /** THREE.Matrix3 usable as a `mat3` uniform, world dir -> fixed sky. */ + celestialMatrix(out) { + return out.setFromMatrix4(this._m); + } +} diff --git a/src/lib/cod/sky/clouds.js b/src/lib/cod/sky/clouds.js new file mode 100644 index 00000000..6418d65b --- /dev/null +++ b/src/lib/cod/sky/clouds.js @@ -0,0 +1,374 @@ +/** + * Two procedural cloud decks on the sky shell. + * + * cumulus 1.5 km coverage-eroded fbm with a fake vertical extent produced + * by parallax-shifting the sample along the view ray, so the + * deck has billows and a silhouette instead of reading as a + * printed pattern. Self-shadowed with three taps toward the + * sun, powder-darkened bases, silver rims from a forward + * Henyey-Greenstein lobe. + * cirrus 7.8 km two decorrelated families of ridged fbm, each stretched + * 3.5:1 (not 9:1) about its own bearing, each bearing 75 + * degrees from the other and wandering +-0.55 rad under a + * field that turns every four to six kilometres, each cut + * into 1.5 km fallstreaks by an along-fibre amplitude + * modulation, each gated by its own kilometre-scale patch + * mask so the layer arrives in fronts with clean blue between + * them. Optically thin, almost all forward scatter — the + * layer that turns a sunset pink. Read skCirrusBand below + * before changing any of those numbers: every one of them is + * load bearing against the starburst. + * + * Both are intersected against the planet shell rather than a flat plane, and + * both fade out with the *distance* to that intersection. That fade is not + * decoration. A deck seen at a grazing angle is fifty kilometres away, and if + * you do not bleed it into the aerial haze it collapses into a hard grey wall + * pasted along the horizon, or — for cirrus, whose streaks are parallel in + * world space — into a starburst converging on a vanishing point. Both are + * immediate tells. + * + * The low-frequency coverage field skCloudMacro is four analytic waves rather + * than noise, for one specific reason: it has to be evaluated identically on + * the CPU (see cloudMacro below) so the sun's cloud-occlusion factor — the + * slow dimming as a cloud crosses the sun — matches the cloud the shader is + * actually drawing. Correlated, not faked. + * + * Radiance convention: sunLow/sunHigh arrive as *irradiance* in scene light + * units, so every direct term is divided by pi to become framebuffer radiance. + * See the long note at the end of skRaymarchSky in atmosphere.js. + */ +export const CLOUDS_GLSL = /* glsl */ ` +#ifndef SKY_CLOUDS +#define SKY_CLOUDS + +// x coverage, y density, z detail gain, w time (seconds) +uniform vec4 uCloudParams; +// x cirrus coverage, y cirrus opacity, z wind x (km/s), w wind z (km/s) +uniform vec4 uCloudParams2; + +const float SK_CUMULUS_KM = 1.5; +const float SK_CIRRUS_KM = 7.8; + +/** Weather-scale coverage, in kilometres. Mirrored exactly on the CPU. */ +float skCloudMacro( vec2 p ) { + float a = sin( p.x * 0.412 + 0.7 ) * cos( p.y * 0.331 - 0.4 ); + float b = sin( p.x * 0.173 - p.y * 0.209 + 1.9 ); + float c = cos( p.x * 0.0871 + p.y * 0.1123 - 0.6 ); + return clamp( 0.5 + 0.5 * ( 0.42 * a + 0.36 * b + 0.30 * c ), 0.0, 1.0 ); +} + +/** + * Ridged noise with a *parabolic* crest instead of an absolute-value one. + * + * skRidge2 in noise.js builds its ridge as 1 - |2v-1|, which has a crease at the + * crest: the derivative flips sign discontinuously, so any threshold applied to + * it produces a hairline. On the cumulus silhouette that crease is what makes the + * cauliflower edge, and it is right there. On an anisotropic field stretched + * across the sky it is a pen stroke, and a sky full of pen strokes was the second + * half of the cirrus problem — the first was where they pointed. + * + * 1 - (2v-1)^2 has the same crest lines and the same statistics but is C1 across + * them, so a fibre has a soft shoulder and a body several pixels wide. Two + * octaves only: the third would land near the pixel footprint again. + */ +float skSmoothRidge2( vec2 p, int oct ) { + float a = 0.62, s = 0.0, n = 0.0; + for ( int i = 0; i < oct; i ++ ) { + float v = skVal2( p ) * 2.0 - 1.0; + s += a * ( 1.0 - v * v ); + n += a; + p = SK_ROT * p * 2.17 + 3.71; + a *= 0.45; + } + return s / max( n, 1e-4 ); +} + +/** + * One family of cirrus, p in kilometres on the deck. + * + * WHY THIS IS SHAPED THE WAY IT IS — the starburst, and its two successors. + * + * The deck is sampled where the view ray meets a shell 7.8 km up, so the map from + * screen space to p is a projection whose derivative grows without bound as the + * ray flattens toward that shell. Three separate artefacts came out of that, and + * each one had to be answered by a different part of this function: + * + * 1 STARBURST. A field with a locally constant direction is a family of + * parallel lines, and parallel lines on a plane converge on a vanishing + * point. With one rotation field at one turn per 80 km the direction was + * effectively constant across a 90-degree frame, so every fibre pointed at + * the same spot just above the top of the hero framing. + * 2 FINGERPRINT. Rotating the anisotropy frame by a full +-1.45 rad instead + * removes the vanishing point and replaces it with something worse: the + * direction field winds all the way round its own critical points, so the + * fibres close into concentric whorls and the sky reads as wood grain. + * 3 BRUSH STROKES. Even a bounded wander leaves the *silhouette* of the layer + * defined by a level set of a ridged field, and a level set is a continuous + * curve that runs through as many cells as it likes. That is why raising the + * noise frequency only ever made the strokes thinner, never shorter. + * + * The answer to all three is to stop letting the anisotropic field decide *where + * there is cloud*. What survives here is: + * + * silhouette an isotropic warped fbm, thresholded — the same construction as + * the cumulus deck, so it reads as cloud and cannot smear, streak + * or whorl no matter how the projection stretches it; + * fibre an anisotropic smooth-ridge field that only *modulates* that + * silhouette between 0.35 and 1.2 of its density. Cirrus texture + * is a brightness variation inside the patch, which is what it is + * in a photograph too; + * bearing per family, +-0.55 rad of wander, and the two families in + * skClouds sit 75 degrees apart so no single direction owns the + * frame; + * fronts a patch mask at ~8 km, so the layer arrives in bands with clean + * blue between them rather than as an even glaze. + */ +float skCirrusBand( vec2 p, float cov, float seed, float base, + float rotKmInv, float lenKM, float aniso, int oct ) { + // ---- silhouette: isotropic, so it can never streak -------------------- + vec2 w = vec2( skVal2( p * 0.30 + seed ), skVal2( p * 0.30 + seed + 11.7 ) ) - 0.5; + float n = skFbm2( p * 0.78 + w * 1.3, oct + 1 ); + float d = smoothstep( 1.0 - cov * 1.65, 1.0 - cov * 0.60, n ); + if ( d <= 0.001 ) return 0.0; + + // ---- fronts ------------------------------------------------------------ + d *= smoothstep( 0.36, 0.66, skVal2( p * 0.12 + seed * 0.5 ) ); + if ( d <= 0.001 ) return 0.0; + + // ---- fibre texture inside the patch ------------------------------------ + float ang = base + ( skVal2( p * rotKmInv + seed ) - 0.5 ) * 1.1; + float ca = cos( ang ), sa = sin( ang ); + vec2 pr = vec2( p.x * ca - p.y * sa, p.x * sa + p.y * ca ); + float fa = 1.0 / max( 0.4, lenKM ); + vec2 q = vec2( pr.x * fa, pr.y * fa * aniso ); + float f = skSmoothRidge2( q + vec2( seed ), oct ); + // Never zeroes the patch and never doubles it: the fibres are a texture on the + // cloud, not the cloud. The mean is close to 1 so coverage stays where the + // threshold above put it. + return d * ( 0.35 + 1.05 * f ); +} + +/** Cumulus optical thickness at a point on the deck, p in kilometres. */ +float skCumulusDensity( vec2 p, int oct ) { + float macro = skCloudMacro( p * 0.22 ); + float cov = clamp( uCloudParams.x * ( 0.34 + 1.30 * macro ), 0.0, 1.0 ); + + // Domain warp before the shape fbm. Straight fbm gives evenly sized blobs; + // warping it stretches some and pinches others, which is what makes a cloud + // field read as weather rather than as noise. + vec2 w = vec2( skVal2( p * 0.42 ), skVal2( p * 0.42 + 19.7 ) ) - 0.5; + float n = skFbm2( p * 1.25 + w * 1.6, oct ); + + // Erode from below: coverage sets the threshold, the remainder is thickness. + float d = smoothstep( 1.0 - cov, 1.0 - cov * 0.34 + 0.05, n ); + + // Cauliflower the edges with a higher-frequency ridge, so the silhouette is + // not just a smooth level set of the base noise. + if ( d > 0.0 && d < 0.94 && oct > 3 ) { + float e = skRidge2( p * 5.3 + w * 2.0, 3 ); + d = clamp( d - ( 1.0 - d ) * ( 0.50 - 0.50 * e ), 0.0, 1.0 ); + } + return d; +} + +/** + * Fraction of sunlight reaching a point on the cumulus deck. Marched along the + * sun's horizontal projection; the low-sun path through the slab is longer, + * which is why sunset clouds go dark grey underneath and blaze at the top. + */ +float skCumulusLight( vec2 p, vec3 lightDir, int oct ) { + vec2 step2 = normalize( lightDir.xz + vec2( 1e-4 ) ) * ( 0.20 / max( 0.12, abs( lightDir.y ) ) ); + float tau = 0.0; + tau += skCumulusDensity( p + step2 * 1.0, oct ) * 1.0; + tau += skCumulusDensity( p + step2 * 2.4, oct ) * 0.7; + tau += skCumulusDensity( p + step2 * 4.6, oct ) * 0.4; + return exp( -tau * uCloudParams.y * 2.1 ); +} + +/** + * Composite both decks for a view ray. + * Returns rgb = radiance, a = coverage (0 lets the sky through untouched). + * + * sunLow/sunHigh are the solar irradiance already extinguished down to each + * deck's own altitude, so the two layers are lit by genuinely different spectra. + */ +vec4 skClouds( vec3 rayDir, + vec3 sunDir, vec3 sunLow, vec3 sunHigh, + vec3 moonDir, vec3 moonLow, vec3 moonHigh, + vec3 ambient, int quality ) { + + if ( rayDir.y < -0.008 ) return vec4( 0.0 ); + + int octD = quality > 0 ? 6 : 3; + int octL = quality > 0 ? 4 : 2; + // Cirrus gets two octaves where the cumulus gets six, and that is not a + // performance decision. This deck is twenty kilometres away, where one screen + // pixel covers thirty metres of it; an octave finer than that is pure aliasing, + // and aliasing on an anisotropic field is precisely what a hairline smear is. + int octC = 2; + float t = uCloudParams.w; + vec2 wind = vec2( uCloudParams2.z, uCloudParams2.w ) * t; + + float cosSun = dot( rayDir, sunDir ); + float cosMoon = dot( rayDir, moonDir ); + + // ---- cirrus, 7.8 km ---------------------------------------------------- + float tc = skRaySphere( uViewPos, rayDir, SK_GROUND_R + SK_CIRRUS_KM * 0.001 ); + vec4 cirrus = vec4( 0.0 ); + if ( tc > 0.0 ) { + float distKM = tc * 1000.0; + + // Distance fade, and it is doing antialiasing as much as atmospherics. + // Below ~15 degrees of elevation this shell is 30 km away or more, and the + // derivative d(distance)/d(elevation) there is over 400 m per screen pixel — + // several times the width of a fibre. Nothing sampled per-pixel can survive + // that: the field aliases into hairline radial striations that all point at + // the same place on screen, which is one half of what read as a starburst + // (the other half was the field's own constant direction). Ending the layer + // at 90 km rather than 260 km removes the entire undersampled band, and a + // real cirrus deck does fade into the horizon haze at exactly that range. + float fade = 1.0 - smoothstep( 22.0, 90.0, distKM ); + + // Above ~35 degrees of elevation the same derivative blows up the other way: + // a kilometre on the deck covers a large and rapidly changing solid angle, so + // whatever the field does it smears radially through the zenith. Keep the + // layer to a third of its opacity up there — high cirrus overhead is thin + // anyway, and those smears were the loudest thing in the night frame. + fade *= 1.0 - 0.66 * smoothstep( 0.55, 0.85, rayDir.y ); + + if ( fade > 0.004 ) { + vec2 p = ( uViewPos + rayDir * tc ).xz * 1000.0 + wind * 2.4; + float cov = clamp( uCloudParams2.x, 0.0, 1.0 ); + + // Two decorrelated families: different seeds, different patch masks, + // different bearings (0.24 and 1.56 rad — 75 degrees apart), different + // rotation frequencies (one turn per 7.4 km and per 10.2 km) and different + // fibre scales. Each square of sky is dominated by one of them, which is how + // a real cirrus front looks, but the *frame* always contains both — and two + // families 75 degrees apart cannot share a vanishing point. + float d1 = skCirrusBand( p, cov, 0.0, 0.24, 0.135, 1.5, 4.0, octC ); + float d2 = skCirrusBand( p + 137.4, cov * 0.92, 4.7, 1.56, 0.098, 2.0, 3.4, octC ); + float d = 1.0 - ( 1.0 - d1 ) * ( 1.0 - d2 * 0.85 ); + + // Optically thin: even a solid-looking cirrus front only takes about two + // thirds of the sky behind it. + float a = clamp( d * uCloudParams2.y * fade, 0.0, 0.70 ); + + // Optically thin: mostly forward scatter plus whatever the sky gives back. + // Cirrus sit above most of the aerosol, so they keep far more blue than + // the cumulus below them — which is exactly why a sunset goes pink up high + // and orange-grey lower down. + float fwd = skHG( cosSun, 0.74 ) * 3.2 + 0.60; + vec3 col = ( sunHigh * fwd + moonHigh * ( skHG( cosMoon, 0.68 ) * 2.8 + 0.55 ) ) + / SK_PI + ambient * 0.85; + cirrus = vec4( col, a ); + } + } + + // ---- cumulus, 1.5 km --------------------------------------------------- + float tk = skRaySphere( uViewPos, rayDir, SK_GROUND_R + SK_CUMULUS_KM * 0.001 ); + vec4 cumulus = vec4( 0.0 ); + if ( tk > 0.0 ) { + float distKM = tk * 1000.0; + float fade = 1.0 - smoothstep( 14.0, 130.0, distKM ); + if ( fade > 0.004 ) { + vec2 p0 = ( uViewPos + rayDir * tk ).xz * 1000.0 + wind; + + // Fake vertical extent. A cumulus is several hundred metres tall; + // sampling a flat deck once gives a decal. So: probe the base, shift the + // sample along the view ray by the height the cloud would have there, and + // probe again. The result parallaxes — tops lean away from the camera, + // bases toward it — which is what gives the silhouette any depth at all. + float dBase = skCumulusDensity( p0, octD ); + vec2 shear = rayDir.xz * ( 0.85 * dBase / max( 0.10, rayDir.y ) ); + float d = max( skCumulusDensity( p0 + shear, octD ), dBase * 0.55 ); + + if ( d > 0.003 ) { + vec2 p = p0 + shear; + float lit = skCumulusLight( p, sunDir, octL ); + float litM = skCumulusLight( p, moonDir, octL ); + + // Grazing rays travel further through a deck — but only up to a point, + // past which the deck is simply far away and the haze wins. + float graze = clamp( 0.09 / ( abs( rayDir.y ) + 0.09 ), 0.0, 1.0 ); + float thick = d * uCloudParams.y * mix( 1.0, 1.7, graze ); + float a = clamp( 1.0 - exp( -thick * 3.4 ), 0.0, 1.0 ) * fade; + + // Powder (dark-edge) term. Note what it does and does not do: it is + // small where the slab is optically thin, so it darkens the *thin lit + // edge* relative to the deep lit core, which is the multiple-scattering + // deficit a real cloud shows against the sun. It is NOT what darkens + // bases — that is skCumulusLight above, whose sun path through the slab + // is what puts the underside in shadow. At density 1.9 the top-to-base + // spread inside a cloud body measures ~3.5 stops (it was the same spread + // at 1.4, but on a deck so continuous that almost nothing in frame was a + // lit top, which is why the shots read flat). + float powder = 1.0 - exp( -thick * 5.5 ); + float rim = pow( clamp( 1.0 - d, 0.0, 1.0 ), 2.0 ); + + float fwdS = skHG( cosSun, 0.62 ) * 4.0 + 0.62; + float fwdM = skHG( cosMoon, 0.60 ) * 3.4 + 0.55; + + vec3 direct = sunLow * ( lit * ( 0.55 + 0.45 * powder ) * fwdS + rim * lit * 0.9 ); + direct += moonLow * ( litM * ( 0.55 + 0.45 * powder ) * fwdM + rim * litM * 0.9 ); + // Sky fills the shaded sides; the deck's own base steals some of it. + vec3 fill = ambient * mix( 0.50, 1.5, clamp( d * 1.6, 0.0, 1.0 ) ) + * ( 0.32 + 0.68 * lit ); + cumulus = vec4( direct / SK_PI + fill, a ); + } + } + } + + // Cumulus is below cirrus, so it goes on top from the ground's point of view. + float outA = cirrus.a + cumulus.a * ( 1.0 - cirrus.a ); + vec3 outC = cirrus.rgb * cirrus.a + cumulus.rgb * cumulus.a * ( 1.0 - cirrus.a ); + if ( outA > 1e-5 ) outC /= outA; + return vec4( outC, outA ); +} + +/** + * Sunlight reaching the ground through the cumulus deck, for a world XZ point. + * The volumetric fog uses this so shafts carry the cloud pattern; the sun's + * DirectionalLight uses the CPU twin of skCloudMacro for the same reason. + */ +float skCloudShadow( vec2 worldXZ, vec3 sunDir ) { + // Walk from the ground point up to the deck along the sun direction. sunDir + // is unit, so the horizontal offset is just sunDir.xz scaled by the slope. + vec2 p = worldXZ * 0.001 + sunDir.xz * ( SK_CUMULUS_KM / max( 0.10, sunDir.y ) ) + + vec2( uCloudParams2.z, uCloudParams2.w ) * uCloudParams.w; + float d = skCumulusDensity( p, 4 ); + return exp( -d * uCloudParams.y * 2.4 ); +} + +#endif +`; + +/** + * CPU twin of skCloudMacro. Identical expression, so the sun-occlusion factor + * the DirectionalLight uses is the same field the shader draws. float32 vs + * float64 differ in the last few bits; nothing here is sensitive to that. + */ +export function cloudMacro(x, y) { + const a = Math.sin(x * 0.412 + 0.7) * Math.cos(y * 0.331 - 0.4); + const b = Math.sin(x * 0.173 - y * 0.209 + 1.9); + const c = Math.cos(x * 0.0871 + y * 0.1123 - 0.6); + return Math.min(1, Math.max(0, 0.5 + 0.5 * (0.42 * a + 0.36 * b + 0.3 * c))); +} + +/** + * Approximate fraction of direct sunlight surviving the cumulus deck above a + * world point. Uses the macro field only: the fbm detail modulates *within* a + * cloud, but whether the sun is behind a cloud at all is a weather-scale + * question, which is exactly what the macro field answers. + */ +export function cloudSunOcclusion(worldX, worldZ, sunDir, params) { + const h = 1.5; + const k = h / Math.max(0.1, sunDir.y); + const px = worldX * 0.001 + sunDir.x * k + params.windX * params.time; + const pz = worldZ * 0.001 + sunDir.z * k + params.windZ * params.time; + const macro = cloudMacro(px * 0.22, pz * 0.22); + const cov = Math.min(1, Math.max(0, params.coverage * (0.34 + 1.3 * macro))); + // Expected density for a coverage threshold applied to a [0,1] fbm. + const d = Math.min(1, Math.max(0, (cov - 0.42) / 0.62)); + return Math.exp(-d * params.density * 1.55); +} diff --git a/src/lib/cod/sky/dome.js b/src/lib/cod/sky/dome.js new file mode 100644 index 00000000..48f2bd95 --- /dev/null +++ b/src/lib/cod/sky/dome.js @@ -0,0 +1,377 @@ +import * as THREE from 'three'; +import { + ATMOSPHERE_GLSL, + TRANSMITTANCE_LOOKUP_GLSL, +} from './atmosphere.js'; +import { SKYVIEW_LOOKUP_GLSL } from './luts.js'; +import { NOISE_GLSL } from './noise.js'; +import { STARS_GLSL } from './stars.js'; +import { CLOUDS_GLSL } from './clouds.js'; +import { fullScreenGeometry, SKY_VERT } from './fullscreen.js'; + +/** + * The visible sky. + * + * Drawn as a full-screen triangle at renderOrder -10000 with depth test and + * depth write off, exactly the way `scene.background` works internally — so it + * fills the frame before any geometry and costs one primitive. The ray + * direction is rebuilt from `camera.projectionMatrixInverse` inside + * `onBeforeRender`, which means it picks up the renderer's TAA jitter and the + * sun disc gets properly resolved sub-pixel antialiasing instead of stair steps. + * + * `userData.owNoPrepass` keeps it out of the depth/normal/velocity prepass and + * out of the shadow cascades, per the render contract. + * + * Contents, in the order they are layered: + * sky-view LUT -> Rayleigh + Mie + ozone + multiple scattering + * aureoles -> the Mie forward peak the LUT resolution destroys + * sun disc -> limb darkened, extinguished by the view-path transmittance + * moon disc -> procedural albedo, real terminator from the sun direction + * night sky -> Milky Way, three star layers, airglow, attenuated by the + * cloud alpha computed below it — stars do not shine through + * an overcast, and that ordering is the only way to say so + * clouds -> cirrus then cumulus, lit by the same irradiances + * ground -> first-bounce albedo below the horizon (matters for IBL) + */ + +const SKY_BODY = /* glsl */ ` +${ATMOSPHERE_GLSL} +${TRANSMITTANCE_LOOKUP_GLSL} +${SKYVIEW_LOOKUP_GLSL} +${NOISE_GLSL} +${STARS_GLSL} +${CLOUDS_GLSL} + +uniform vec3 uSunDir; +uniform vec3 uMoonDir; +uniform vec3 uSunIrradiance; // scene light units, at the ground +uniform vec3 uMoonIrradiance; +uniform vec3 uSunDiscRadiance; // radiance of the disc before extinction +uniform vec3 uMoonDiscRadiance; +uniform vec4 uDisc; // x sun ang. radius, y moon ang. radius, + // z sun draw scale, w moon draw scale +uniform vec3 uGroundAlbedo; +uniform float uHorizonMurk; // city haze piled up at eye level +uniform vec2 uSkyRolloff; // x knee (scene radiance), y overshoot room + +float owSkLum( vec3 c ) { return dot( c, vec3( 0.2126, 0.7152, 0.0722 ) ); } + +/** 2x1: texel 0 = cosine-weighted sky average, texel 1 = horizon band average. */ +uniform sampler2D uSkyAmbientLut; + +vec3 skAmbientSky() { return texture( uSkyAmbientLut, vec2( 0.25, 0.5 ) ).rgb; } +vec3 skAmbientHorizon() { return texture( uSkyAmbientLut, vec2( 0.75, 0.5 ) ).rgb; } + +/** + * Radiance of the solar disc, limb darkened. The exponents are the per-channel + * Hosek-Wilkie limb coefficients: blue falls off fastest, which is why the rim + * of a low sun is orange while the centre stays white. + */ +vec3 skSunDisc( vec3 rayDir, float theta ) { + float R = uDisc.x * uDisc.z; + float aa = max( 1.0e-6, fwidth( theta ) ); + float cover = smoothstep( R + aa, R - aa, theta ); + if ( cover <= 0.0 ) return vec3( 0.0 ); + float r = clamp( theta / R, 0.0, 1.0 ); + float mu = sqrt( max( 0.0, 1.0 - r * r ) ); + vec3 limb = pow( vec3( mu ), vec3( 0.32, 0.44, 0.58 ) ); + // Enlarging the disc for readability must not add energy, so divide by the + // area factor; bloom then behaves the same as it would at true angular size. + return uSunDiscRadiance * limb * cover * skTransmittance( uViewPos, uSunDir ) + / ( uDisc.z * uDisc.z ); +} + +/** + * Circumsolar aureole — the bright white halo that surrounds a real sun out to + * ten or fifteen degrees. + * + * The sky-view LUT is 384x192, so one texel spans about a degree of azimuth. + * The Mie phase function at g = 0.8 puts most of its energy inside five + * degrees, and bilinear interpolation of a one-degree grid destroys exactly + * that peak — which is why a LUT-based Hillaire sky renders a sun as a hard + * white dot pasted on flat blue. This adds the missing energy back + * analytically: the aerosol optical depth along the view ray times the *excess* + * of the Mie phase over its value at the cutoff angle, so the term is + * continuous at the edge, vanishes when turbidity goes to zero, and reddens + * with the sun because it is driven by the same transmittance as everything + * else. It is a scattering integral, not a lens flare. + */ +vec3 skAureole( vec3 rayDir, vec3 lightDir, vec3 irradiance, float cosTheta ) { + const float CUT = 0.9135; // cos(24 degrees) + if ( cosTheta <= CUT ) return vec3( 0.0 ); + // Aerosol column along the ray: sigma_s * H / cos(zenith), floored so a ray + // at the horizon does not blow up. The floor is small because the aureole of a + // *low* sun is the whole point: that is the ramp from the amber core out to + // eight or ten degrees that makes a sunset read as a sunset. + float mieOd = SK_MIE_S * uMieScale * 0.0012 / max( 0.055, rayDir.y + 0.055 ); + float excess = max( 0.0, skMiePhase( cosTheta ) - skMiePhase( CUT ) ); + // 4.2: the LUT holds a bilinear smear of this peak across a one-degree grid, + // and this restores what that interpolation threw away. The coefficient is the + // one number in the sky that is chosen by eye rather than derived, because what + // it is correcting is a sampling error, not a physical quantity — it is set so + // the aureole is about a stop over the sky it sits in at eight degrees out, + // which is what a photograph of a low sun shows. It carries the sun's own + // reddened spectrum through skTransmittance, so the ramp is amber at 19h and + // white at noon without a single hand-picked colour anywhere. + return irradiance * skTransmittance( uViewPos, rayDir ) * ( excess * mieOd * 4.2 ); +} + +/** + * Highlight roll-off for the sky, and only for the sky. + * + * At four degrees of solar elevation the aerosol forward peak puts the western + * horizon three to four stops over the street it is lighting. The street is what + * the meter is set for, so the whole sky above it lands on the flat top of the + * tone curve: one achromatic plateau, no Rayleigh column, no transition band, no + * gradient at all — which is exactly what the 19:20 frame was. + * + * This is the sky's own shoulder, applied before the discs. A Reinhard knee on + * LUMINANCE with the chromaticity carried through unchanged, so what comes back + * is compressed in level but identical in hue: the peach-to-crimson ramp + * survives instead of desaturating to white the way a per-channel clamp would. + * The knee is published as a fraction of the beam's own luminance (see + * SkySystem._updateCelestial), which makes it exposure-invariant — autoexposure + * follows the beam, so a knee that follows the beam lands at the same code value + * at every time of day, and a daylight sky, which never reaches it, is untouched. + * + * uSkyRolloff.x knee, in scene radiance units + * uSkyRolloff.y compression exponent above the knee (1 = none, 0.38 = ~2.6:1) + */ +vec3 skRolloff( vec3 col ) { + float knee = uSkyRolloff.x; + if ( knee <= 0.0 ) return col; + float l = max( owSkLum( col ), 1.0e-6 ); + if ( l <= knee ) return col; + // POWER compressor, not a Reinhard knee. The Reinhard form asymptotes at + // knee * (1 + room), which is a hard ceiling: everything from eight degrees + // off the sun out to the far horizon piles onto the same value and the sunset + // sky comes back as one cream plateau — the very artefact the roll-off exists + // to prevent. x^p has no ceiling, so a 40:1 overshoot still comes out as a + // 4:1 gradient and the peach-to-crimson ramp survives all the way in to the + // aureole, while the disc (four decades over) still clips and blooms. + float p = uSkyRolloff.y; + return col * ( pow( l / knee, p ) * knee / l ); +} + +vec3 skMoonDisc( vec3 rayDir, float theta, int oct ) { + float R = uDisc.y * uDisc.w; + if ( theta > R * 1.6 ) return vec3( 0.0 ); + + vec3 ref = abs( uMoonDir.y ) > 0.97 ? vec3( 0.0, 0.0, 1.0 ) : vec3( 0.0, 1.0, 0.0 ); + vec3 mr = normalize( cross( ref, uMoonDir ) ); + vec3 mu3 = cross( uMoonDir, mr ); + + // Gnomonic projection is exact enough over a quarter of a degree. + vec2 p = vec2( dot( rayDir, mr ), dot( rayDir, mu3 ) ) / R; + float r2 = dot( p, p ); + // Two pixels of edge, not one: the disc is six stops over the tonemap knee, + // so a one-pixel edge leaves a dotted rim once TAA and the sharpen filter + // have had a go at it. + float aa = max( 1.0e-4, 1.9 * fwidth( r2 ) ); + float cover = smoothstep( 1.0 + aa, 1.0 - aa, r2 ); + if ( cover <= 0.0 ) return vec3( 0.0 ); + + vec3 n = normalize( mr * p.x + mu3 * p.y - uMoonDir * sqrt( max( 0.0, 1.0 - min( r2, 1.0 ) ) ) ); + + // Maria are basalt floods over anorthositic highlands: albedo 0.06 vs 0.14. + float highlands = skFbm3( n * 6.5, oct ); + float maria = smoothstep( 0.44, 0.63, skFbm3( n * 2.1 + 5.0, max( 2, oct - 1 ) ) ); + float albedo = mix( 0.105, 0.155, highlands ) * mix( 1.0, 0.52, maria ); + + float NdL = max( 0.0, dot( n, uSunDir ) ); + // Lunar regolith backscatters hard: the disc is nearly flat right up to the + // terminator, which a Lambert cosine gets badly wrong. + float shade = pow( NdL, 0.42 ); + float earthshine = 0.014; + + return uMoonDiscRadiance * ( albedo / 0.13 ) * ( shade + earthshine ) * cover; +} + +/** + * @param rayDir normalised world direction + * @param quality 1 = screen, 0 = environment map (fewer octaves, no star points) + */ +vec3 skSample( vec3 rayDir, int quality ) { + vec3 ambSky = skAmbientSky(); + vec3 ambHor = skAmbientHorizon(); + + vec3 col = skSkyView( rayDir, uSunDir ); + + float cosS = dot( rayDir, uSunDir ); + float cosM = dot( rayDir, uMoonDir ); + float thetaS = skSafeAcos( cosS ); + float thetaM = skSafeAcos( cosM ); + + // Aureoles go in before the discs so the discs sit *inside* their own glow. + // Both are driven by the same irradiances that light the scattering, so the + // lunar halo ends up the same *fraction* of the moonlit sky as the solar + // aureole is of the daylit sky — it scales with the night's exposure for free. + col += skAureole( rayDir, uSunDir, uSunIrradiance, cosS ); + col += skAureole( rayDir, uMoonDir, uMoonIrradiance, cosM ); + + // ---- clouds ------------------------------------------------------------- + // The two decks sit at very different altitudes, so they see very different + // solar spectra: the cumulus at 1.5 km looks through nearly the whole aerosol + // column while the cirrus at 7.8 km is above most of it. Sampling the + // transmittance LUT at each deck's own altitude is what makes a sunset read + // as pink cirrus over orange-grey cumulus instead of one flat orange wash. + vec3 pLow = vec3( 0.0, SK_GROUND_R + 0.0015, 0.0 ); + vec3 pHigh = vec3( 0.0, SK_GROUND_R + 0.0078, 0.0 ); + vec3 sunLow = uSunIrradiance * skTransmittance( pLow, uSunDir ); + vec3 sunHigh = uSunIrradiance * skTransmittance( pHigh, uSunDir ); + vec3 moonLow = uMoonIrradiance * skTransmittance( pLow, uMoonDir ); + vec3 moonHigh = uMoonIrradiance * skTransmittance( pHigh, uMoonDir ); + vec4 cl = skClouds( rayDir, uSunDir, sunLow, sunHigh, + uMoonDir, moonLow, moonHigh, ambSky, quality ); + + // ---- night sky, BEHIND the decks --------------------------------------- + // Stars have to be occluded by cloud. A star seen *through* an opaque cumulus + // is the single most obvious tell in a night frame, and it was visible here + // because the starfield was added to the sky before the decks were composited + // over it — an 0.6-alpha cloud still let 40% of the field through, and the + // deck's own radiance at night is so low that 40% of a star is still a star. + // The multiplier is above one because a deck that is optically thick enough to + // hide its own texture is thick enough to hide a point source completely. + vec3 night = skNightSky( rayDir, quality > 0 ? 5 : 3, quality > 0 ); + col += night * ( 1.0 - clamp( cl.a * 1.9, 0.0, 1.0 ) ); + + if ( cl.a > 1.0e-4 ) { + // Aerial perspective on the decks themselves. A cloud twenty kilometres out + // is seen through twenty kilometres of air, so it loses contrast toward the + // radiance of the sky in front of it. Keyed off view elevation, which is + // what sets the path length to a deck of fixed altitude. skClouds has + // already faded its own alpha with distance; this fades the *colour*, which + // is what stops a low cloud bank reading as a cut-out. + float bleed = 1.0 - smoothstep( 0.0, 0.22, rayDir.y ); + col = mix( col, mix( cl.rgb, col, bleed * 0.82 ), cl.a ); + } + + // ---- ground / below the horizon ---------------------------------------- + if ( rayDir.y < 0.0 ) { + // First bounce off the street: this is what fills the lower hemisphere of + // the IBL and gives upward-facing surfaces their warm fill. + vec3 ground = uGroundAlbedo * + ( ambHor + uSunIrradiance * max( 0.0, uSunDir.y ) / SK_PI + + uMoonIrradiance * max( 0.0, uMoonDir.y ) / SK_PI ); + col = mix( col, ground, smoothstep( 0.0, -0.22, rayDir.y ) ); + } + + // A real city horizon is never clean: dust and exhaust pile up in the first + // few degrees. Scaled by the sky's own brightness so it can never glow. + float murk = uHorizonMurk * exp( -abs( rayDir.y ) * 26.0 ); + col = mix( col, ambHor * 1.15, clamp( murk, 0.0, 0.85 ) ); + + // ---- horizon roll-off --------------------------------------------------- + col = skRolloff( col ); + + // The discs go in AFTER the roll-off: they are supposed to clip and bloom, + // and they are the only thing in the sky that is. + if ( quality > 0 ) col += skSunDisc( rayDir, thetaS ); + col += skMoonDisc( rayDir, thetaM, quality > 0 ? 4 : 2 ); + + return max( col, vec3( 0.0 ) ); +} +`; + +const DOME_VERT = /* glsl */ ` +uniform mat4 uInvProj; +uniform mat4 uCamWorld; +out vec3 vRay; +void main() { + vec2 ndc = position.xy; + vec4 h = uInvProj * vec4( ndc, 1.0, 1.0 ); + vec3 vd = h.xyz / h.w; + // Normalise onto the z = -1 plane: that quantity is linear in screen space, + // so interpolating it and normalising in the fragment shader is exact. + vd /= max( 1.0e-6, -vd.z ); + vRay = mat3( uCamWorld ) * vd; + gl_Position = vec4( ndc, 1.0, 1.0 ); +} +`; + +const DOME_FRAG = /* glsl */ ` +precision highp float; +${SKY_BODY} +in vec3 vRay; +layout(location = 0) out vec4 fragColor; +void main() { + fragColor = vec4( skSample( normalize( vRay ), 1 ), 1.0 ); +} +`; + +/** Equirectangular bake for PMREM. Matches three's `equirectUv` exactly. */ +const ENV_FRAG = /* glsl */ ` +precision highp float; +${SKY_BODY} +in vec2 vUv; +layout(location = 0) out vec4 fragColor; +void main() { + float az = ( vUv.x - 0.5 ) * 2.0 * SK_PI; + float lat = ( vUv.y - 0.5 ) * SK_PI; + float cl = cos( lat ); + vec3 dir = vec3( cl * cos( az ), sin( lat ), cl * sin( az ) ); + fragColor = vec4( skSample( normalize( dir ), 0 ), 1.0 ); +} +`; + +export class SkyDome { + /** + * @param {object} uniforms shared uniform objects, owned by SkySystem + */ + constructor(uniforms) { + this.uniforms = { + ...uniforms, + uInvProj: { value: new THREE.Matrix4() }, + uCamWorld: { value: new THREE.Matrix4() }, + }; + + this.material = new THREE.ShaderMaterial({ + name: 'sky-dome', + uniforms: this.uniforms, + vertexShader: DOME_VERT, + fragmentShader: DOME_FRAG, + glslVersion: THREE.GLSL3, + side: THREE.DoubleSide, + depthTest: false, + depthWrite: false, + blending: THREE.NoBlending, + fog: false, + toneMapped: false, + }); + + this.mesh = new THREE.Mesh(fullScreenGeometry(), this.material); + this.mesh.name = 'sky-dome'; + this.mesh.frustumCulled = false; + this.mesh.renderOrder = -10000; + this.mesh.matrixAutoUpdate = false; + // Render contract: stay out of the prepass, the cascades and contact shadows. + this.mesh.userData.owNoPrepass = true; + this.mesh.userData.owNoShadow = true; + + const u = this.uniforms; + this.mesh.onBeforeRender = (renderer, scene, camera) => { + // projectionMatrixInverse is kept in sync with the TAA jitter by the + // renderer, so the sky is jittered with the rest of the frame. + u.uInvProj.value.copy(camera.projectionMatrixInverse); + u.uCamWorld.value.copy(camera.matrixWorld); + }; + + // Environment bake shares every uniform object with the visible sky, so the + // IBL can never drift out of agreement with what the camera sees. + this.envMaterial = new THREE.ShaderMaterial({ + name: 'sky-env', + uniforms: this.uniforms, + vertexShader: SKY_VERT, + fragmentShader: ENV_FRAG, + glslVersion: THREE.GLSL3, + depthTest: false, + depthWrite: false, + blending: THREE.NoBlending, + }); + } + + dispose() { + this.material.dispose(); + this.envMaterial.dispose(); + } +} diff --git a/src/lib/cod/sky/driver.js b/src/lib/cod/sky/driver.js new file mode 100644 index 00000000..389bdf21 --- /dev/null +++ b/src/lib/cod/sky/driver.js @@ -0,0 +1,192 @@ +// driver.js +// Static-sky driver for the Claude-of-Duty (CoD) procedural atmosphere. +// Replaces the ctx-coupled parts of src/sky/index.js for a fixed-hour, no-animation, +// no-lights, no-volumetrics, no-events bake into React-Three-Fiber. +// +// Ports, faithfully: +// buildSharedUniforms() <- index.js ~226-286 (the shared uniform block) +// updateCelestial(cel, shared, h) <- index.js ~540-794 (_updateCelestial), with the +// DirectionalLight / exposure / ambient / ctx.* / fog +// publishing dropped. Only the LUT/dome/env shader +// inputs survive. +// +// The vendored luts.js / dome.js read these uniforms BY NAME, so names + THREE types +// below must match index.js exactly. + +import * as THREE from 'three'; +import { + ATMO, // index.js:5 (ATMO.groundRadiusMM / viewAltitudeMM) + SUN_ILLUMINANCE_TOP, // index.js:6 (= 5.12 scene units) + MOON_ILLUMINANCE_NIGHT, // index.js:8 (= 0.30 scene units) + transmittanceToSpace, // index.js:9 (CPU twin of the transmittance LUT) +} from './atmosphere.js'; + +// ---- module constant retained from index.js (only one the ported path needs) ---------- +// index.js:24 — floor on the beam's luminous transmittance (feeds the beam-floor gain). +const SUN_LUM_FLOOR = 0.35; + +// ---- weather defaults that index.js init (lines 142-163) reads into the shared block ---- +const W = { + turbidity: 1.35, // index.js:142 + cloudCoverage: 0.3, // index.js:146 + cloudDensity: 1.9, // index.js:150 + cirrusCoverage: 0.21, // index.js:159 + cirrusOpacity: 0.3, // index.js:160 + horizonMurk: 0.13, // index.js:163 +}; + +const { clamp, lerp, smoothstep, radToDeg } = THREE.MathUtils; + +/** + * The shared uniform object, EXACTLY as index.js builds it at 226-286. + * The 4 LUT-texture uniforms (uTransmittanceLut / uMultiScatterLut / uSkyViewLut / + * uSkyAmbientLut) are intentionally ABSENT: the SkyLuts constructor adds them to this + * same object (luts.js:253-256). Celestial-dependent fields start at their pre-solve + * defaults and are filled by updateCelestial(). + */ +export function buildSharedUniforms() { + const viewR = ATMO.groundRadiusMM + ATMO.viewAltitudeMM; // index.js:226 + + return { + uMieScale: { value: W.turbidity }, // index.js:228 (weather.turbidity) + uViewPos: { value: new THREE.Vector3(0, viewR, 0) }, // index.js:229 (MUST be non-zero) + + uSunDir: { value: new THREE.Vector3(0, 1, 0) }, // index.js:231 + uMoonDir: { value: new THREE.Vector3(0, -1, 0) }, // index.js:232 + uSunIrradiance: { value: new THREE.Vector3() }, // index.js:233 (filled by updateCelestial) + uMoonIrradiance: { value: new THREE.Vector3() }, // index.js:234 (filled by updateCelestial) + uSunDiscRadiance: { value: new THREE.Vector3() }, // index.js:235 (filled by updateCelestial) + uMoonDiscRadiance: { value: new THREE.Vector3() }, // index.js:236 (filled by updateCelestial) + uSunAltitude: { value: 0 }, // index.js:237 (filled by updateCelestial) + uMoonAltitude: { value: 0 }, // index.js:238 (filled by updateCelestial) + uMoonRelAz: { value: 0 }, // index.js:239 (filled by updateCelestial) + // x/y true angular radii of sun/moon, z/w draw-scale (readability). index.js:245 + uDisc: { value: new THREE.Vector4(0.004654, 0.004516, 3.0, 4.2) }, + // Lower-hemisphere IBL albedo (sand/lime plaster). index.js:249 + uGroundAlbedo: { value: new THREE.Vector3(0.33, 0.29, 0.225) }, + uHorizonMurk: { value: W.horizonMurk }, // index.js:250 (weather.horizonMurk) + // Sky highlight roll-off knee/overshoot; re-driven by updateCelestial. index.js:253 + uSkyRolloff: { value: new THREE.Vector2(0.3, 1.5) }, + + uStarParams: { value: new THREE.Vector4(0, 0.5, 0, 0) }, // index.js:255 (x/y/w re-driven; z=time stays 0) + uCelestial: { value: new THREE.Matrix3() }, // index.js:256 (filled by updateCelestial) + + uCloudParams: { + // index.js:258 (coverage, density, 1, time=0) + value: new THREE.Vector4(W.cloudCoverage, W.cloudDensity, 1, 0), + }, + uCloudParams2: { + // index.js:266 (cirrusCov, cirrusOpac, windX, windZ) + value: new THREE.Vector4(W.cirrusCoverage, W.cirrusOpacity, 0.004, 0.0016), + }, + + // ---- volumetric / camera block (index.js:276-285) ---- + // Present for byte-fidelity with index.js. NONE are read by luts.js / dome.js / the env + // bake — they belong to the dropped volumetric + fog passes. dome.js overrides its own + // uInvProj/uCamWorld anyway (dome.js:324-325). Kept so the object matches the source. + uInvProj: { value: new THREE.Matrix4() }, // index.js:276 + uCamWorld: { value: new THREE.Matrix4() }, // index.js:277 + uCamPos: { value: new THREE.Vector3() }, // index.js:278 + uFog: { value: new THREE.Vector4() }, // index.js:279 + uFog2: { value: new THREE.Vector4() }, // index.js:280 + uFogExt: { value: new THREE.Vector3() }, // index.js:281 + uPhase: { value: new THREE.Vector4() }, // index.js:282 + uKeyDir: { value: new THREE.Vector3(0, 1, 0) }, // index.js:283 + uKeyIrr: { value: new THREE.Vector3() }, // index.js:284 + uFogDrift: { value: new THREE.Vector3() }, // index.js:285 + }; +} + +/** + * Populate the celestial-dependent shared uniforms from the sun/moon solve. + * Faithful port of index.js _updateCelestial (540-794), keeping ONLY the writes the + * sky-view LUT, ambient LUT and dome/env shaders consume. + * + * @param {import('./celestial.js').Celestial} c a Celestial instance + * @param {object} s the object returned by buildSharedUniforms() (LUT textures already added) + * @param {number} hour 0..24 local solar time + */ +export function updateCelestial(c, s, hour) { + c.setHour(hour); // index.js:541 — solves sun/moon alt/az/phase + sky matrix + + s.uSunDir.value.copy(c.sun); // index.js:544 + s.uMoonDir.value.copy(c.moon); // index.js:545 + s.uSunAltitude.value = c.sunAlt; // index.js:546 + s.uMoonAltitude.value = c.moonAlt; // index.js:547 + + // Moon azimuth RELATIVE to the sun (the sky-view LUT bakes the sun at az 0). index.js:550-553 + let rel = c.moonAz - c.sunAz; + while (rel > Math.PI) rel -= 2 * Math.PI; + while (rel < -Math.PI) rel += 2 * Math.PI; + s.uMoonRelAz.value = rel; + c.celestialMatrix(s.uCelestial.value); // index.js:554 + + const mie = W.turbidity; // index.js:556 (weather.turbidity) + + // ---- sun ---------------------------------------------------------------- index.js:558-616 + const muS = Math.sin(c.sunAlt); + const discS = clamp(0.5 + muS / (2 * 0.004654), 0, 1); + const sunT = [0, 0, 0]; + transmittanceToSpace(Math.max(muS, 0.0008), mie, sunT); + const tint = [1.0, 0.975, 0.94]; // solar spectrum, warm of D65 + const T = sunT; + const aureoleP = lerp(0.55, 1.0, smoothstep(radToDeg(c.sunAlt), 0, 16)); + const sr = Math.pow(T[0], aureoleP) * tint[0]; + const sg = Math.pow(T[1], aureoleP) * tint[1]; + const sb = Math.pow(T[2], aureoleP) * tint[2]; + const smax = Math.max(1e-6, sr, sg, sb); + + const lumT = 0.2126 * sr + 0.7152 * sg + 0.0722 * sb; // index.js:605 + const altDeg = radToDeg(c.sunAlt); + const beamAlive = smoothstep(altDeg, -6.0, -1.0); + const lumFloor = SUN_LUM_FLOOR * beamAlive; + const beamGain = Math.max(1, lumFloor / Math.max(lumT, 1e-5)); + const baseSunIntensity = SUN_ILLUMINANCE_TOP * smax * discS * beamGain; // index.js:613 + const beamLuminance = SUN_ILLUMINANCE_TOP * Math.max(lumT * beamGain, 1e-6) * discS; // index.js:616 + + // Extraterrestrial irradiance handed to the sky LUT (raymarch applies extinction). index.js:620 + s.uSunIrradiance.value.set( + SUN_ILLUMINANCE_TOP * tint[0], + SUN_ILLUMINANCE_TOP * tint[1], + SUN_ILLUMINANCE_TOP * tint[2] + ); + + const discRad = 4000; // index.js:630 (half-float-safe disc radiance) + s.uSunDiscRadiance.value.set(discRad * tint[0], discRad * tint[1], discRad * tint[2]); // index.js:631 + + // ---- night ramps -------------------------------------------------------- index.js:636-638 + const keyRamp = smoothstep(-altDeg, -3, 5); + const nightRamp = smoothstep(-altDeg, 0, 9); + + // ---- moon --------------------------------------------------------------- index.js:641-664 + const muM = Math.sin(c.moonAlt); + const discM = clamp(0.5 + muM / (2 * 0.004516), 0, 1); + const moonT = [0, 0, 0]; + transmittanceToSpace(Math.max(muM, 0.0008), mie, moonT); + const MT = moonT; + const cool = [0.66, 0.8, 1.0]; // Purkinje-shifted moonlight tint + const mr = MT[0] * cool[0]; + const mg = MT[1] * cool[1]; + const mb = MT[2] * cool[2]; + const mmax = Math.max(1e-6, mr, mg, mb); + let moonI = MOON_ILLUMINANCE_NIGHT * c.moonPhase * mmax * discM * keyRamp; // index.js:655 + // Handover floor (index.js:660): kept because moonI feeds the uSkyRolloff night knee below. + if (Math.max(baseSunIntensity, moonI) < 0.03) moonI = 0.03; + + const moonIrr = MOON_ILLUMINANCE_NIGHT * c.moonPhase * keyRamp; // index.js:663 + s.uMoonIrradiance.value.set(moonIrr * cool[0], moonIrr * cool[1], moonIrr * cool[2]); // index.js:664 + + const moonDisc = lerp(0.35, 3.5, nightRamp); // index.js:671 + s.uMoonDiscRadiance.value.set(moonDisc, moonDisc * 0.985, moonDisc * 0.95); // index.js:672 + + // ---- sky roll-off knee -------------------------------------------------- index.js:733-741 + const kneeFrac = lerp(0.045, 0.11, smoothstep(altDeg, 2.0, 15.0)); + s.uSkyRolloff.value.set(Math.max(kneeFrac * beamLuminance, 0.02 + 6.0 * moonI), 0.34); + + // ---- stars -------------------------------------------------------------- index.js:781-783 + s.uStarParams.value.x = 0.07 * nightRamp; + s.uStarParams.value.y = 0.55; + s.uStarParams.value.w = 0.16 * nightRamp; + + return s; +} diff --git a/src/lib/cod/sky/fullscreen.js b/src/lib/cod/sky/fullscreen.js new file mode 100644 index 00000000..c1894013 --- /dev/null +++ b/src/lib/cod/sky/fullscreen.js @@ -0,0 +1,101 @@ +import * as THREE from 'three'; + +/** + * Full-screen triangle plumbing, local to the sky subsystem. + * + * `src/render/pass.js` has an equivalent, but ARCHITECTURE.md forbids importing + * another subsystem's module, so we keep our own tiny copy. One shared + * geometry / scene / camera, and a mesh whose material we swap — no allocation + * per frame, no EffectComposer. + * + * Everything here is GLSL ES 3.00 (`glslVersion: THREE.GLSL3`) so the + * volumetric pass can dynamically index the CSM matrix array and sample the + * `sampler2DArray` cascade atlas without relying on ES 1.00 leniency. + */ + +const _geometry = new THREE.BufferGeometry(); +_geometry.setAttribute( + 'position', + new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3) +); +_geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e8); + +const _scene = new THREE.Scene(); +_scene.matrixAutoUpdate = false; +const _camera = new THREE.Camera(); +const _mesh = new THREE.Mesh(_geometry, null); +_mesh.frustumCulled = false; +_mesh.matrixAutoUpdate = false; +_scene.add(_mesh); + +/** The geometry is shared by the sky dome too, so it is never disposed here. */ +export function fullScreenGeometry() { + return _geometry; +} + +export const SKY_VERT = /* glsl */ ` +out vec2 vUv; +void main() { + vUv = position.xy * 0.5 + 0.5; + gl_Position = vec4( position.xy, 0.0, 1.0 ); +} +`; + +/** Draw `material` over the whole of `target` (null = canvas). */ +export function blit(renderer, material, target) { + _mesh.material = material; + renderer.setRenderTarget(target); + renderer.render(_scene, _camera); +} + +/** A full-screen shader step. Uniform objects may be shared between passes. */ +export class SkyPass { + constructor(name, fragmentShader, uniforms, defines = {}) { + this.uniforms = uniforms; + this.material = new THREE.ShaderMaterial({ + name, + uniforms, + defines, + vertexShader: SKY_VERT, + fragmentShader, + glslVersion: THREE.GLSL3, + depthTest: false, + depthWrite: false, + blending: THREE.NoBlending, + }); + } + render(renderer, target) { + blit(renderer, this.material, target); + } + dispose() { + this.material.dispose(); + } +} + +/** + * Half-float colour target. Sky radiance is HDR and physically scaled — the sun + * disc alone is four orders of magnitude above the zenith — so nothing in this + * subsystem is ever allowed to touch an 8-bit buffer. + */ +export function hdrTarget(w, h, opts = {}) { + const rt = new THREE.WebGLRenderTarget(Math.max(1, w | 0), Math.max(1, h | 0), { + type: THREE.HalfFloatType, + format: THREE.RGBAFormat, + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + wrapS: THREE.ClampToEdgeWrapping, + wrapT: THREE.ClampToEdgeWrapping, + depthBuffer: false, + stencilBuffer: false, + generateMipmaps: false, + ...opts, + }); + rt.texture.name = opts.name ?? 'sky-hdr'; + rt.texture.colorSpace = THREE.NoColorSpace; + return rt; +} + +/** Float32 target — used for the transmittance LUT, where banding shows. */ +export function floatTarget(w, h, opts = {}) { + return hdrTarget(w, h, { type: THREE.FloatType, ...opts }); +} diff --git a/src/lib/cod/sky/luts.js b/src/lib/cod/sky/luts.js new file mode 100644 index 00000000..469e754e --- /dev/null +++ b/src/lib/cod/sky/luts.js @@ -0,0 +1,311 @@ +import * as THREE from 'three'; +import { SkyPass, hdrTarget, floatTarget } from './fullscreen.js'; +import { + ATMO, + ATMOSPHERE_GLSL, + TRANSMITTANCE_LOOKUP_GLSL, + MULTISCATTER_LOOKUP_GLSL, + SCATTER_GLSL, +} from './atmosphere.js'; + +/** + * The three atmosphere LUTs plus a 1x1 ambient probe. + * + * Cost, measured on an M-series GPU: + * transmittance 256x64, 40 steps ~0.15 ms once, at boot + * multiscatter 32x32, 64 dirs x 20 ~0.9 ms once, at boot + * sky-view 384x192, 40 steps ~0.6 ms only when the sun moves + * ambient probe 1x1, 64 taps negligible only when the sun moves + * + * Nothing here runs per frame. A static time of day costs zero. + */ + +// --------------------------------------------------------------------------- +// sky-view LUT parameterisation (shared by the bake and every lookup) +// --------------------------------------------------------------------------- + +/** + * Azimuth is measured *relative to the sun*, so one 384x192 table serves every + * compass direction. The altitude axis is square-distributed about the horizon, + * putting half the texels in the bottom 25 degrees where the gradient lives. + */ +export const SKYVIEW_LOOKUP_GLSL = /* glsl */ ` +#ifndef SKY_SVLUT +#define SKY_SVLUT +uniform sampler2D uSkyViewLut; + +vec3 skSkyView( vec3 rayDir, vec3 sunDir ) { + float h = length( uViewPos ); + vec3 up = uViewPos / h; + float horizon = skSafeAcos( sqrt( h * h - SK_GROUND_R * SK_GROUND_R ) / h ); + float altitude = horizon - skSafeAcos( dot( rayDir, up ) ); + + float azimuth = 0.0; + if ( abs( altitude ) < ( 0.5 * SK_PI - 1e-4 ) ) { + vec3 right = cross( sunDir, up ); + vec3 fwd = cross( up, right ); + vec3 proj = normalize( rayDir - up * dot( rayDir, up ) ); + azimuth = atan( dot( proj, right ), dot( proj, fwd ) ) + SK_PI; + } + + float v = 0.5 + 0.5 * sign( altitude ) * sqrt( abs( altitude ) * 2.0 / SK_PI ); + return texture( uSkyViewLut, vec2( azimuth / ( 2.0 * SK_PI ), v ) ).rgb; +} +#endif +`; + +// --------------------------------------------------------------------------- +// bakes +// --------------------------------------------------------------------------- + +const TRANSMITTANCE_FRAG = /* glsl */ ` +precision highp float; +${ATMOSPHERE_GLSL} +in vec2 vUv; +layout(location = 0) out vec4 fragColor; + +void main() { + float mu = vUv.x * 2.0 - 1.0; + float h = mix( SK_GROUND_R, SK_TOP_R, vUv.y ); + vec3 pos = vec3( 0.0, h, 0.0 ); + vec3 dir = vec3( sqrt( max( 0.0, 1.0 - mu * mu ) ), mu, 0.0 ); + + float t = skRaySphere( pos, dir, SK_TOP_R ); + if ( t <= 0.0 ) { fragColor = vec4( 0.0, 0.0, 0.0, 1.0 ); return; } + + const float STEPS = 40.0; + float dt = t / STEPS; + vec3 od = vec3( 0.0 ); + for ( float i = 0.0; i < STEPS; i += 1.0 ) { + vec3 p = pos + ( i + 0.5 ) * dt * dir; + vec3 rs; float ms; vec3 ext; + skMedium( p, rs, ms, ext ); + od += ext * dt; + } + fragColor = vec4( exp( -od ), 1.0 ); +} +`; + +const MULTISCATTER_FRAG = /* glsl */ ` +precision highp float; +${ATMOSPHERE_GLSL} +${TRANSMITTANCE_LOOKUP_GLSL} +in vec2 vUv; +layout(location = 0) out vec4 fragColor; + +const float MS_STEPS = 20.0; +const int SQRT_SAMPLES = 8; + +void main() { + float mu = vUv.x * 2.0 - 1.0; + float h = mix( SK_GROUND_R + 1e-5, SK_TOP_R, vUv.y ); + vec3 pos = vec3( 0.0, h, 0.0 ); + vec3 sunDir = normalize( vec3( sqrt( max( 0.0, 1.0 - mu * mu ) ), mu, 0.0 ) ); + + vec3 lumTotal = vec3( 0.0 ); + vec3 fmsTotal = vec3( 0.0 ); + float invSamples = 1.0 / float( SQRT_SAMPLES * SQRT_SAMPLES ); + + for ( int i = 0; i < SQRT_SAMPLES; i ++ ) { + for ( int j = 0; j < SQRT_SAMPLES; j ++ ) { + // Uniform on the sphere: theta linear, cos(phi) linear. + float theta = SK_PI * ( float( i ) + 0.5 ) / float( SQRT_SAMPLES ); + float phi = skSafeAcos( 1.0 - 2.0 * ( float( j ) + 0.5 ) / float( SQRT_SAMPLES ) ); + float cp = cos( phi ), sp = sin( phi ); + vec3 rayDir = vec3( sp * sin( theta ), cp, sp * cos( theta ) ); + + float topT = skRaySphere( pos, rayDir, SK_TOP_R ); + float grnT = skRaySphere( pos, rayDir, SK_GROUND_R ); + float tMax = grnT < 0.0 ? topT : grnT; + if ( tMax <= 0.0 ) continue; + + vec3 lum = vec3( 0.0 ); + vec3 fms = vec3( 0.0 ); + vec3 trans = vec3( 1.0 ); + float t = 0.0; + for ( float s = 0.0; s < MS_STEPS; s += 1.0 ) { + float nt = ( ( s + 0.5 ) / MS_STEPS ) * tMax; + float dt = nt - t; + t = nt; + vec3 p = pos + t * rayDir; + vec3 rs; float ms; vec3 ext; + skMedium( p, rs, ms, ext ); + vec3 sampleT = exp( -dt * ext ); + + // f_ms: the fraction of light that scatters at least once more. + vec3 sNoPhase = rs + vec3( ms ); + fms += trans * ( sNoPhase - sNoPhase * sampleT ) / max( ext, vec3( 1e-8 ) ); + + vec3 tSun = skTransmittance( p, sunDir ); + vec3 inS = ( rs + vec3( ms ) ) * SK_ISO_PHASE * tSun; + lum += trans * ( inS - inS * sampleT ) / max( ext, vec3( 1e-8 ) ); + trans *= sampleT; + } + + if ( grnT > 0.0 ) { + vec3 hit = normalize( pos + grnT * rayDir ) * SK_GROUND_R; + if ( dot( hit, sunDir ) > 0.0 ) { + lum += trans * SK_GROUND_ALBEDO * skTransmittance( hit, sunDir ); + } + } + + lumTotal += lum * invSamples; + fmsTotal += fms * invSamples; + } + } + + // Infinite geometric series of scattering orders, collapsed. + vec3 psi = lumTotal / max( vec3( 1.0 ) - fmsTotal, vec3( 1e-4 ) ); + fragColor = vec4( psi, 1.0 ); +} +`; + +const SKYVIEW_FRAG = /* glsl */ ` +precision highp float; +${ATMOSPHERE_GLSL} +${TRANSMITTANCE_LOOKUP_GLSL} +${MULTISCATTER_LOOKUP_GLSL} +${SCATTER_GLSL} +in vec2 vUv; +layout(location = 0) out vec4 fragColor; + +uniform vec3 uSunIrradiance; +uniform vec3 uMoonIrradiance; +uniform float uSunAltitude; // radians above the horizon +uniform float uMoonRelAz; // moon azimuth relative to the sun, radians +uniform float uMoonAltitude; + +void main() { + float azimuth = ( vUv.x - 0.5 ) * 2.0 * SK_PI; + float v = vUv.y; + float adjV = v < 0.5 ? -( 1.0 - 2.0 * v ) * ( 1.0 - 2.0 * v ) + : ( 2.0 * v - 1.0 ) * ( 2.0 * v - 1.0 ); + + float h = length( uViewPos ); + vec3 up = uViewPos / h; + float horizon = skSafeAcos( sqrt( h * h - SK_GROUND_R * SK_GROUND_R ) / h ) - 0.5 * SK_PI; + float altitude = adjV * 0.5 * SK_PI - horizon; + float ca = cos( altitude ); + vec3 rayDir = vec3( ca * sin( azimuth ), sin( altitude ), -ca * cos( azimuth ) ); + + // The LUT frame puts the sun at azimuth 0 (along -Z). + vec3 sunDir = vec3( 0.0, sin( uSunAltitude ), -cos( uSunAltitude ) ); + float cm = cos( uMoonAltitude ); + vec3 moonDir = vec3( cm * sin( uMoonRelAz ), sin( uMoonAltitude ), -cm * cos( uMoonRelAz ) ); + + vec3 lum = skRaymarchSky( uViewPos, rayDir, sunDir, uSunIrradiance, + moonDir, uMoonIrradiance, 40.0 ); + fragColor = vec4( lum, 1.0 ); +} +`; + +/** + * Average sky radiance over the upper hemisphere (cosine weighted) and over the + * lower hemisphere-facing band. The ground fog samples this so its ambient term + * is the actual sky it is standing under, not a hand-picked blue. + * texel 0 -> sky ambient texel 1 -> horizon-band ambient + */ +const AMBIENT_FRAG = /* glsl */ ` +precision highp float; +${ATMOSPHERE_GLSL} +${SKYVIEW_LOOKUP_GLSL} +in vec2 vUv; +layout(location = 0) out vec4 fragColor; +uniform float uSunAltitude; + +void main() { + vec3 sunDir = vec3( 0.0, sin( uSunAltitude ), -cos( uSunAltitude ) ); + bool horizonBand = vUv.x > 0.5; + vec3 sum = vec3( 0.0 ); + float wsum = 0.0; + const int N = 64; + for ( int i = 0; i < N; i ++ ) { + // Fibonacci hemisphere. + float fi = ( float( i ) + 0.5 ) / float( N ); + float phi = float( i ) * 2.39996323; + float ct = horizonBand ? mix( -0.12, 0.35, fi ) : sqrt( 1.0 - fi ); + float st = sqrt( max( 0.0, 1.0 - ct * ct ) ); + vec3 d = vec3( st * cos( phi ), ct, st * sin( phi ) ); + float w = horizonBand ? 1.0 : max( 0.0, ct ); + sum += skSkyView( d, sunDir ) * w; + wsum += w; + } + fragColor = vec4( sum / max( wsum, 1e-4 ), 1.0 ); +} +`; + +export class SkyLuts { + constructor(renderer, shared) { + this.renderer = renderer; + + this.transmittanceRt = floatTarget(256, 64, { name: 'sky-transmittance' }); + this.multiScatterRt = hdrTarget(32, 32, { name: 'sky-multiscatter' }); + // 384x192 rather than Hillaire's 192x108: one texel is then under a degree + // of azimuth, which is the difference between a readable warm band around a + // low sun and a visibly interpolated smear. The remaining loss of the Mie + // forward peak is restored analytically by skAureole in dome.js. + this.skyViewRt = hdrTarget(384, 192, { name: 'sky-view' }); + this.ambientRt = hdrTarget(2, 1, { name: 'sky-ambient' }); + this.skyViewRt.texture.wrapS = THREE.RepeatWrapping; + + // Shared uniform objects: one write in SkySystem updates every pass and + // every material that references them. + shared.uTransmittanceLut = { value: this.transmittanceRt.texture }; + shared.uMultiScatterLut = { value: this.multiScatterRt.texture }; + shared.uSkyViewLut = { value: this.skyViewRt.texture }; + shared.uSkyAmbientLut = { value: this.ambientRt.texture }; + this.shared = shared; + + this.transmittancePass = new SkyPass('sky-transmittance', TRANSMITTANCE_FRAG, { + uMieScale: shared.uMieScale, + uViewPos: shared.uViewPos, + }); + this.multiScatterPass = new SkyPass('sky-multiscatter', MULTISCATTER_FRAG, { + uMieScale: shared.uMieScale, + uViewPos: shared.uViewPos, + uTransmittanceLut: shared.uTransmittanceLut, + }); + this.skyViewPass = new SkyPass('sky-view', SKYVIEW_FRAG, { + uMieScale: shared.uMieScale, + uViewPos: shared.uViewPos, + uTransmittanceLut: shared.uTransmittanceLut, + uMultiScatterLut: shared.uMultiScatterLut, + uSunIrradiance: shared.uSunIrradiance, + uMoonIrradiance: shared.uMoonIrradiance, + uSunAltitude: shared.uSunAltitude, + uMoonAltitude: shared.uMoonAltitude, + uMoonRelAz: shared.uMoonRelAz, + }); + this.ambientPass = new SkyPass('sky-ambient', AMBIENT_FRAG, { + uViewPos: shared.uViewPos, + uMieScale: shared.uMieScale, + uSkyViewLut: shared.uSkyViewLut, + uSunAltitude: shared.uSunAltitude, + }); + } + + /** Altitude/aerosol dependent only — baked at boot, and if turbidity changes. */ + bakeStatic() { + this.transmittancePass.render(this.renderer, this.transmittanceRt); + this.multiScatterPass.render(this.renderer, this.multiScatterRt); + } + + /** Sun/moon dependent. ~0.4 ms; called only when the sun has actually moved. */ + bakeSkyView() { + this.skyViewPass.render(this.renderer, this.skyViewRt); + this.ambientPass.render(this.renderer, this.ambientRt); + } + + dispose() { + this.transmittanceRt.dispose(); + this.multiScatterRt.dispose(); + this.skyViewRt.dispose(); + this.ambientRt.dispose(); + this.transmittancePass.dispose(); + this.multiScatterPass.dispose(); + this.skyViewPass.dispose(); + this.ambientPass.dispose(); + } +} + +export { ATMO }; diff --git a/src/lib/cod/sky/noise.js b/src/lib/cod/sky/noise.js new file mode 100644 index 00000000..b2017559 --- /dev/null +++ b/src/lib/cod/sky/noise.js @@ -0,0 +1,90 @@ +/** + * Procedural noise shared by the stars, the clouds and the fog. + * + * Kept in one chunk with an include guard so the dome (which needs all of it), + * the environment bake and the volumetric pass can each pull in whatever they + * reference without duplicating a single line of GLSL — and, more importantly, + * without the clouds in the sky disagreeing with the cloud shadows in the fog. + */ +export const NOISE_GLSL = /* glsl */ ` +#ifndef SKY_NOISE +#define SKY_NOISE + +float skHash12( vec2 p ) { + vec3 p3 = fract( vec3( p.xyx ) * 0.1031 ); + p3 += dot( p3, p3.yzx + 33.33 ); + return fract( ( p3.x + p3.y ) * p3.z ); +} + +float skHash13( vec3 p ) { + p = fract( p * 0.1031 ); + p += dot( p, p.yzx + 33.33 ); + return fract( ( p.x + p.y ) * p.z ); +} + +vec3 skHash33( vec3 p ) { + p = fract( p * vec3( 0.1031, 0.11369, 0.13787 ) ); + p += dot( p, p.yxz + 19.19 ); + return fract( vec3( ( p.x + p.y ) * p.z, ( p.x + p.z ) * p.y, ( p.y + p.z ) * p.x ) ); +} + +/** Interleaved gradient noise (Jimenez) — the right dither for a raymarch. */ +float skIGN( vec2 p ) { + return fract( 52.9829189 * fract( dot( p, vec2( 0.06711056, 0.00583715 ) ) ) ); +} + +float skVal2( vec2 p ) { + vec2 i = floor( p ), f = fract( p ); + f = f * f * ( 3.0 - 2.0 * f ); + return mix( mix( skHash12( i ), skHash12( i + vec2( 1, 0 ) ), f.x ), + mix( skHash12( i + vec2( 0, 1 ) ), skHash12( i + vec2( 1, 1 ) ), f.x ), f.y ); +} + +float skVal3( vec3 p ) { + vec3 i = floor( p ), f = fract( p ); + f = f * f * ( 3.0 - 2.0 * f ); + return mix( + mix( mix( skHash13( i + vec3( 0, 0, 0 ) ), skHash13( i + vec3( 1, 0, 0 ) ), f.x ), + mix( skHash13( i + vec3( 0, 1, 0 ) ), skHash13( i + vec3( 1, 1, 0 ) ), f.x ), f.y ), + mix( mix( skHash13( i + vec3( 0, 0, 1 ) ), skHash13( i + vec3( 1, 0, 1 ) ), f.x ), + mix( skHash13( i + vec3( 0, 1, 1 ) ), skHash13( i + vec3( 1, 1, 1 ) ), f.x ), f.y ), f.z ); +} + +const mat2 SK_ROT = mat2( 0.8, 0.6, -0.6, 0.8 ); + +float skFbm2( vec2 p, int oct ) { + float a = 0.5, s = 0.0, n = 0.0; + for ( int i = 0; i < oct; i ++ ) { + s += a * skVal2( p ); + n += a; + p = SK_ROT * p * 2.04 + 7.13; + a *= 0.5; + } + return s / max( n, 1e-4 ); +} + +/** Ridged variant — fibrous cirrus streaks and wind-torn fog wisps. */ +float skRidge2( vec2 p, int oct ) { + float a = 0.5, s = 0.0, n = 0.0; + for ( int i = 0; i < oct; i ++ ) { + s += a * ( 1.0 - abs( skVal2( p ) * 2.0 - 1.0 ) ); + n += a; + p = SK_ROT * p * 2.11 + 3.71; + a *= 0.52; + } + return s / max( n, 1e-4 ); +} + +float skFbm3( vec3 p, int oct ) { + float a = 0.5, s = 0.0, n = 0.0; + for ( int i = 0; i < oct; i ++ ) { + s += a * skVal3( p ); + n += a; + p = p * 2.07 + vec3( 11.3, 5.1, 7.7 ); + a *= 0.5; + } + return s / max( n, 1e-4 ); +} + +#endif +`; diff --git a/src/lib/cod/sky/stars.js b/src/lib/cod/sky/stars.js new file mode 100644 index 00000000..db01727c --- /dev/null +++ b/src/lib/cod/sky/stars.js @@ -0,0 +1,164 @@ +/** + * Night sky: a real starfield and a Milky Way band, both procedural. + * + * "White dots on black" is the tell of a WebGL demo, so: + * - stars are drawn from three density layers with a magnitude power law, so + * a handful are conspicuous and thousands are barely there; + * - each star gets a blackbody colour temperature (2600 K red giants through + * 22000 K B-type blue) with the *luminance normalised out*, so temperature + * and magnitude are independent, exactly as in a real catalogue; + * - brightness is attenuated by Kasten-Young airmass, so the sky loses stars + * toward the horizon instead of ending in a hard line; + * - scintillation is airmass-weighted: stars twinkle low down and sit still + * overhead, which is the single most convincing detail; + * - the Milky Way is a warm bulge and a cool disc separated by fbm dust + * lanes, with extra unresolved star density inside the band. + * + * Radiance is in the same scene units as everything else, so on a bright + * afternoon the stars are simply four orders of magnitude below the sky and + * vanish on their own — there is no "hide the stars in daytime" switch. + */ +export const STARS_GLSL = /* glsl */ ` +#ifndef SKY_STARS +#define SKY_STARS + +uniform vec4 uStarParams; // x brightness, y twinkle amount, z time, w milkyway gain +uniform mat3 uCelestial; // equatorial -> world; rotates the sky with the day + +/** + * How much of the blackbody hue survives. + * + * A star is a point source a couple of pixels wide, and a saturated point is not + * read as "a red giant", it is read as a stuck pixel or a dead sub-pixel on the + * monitor — which is exactly what the red-orange dots in the night frame were. + * Real naked-eye stars are close to white: even Betelgeuse is only a few percent + * off neutral once the eye has adapted. 0.11 holds the worst case (2600 K, whose + * normalised primaries are roughly 1.6 / 0.7 / 0.25) under 0.15 HSV saturation, + * which still tints a field of a thousand stars visibly without any of them + * reading as a coloured defect. + */ +const float SK_STAR_TINT = 0.11; + +/** Tanner Helland's blackbody fit, moved to linear light and normalised. */ +vec3 skBlackbody( float kelvin ) { + float t = clamp( kelvin, 1200.0, 40000.0 ) / 100.0; + float r, g, b; + if ( t <= 66.0 ) r = 1.0; + else r = clamp( 1.29293619 * pow( t - 60.0, -0.13320476 ), 0.0, 1.0 ); + if ( t <= 66.0 ) g = clamp( 0.39008158 * log( t ) - 0.63184144, 0.0, 1.0 ); + else g = clamp( 1.12989086 * pow( t - 60.0, -0.07551485 ), 0.0, 1.0 ); + if ( t >= 66.0 ) b = 1.0; + else if ( t <= 19.0 ) b = 0.0; + else b = clamp( 0.54320679 * log( t - 10.0 ) - 1.19625409, 0.0, 1.0 ); + vec3 c = pow( vec3( r, g, b ), vec3( 2.2 ) ); + return c / max( 1e-4, dot( c, vec3( 0.2126, 0.7152, 0.0722 ) ) ); +} + +/** Kasten-Young relative airmass. 1 overhead, ~38 at the horizon. */ +float skAirmass( float cosZenith ) { + float z = degrees( acos( clamp( cosZenith, -1.0, 1.0 ) ) ); + return 1.0 / ( max( cosZenith, 0.0 ) + 0.50572 * pow( max( 0.0, 96.07995 - z ), -1.6364 ) ); +} + +/** + * One star per grid cell of a 3D lattice sampled on the unit sphere. Because + * |dir * N| == N exactly, only one radial shell of cells is ever visited, so a + * single hash gives a stable star per direction with no neighbour search. + */ +vec3 skStarLayer( vec3 dir, float N, float keep, float gain, float seed, + float sigma, float twinkle, float band ) { + vec3 cell = floor( dir * N ) + seed; + vec3 h = skHash33( cell ); + float exist = step( 1.0 - keep, h.x ); + if ( exist < 0.5 ) return vec3( 0.0 ); + + vec3 h2 = skHash33( cell + 91.7 ); + vec3 starDir = normalize( floor( dir * N ) + 0.5 + ( h2 - 0.5 ) * 0.94 ); + + // sin(separation) — cheaper and better conditioned than acos near zero. + float d = length( cross( dir, starDir ) ); + + // Magnitude power law: most cells hold something you would never notice. + float mag = pow( h.y, 5.5 ); + float flux = gain * ( mag + 0.0016 ) * ( 1.0 + band * 1.4 ); + + // Core plus a faint diffraction skirt; the skirt is what makes the bright + // ones read as stars rather than as dead pixels once bloom gets to them. + float core = exp( -( d * d ) / ( sigma * sigma ) ); + float skirt = 0.055 * exp( -d / ( sigma * 3.4 ) ); + + float tw = 1.0 + twinkle * ( sin( uStarParams.z * ( 7.0 + 19.0 * h.z ) + h2.x * 43.0 ) + + 0.6 * sin( uStarParams.z * ( 23.0 + 31.0 * h2.y ) ) ); + float kelvin = mix( 2600.0, 22000.0, pow( h2.z, 1.9 ) ); + // Normalised blackbody, pulled back toward white — see SK_STAR_TINT. + vec3 tint = mix( vec3( 1.0 ), skBlackbody( kelvin ), SK_STAR_TINT ); + return tint * ( flux * ( core + skirt ) * max( 0.0, tw ) ); +} + +/** Galactic plane: pole and centre direction, in the equatorial frame. */ +const vec3 SK_GAL_POLE = vec3( -0.4288, 0.7146, 0.5522 ); +const vec3 SK_GAL_CORE = vec3( 0.7549, -0.2154, -0.6194 ); + +vec3 skMilkyWay( vec3 eq, float gain, int oct ) { + float lat = dot( eq, SK_GAL_POLE ); + // Two nested bands: a tight bright spine inside a broad halo. + float spine = exp( -pow( abs( lat ) / 0.048, 1.55 ) ); + float halo = exp( -pow( abs( lat ) / 0.165, 1.30 ) ); + float band = clamp( 0.78 * spine + 0.48 * halo, 0.0, 1.4 ); + if ( band < 0.002 ) return vec3( 0.0 ); + + float toCore = dot( eq, SK_GAL_CORE ); + float bulge = exp( -pow( max( 0.0, 1.0 - toCore ) / 0.22, 1.1 ) ); + + vec3 q = eq * 9.0; + float clumps = skFbm3( q, oct ); + // Dust lanes: a second, sharper field subtracted, biased to the spine. + float dust = skFbm3( eq * 21.0 + 3.7, max( 2, oct - 1 ) ); + float lane = smoothstep( 0.36, 0.68, dust ) * spine; + + // High clump contrast is what separates a galaxy from a painted stripe: the + // real band is mostly gaps, with a few very bright knots of unresolved stars. + float density = band * ( 0.20 + 1.35 * clumps * clumps ) * ( 1.0 - 0.80 * lane ); + density *= 1.0 + 2.6 * bulge; + + // Warm toward the obscured core, cool blue-white in the outer arms. + vec3 tint = mix( vec3( 0.72, 0.80, 1.06 ), vec3( 1.10, 0.86, 0.62 ), bulge * 0.85 ); + return tint * ( density * gain ); +} + +/** + * Full night sky in scene radiance units. + * dir is a world-space direction; uCelestial takes it to the fixed sky. + */ +vec3 skNightSky( vec3 dir, int mwOctaves, bool points ) { + vec3 eq = uCelestial * dir; + float am = skAirmass( dir.y ); + // Extinction ~0.16 mag/airmass in V, plus the horizon murk of a real city. + float ext = exp( -0.145 * am ) * smoothstep( -0.03, 0.10, dir.y ); + + float mw = clamp( dot( eq, SK_GAL_POLE ), -1.0, 1.0 ); + float band = exp( -pow( abs( mw ) / 0.16, 1.4 ) ); + + vec3 col = skMilkyWay( eq, uStarParams.w, mwOctaves ); + + if ( points ) { + float tw = uStarParams.y * clamp( ( am - 1.0 ) * 0.16, 0.0, 0.85 ); + // sigma is the Gaussian core radius in radians. At a 75-degree vertical fov + // over 1080 lines one pixel is 1.2e-3 rad, so anything under that lands + // inside a single pixel — and a single bright pixel is a stuck pixel, not a + // star. Every layer is now at least a pixel and a half across, which is also + // what lets TAA and the bloom prefilter treat it as image content instead of + // as a firefly to clamp away. + col += skStarLayer( eq, 21.0, 0.30, 1.00, 0.0, 0.00165, tw, band ); + col += skStarLayer( eq, 43.0, 0.20, 0.34, 13.0, 0.00145, tw, band ); + col += skStarLayer( eq, 87.0, 0.10, 0.11, 47.0, 0.00125, tw * 0.5, band * 2.2 ); + } + + // Airglow: real, faint, and greenish — it keeps the "empty" sky off zero. + col += vec3( 0.55, 1.0, 0.78 ) * 0.00030; + + return col * ( uStarParams.x * ext ); +} + +#endif +`; diff --git a/src/lib/cod/springs.js b/src/lib/cod/springs.js new file mode 100644 index 00000000..9ef8e24c --- /dev/null +++ b/src/lib/cod/springs.js @@ -0,0 +1,177 @@ +/** + * Scalar maths + spring integrators used by the player controller. + * + * Everything here is allocation-free after construction and framerate + * independent: the springs sub-step internally so a 8 ms physics tick and a + * 33 ms hitch produce the same visible motion. + */ + +export const TAU = Math.PI * 2; +export const DEG = Math.PI / 180; + +export function clamp(v, a, b) { + return v < a ? a : v > b ? b : v; +} + +export function clamp01(v) { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +export function lerp(a, b, t) { + return a + (b - a) * t; +} + +export function smoothstep(t) { + t = clamp01(t); + return t * t * (3 - 2 * t); +} + +/** C2-continuous ease — used for rooted mantle curves where velocity must not pop. */ +export function smootherstep(t) { + t = clamp01(t); + return t * t * t * (t * (t * 6 - 15) + 10); +} + +export function easeOutCubic(t) { + t = clamp01(t); + const u = 1 - t; + return 1 - u * u * u; +} + +export function easeInOutSine(t) { + return 0.5 - 0.5 * Math.cos(clamp01(t) * Math.PI); +} + +/** + * Exponential approach with a real time constant. `tau` is the 63 % time, so + * "reach it in about a tenth of a second" is tau = 0.1 / 2.3. + */ +export function approach(current, target, tau, dt) { + if (tau <= 1e-6) return target; + return target + (current - target) * Math.exp(-dt / tau); +} + +/** Constant-rate move, for things that must not have an asymptotic tail. */ +export function moveToward(current, target, rate, dt) { + const d = target - current; + const step = rate * dt; + if (d > step) return current + step; + if (d < -step) return current - step; + return target; +} + +/** Shortest signed angular difference, radians. */ +export function angleDelta(from, to) { + let d = (to - from) % TAU; + if (d > Math.PI) d -= TAU; + else if (d < -Math.PI) d += TAU; + return d; +} + +/** Deterministic value noise in 1D — camera shake without touching any RNG. */ +export function hashNoise(x, seed = 0) { + const xi = Math.floor(x); + const f = x - xi; + const h = (i) => { + let n = (i | 0) ^ (seed * 374761393); + n = Math.imul(n ^ (n >>> 15), 0x2c1b3c6d); + n = Math.imul(n ^ (n >>> 12), 0x297a2d39); + n ^= n >>> 15; + return ((n >>> 0) / 4294967296) * 2 - 1; + }; + const u = f * f * (3 - 2 * f); + return h(xi) * (1 - u) + h(xi + 1) * u; +} + +const MAX_SUB_DT = 1 / 360; + +/** + * Damped harmonic oscillator, driven by frequency (Hz) and damping ratio. + * zeta < 1 under-damped, overshoots — good for punchy recoil + * zeta = 1 critically damped, fastest non-overshooting — good for FOV/ADS + * `impulse()` injects velocity (the physical way to kick a spring), `set()` + * displaces it instantly. + */ +export class Spring { + constructor(freq = 8, damping = 0.7, value = 0) { + this.freq = freq; + this.damping = damping; + this.value = value; + this.velocity = 0; + this.target = 0; + } + + reset(value = 0) { + this.value = value; + this.velocity = 0; + return this; + } + + impulse(v) { + this.velocity += v; + return this; + } + + set(v) { + this.value = v; + return this; + } + + step(dt) { + if (dt <= 0) return this.value; + const w = TAU * this.freq; + const k = w * w; + const c = 2 * this.damping * w; + // Sub-step so a stiff spring stays stable through a dropped frame. + let remaining = dt; + let guard = 0; + while (remaining > 1e-7 && guard++ < 24) { + const h = remaining > MAX_SUB_DT ? MAX_SUB_DT : remaining; + remaining -= h; + const a = -k * (this.value - this.target) - c * this.velocity; + this.velocity += a * h; + this.value += this.velocity * h; + } + // Kill denormal ringing so idle frames are bit-stable for capture. + if (Math.abs(this.value - this.target) < 1e-7 && Math.abs(this.velocity) < 1e-6) { + this.value = this.target; + this.velocity = 0; + } + return this.value; + } +} + +/** + * Two-layer response: a fast under-damped spring plus a slow exponential + * residual. Real weapon/camera recoil rises instantly, snaps most of the way + * back, then settles — a single spring can only do two of those three. + */ +export class RecoilAxis { + constructor(freq = 9.5, damping = 0.52, residualTau = 0.3, residualShare = 0.34) { + this.spring = new Spring(freq, damping, 0); + this.residual = 0; + this.residualTau = residualTau; + this.residualShare = residualShare; + this.value = 0; + } + + reset() { + this.spring.reset(0); + this.residual = 0; + this.value = 0; + } + + /** `amount` is an angle in radians (or metres for a positional axis). */ + kick(amount) { + // A displacement kick reads snappier than a velocity kick for recoil. + this.spring.value += amount * (1 - this.residualShare); + this.residual += amount * this.residualShare; + } + + step(dt) { + this.spring.step(dt); + this.residual = approach(this.residual, 0, this.residualTau, dt); + this.value = this.spring.value + this.residual; + return this.value; + } +} diff --git a/src/lib/cod/surfaces.js b/src/lib/cod/surfaces.js new file mode 100644 index 00000000..c2028f90 --- /dev/null +++ b/src/lib/cod/surfaces.js @@ -0,0 +1,143 @@ +/** + * Surface & layer vocabulary. + * + * The twelve surface names are fixed by ARCHITECTURE.md — impact FX, decals, + * footstep audio and this system all key off the same enum. Physics stores the + * *index* per triangle (one byte) and hands the *name* back to callers, so + * nobody outside this directory has to care about the packing. + * + * The numbers below are game-tuned but physically motivated: penetration depth + * is roughly how much of the material a 7.62 x 51 round will defeat, friction + * is a dry kinetic coefficient, restitution is measured drop-bounce. + */ + +export const SURFACE_NAMES = [ + 'concrete', + 'metal', + 'wood', + 'dirt', + 'sand', + 'glass', + 'water', + 'foliage', + 'fabric', + 'flesh', + 'rubber', + 'plaster', +]; + +export const SURFACE = /** @type {Record} */ ({}); +for (let i = 0; i < SURFACE_NAMES.length; i++) SURFACE[SURFACE_NAMES[i]] = i; + +/** + * Per-surface physical response. + * + * penDepth metres of material a reference round (power 1.0) fully defeats. + * energyLoss fraction of remaining damage lost per penDepth traversed. + * deflect radians of random yaw/pitch scatter per penDepth traversed. + * friction dry kinetic coefficient (rigid bodies, ragdolls, footing). + * restitution bounce factor for debris. + * density kg/m^3, used for the impulse a body of unknown mass receives. + * hardness 0..1 — spark/chip likelihood, drives fx choice. + * shatters the surface breaks rather than absorbs (glass). + */ +export const SURFACE_PROPS = [ + // concrete + { penDepth: 0.055, energyLoss: 0.62, deflect: 0.055, friction: 0.92, restitution: 0.26, density: 2400, hardness: 0.95, shatters: false }, + // metal (structural steel / vehicle panel) + { penDepth: 0.022, energyLoss: 0.7, deflect: 0.075, friction: 0.52, restitution: 0.44, density: 7800, hardness: 1.0, shatters: false }, + // wood + { penDepth: 0.32, energyLoss: 0.3, deflect: 0.03, friction: 0.72, restitution: 0.3, density: 620, hardness: 0.4, shatters: false }, + // dirt + { penDepth: 0.26, energyLoss: 0.45, deflect: 0.05, friction: 0.96, restitution: 0.09, density: 1500, hardness: 0.2, shatters: false }, + // sand + { penDepth: 0.19, energyLoss: 0.55, deflect: 0.06, friction: 1.05, restitution: 0.04, density: 1600, hardness: 0.12, shatters: false }, + // glass + { penDepth: 0.45, energyLoss: 0.12, deflect: 0.012, friction: 0.32, restitution: 0.2, density: 2500, hardness: 0.85, shatters: true }, + // water + { penDepth: 1.1, energyLoss: 0.5, deflect: 0.09, friction: 0.3, restitution: 0.0, density: 1000, hardness: 0.0, shatters: false }, + // foliage + { penDepth: 3.0, energyLoss: 0.05, deflect: 0.008, friction: 0.62, restitution: 0.06, density: 300, hardness: 0.05, shatters: false }, + // fabric + { penDepth: 2.2, energyLoss: 0.06, deflect: 0.01, friction: 0.8, restitution: 0.05, density: 400, hardness: 0.02, shatters: false }, + // flesh + { penDepth: 0.55, energyLoss: 0.35, deflect: 0.02, friction: 0.9, restitution: 0.05, density: 1050, hardness: 0.05, shatters: false }, + // rubber + { penDepth: 0.28, energyLoss: 0.4, deflect: 0.04, friction: 1.25, restitution: 0.72, density: 1200, hardness: 0.1, shatters: false }, + // plaster / drywall + { penDepth: 0.7, energyLoss: 0.12, deflect: 0.02, friction: 0.86, restitution: 0.14, density: 800, hardness: 0.25, shatters: false }, +]; + +/** Resolve a surface name (or index, or undefined) to a valid index. */ +export function surfaceIndex(s, fallback = SURFACE.concrete) { + if (typeof s === 'number') return s >= 0 && s < SURFACE_NAMES.length ? s | 0 : fallback; + if (typeof s === 'string') { + const i = SURFACE[s]; + if (i !== undefined) return i; + return guessSurface(s, fallback); + } + return fallback; +} + +const GUESS = [ + [/concrete|cement|stone|brick|rock|asphalt|tarmac|road|kerb|curb|marble|tile/i, SURFACE.concrete], + [/metal|steel|iron|alu|aluminium|aluminum|tin|pipe|rail|grate|vent|car|vehicle|chassis|barrel|drum|sign/i, SURFACE.metal], + [/wood|timber|plank|crate|pallet|door|plywood|fence|log|furnit/i, SURFACE.wood], + [/dirt|mud|soil|earth|ground|terrain|gravel|rubble/i, SURFACE.dirt], + [/sand|dune|beach/i, SURFACE.sand], + [/glass|window|mirror|screen|pane/i, SURFACE.glass], + [/water|pool|puddle|liquid/i, SURFACE.water], + [/foliage|leaf|leaves|bush|tree|grass|plant|hedge|shrub/i, SURFACE.foliage], + [/fabric|cloth|canvas|tarp|curtain|carpet|rug|sofa|awning/i, SURFACE.fabric], + [/flesh|body|skin|head|torso|limb|enemy|actor|char/i, SURFACE.flesh], + [/rubber|tyre|tire|hose|mat/i, SURFACE.rubber], + [/plaster|drywall|gypsum|stucco|wall|ceiling|partition/i, SURFACE.plaster], +]; + +/** Best-effort surface inference from a mesh/material name. */ +export function guessSurface(name, fallback = SURFACE.concrete) { + if (!name) return fallback; + for (let i = 0; i < GUESS.length; i++) if (GUESS[i][0].test(name)) return GUESS[i][1]; + return fallback; +} + +export function surfaceName(i) { + return SURFACE_NAMES[i] ?? 'concrete'; +} + +/* ------------------------------------------------------------------ */ +/* Collision layers */ +/* ------------------------------------------------------------------ */ + +export const LAYER = { + /** Immovable level geometry. */ STATIC: 1 << 0, + /** Static props — crates, cars. Same BVH, separate bit so AI can ignore. */ PROP: 1 << 1, + /** Simulated debris & dropped weapons. */ DEBRIS: 1 << 2, + /** Player capsule. */ PLAYER: 1 << 3, + /** AI character capsules / hitboxes. */ ACTOR: 1 << 4, + /** Ragdoll bones. */ RAGDOLL: 1 << 5, + /** Breakable glass — blocks bullets briefly, never blocks sight. */ GLASS: 1 << 6, + /** Water volumes. */ WATER: 1 << 7, + /** Invisible clip: stops characters, ignored by bullets and cameras. */ CLIP: 1 << 8, + /** Blocks bullets but not movement (grates, railings modelled thin). */ SHOOT_ONLY: 1 << 9, + /** Non-colliding trigger volume. */ TRIGGER: 1 << 10, + /** Foliage — no collision, deflects bullets barely, blocks nothing. */ FOLIAGE: 1 << 11, +}; + +export const MASK = { + ALL: 0xffff & ~LAYER.TRIGGER, + /** Everything a character capsule collides with. */ + CHARACTER: LAYER.STATIC | LAYER.PROP | LAYER.CLIP, + /** Everything a bullet can strike. */ + BULLET: + LAYER.STATIC | LAYER.PROP | LAYER.DEBRIS | LAYER.ACTOR | LAYER.RAGDOLL | + LAYER.GLASS | LAYER.SHOOT_ONLY | LAYER.FOLIAGE, + /** Static-only: camera collision, cover queries, decal projection. */ + WORLD: LAYER.STATIC | LAYER.PROP, + /** Line of sight — glass and foliage do not block vision. */ + SIGHT: LAYER.STATIC | LAYER.PROP | LAYER.DEBRIS, + /** What rigid debris bounces off. */ + DEBRIS: LAYER.STATIC | LAYER.PROP | LAYER.CLIP, + /** Explosion occlusion. */ + EXPLOSION: LAYER.STATIC | LAYER.PROP, +}; diff --git a/src/stage/Rig.ts b/src/stage/Rig.ts index 53d4c51d..9e1615bc 100644 --- a/src/stage/Rig.ts +++ b/src/stage/Rig.ts @@ -138,6 +138,13 @@ export class Rig { groundHeight: ((x: number, z: number) => number) | null; collide: ((pos: Vector3, r: number) => void) | null; + /** Walk-mode delegate: owns feet + stance, returns the eye position for the + * Rig to place the camera (rotation stays the Rig's). null → the legacy + * kinematic glide below. Lets an embodied physics controller drive Walk + * without CoD ever entering this generic/liftable file. */ + walkMove: + | ((dt: number, rig: Rig) => { x: number; y: number; z: number } | null) + | null; onCaption: | (( cap: RigWaypoint | null, @@ -240,6 +247,7 @@ export class Rig { // callbacks (app supplies) this.groundHeight = null; // (x,z) -> y this.collide = null; // (pos, r) -> mutate pos + this.walkMove = null; // walk-mode embodied delegate (EmbodiedController) this.onCaption = null; this.onModeInternal = null; @@ -576,6 +584,17 @@ export class Rig { } _walk(dt: number): void { + // Embodied delegate (EmbodiedController) owns feet + stance when injected; + // the Rig still owns look. A null return (meshes still loading) falls back to + // the legacy kinematic glide so Walk is never dead. + if (this.walkMove) { + const eye = this.walkMove(dt, this); + if (eye) { + this.cam.position.set(eye.x, eye.y, eye.z); + this.cam.rotation.set(this.pitch, this.yaw, 0, 'YXZ'); + return; + } + } this._driveAvatar(dt, this.yaw); const a = this.avatar; const eyeY = a.pos.y + this.o.eye; diff --git a/src/stage/__tests__/embodiedWalk.test.ts b/src/stage/__tests__/embodiedWalk.test.ts new file mode 100644 index 00000000..77d68ec2 --- /dev/null +++ b/src/stage/__tests__/embodiedWalk.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { chaseBackDir } from '../embodiedWalk'; + +// The camera's forward vector for cam.rotation.set(pitch, yaw, 0, 'YXZ') +// (see Rig._walk) — the reference the chase cam must sit OPPOSITE to. +function cameraForward(yaw: number, pitch: number) { + const cp = Math.cos(pitch); + return { x: -Math.sin(yaw) * cp, y: Math.sin(pitch), z: -Math.cos(yaw) * cp }; +} + +describe('chaseBackDir — third-person chase-cam placement', () => { + const cases: Array<[number, number]> = [ + [0, 0], + [Math.PI / 2, 0], + [Math.PI, 0], + [-Math.PI / 3, 0], + [0, -0.15], // default gaze (slightly down) + [1.2, -0.4], + [-2.0, 0.5], + ]; + + it('is always a unit vector (cameraDistance raycast needs a unit dir)', () => { + for (const [yaw, pitch] of cases) { + const b = chaseBackDir(yaw, pitch); + expect(Math.hypot(b.x, b.y, b.z)).toBeCloseTo(1, 6); + } + }); + + it('is exactly the negation of the camera forward → player stays centred', () => { + // camera = eye + back·chaseBackDir sits behind the player; the player then + // lies at distance `back` straight along the camera forward = screen centre. + for (const [yaw, pitch] of cases) { + const f = cameraForward(yaw, pitch); + const b = chaseBackDir(yaw, pitch); + expect(b.x).toBeCloseTo(-f.x, 6); + expect(b.y).toBeCloseTo(-f.y, 6); + expect(b.z).toBeCloseTo(-f.z, 6); + } + }); + + it('at level gaze sits directly behind at eye height (0,0,1 for yaw 0)', () => { + const b = chaseBackDir(0, 0); + expect(b.x).toBeCloseTo(0, 6); + expect(b.y).toBeCloseTo(0, 6); + expect(b.z).toBeCloseTo(1, 6); + }); + + it('looking DOWN lifts the camera (positive y) so it looks down at the body', () => { + const b = chaseBackDir(0, -0.5); // pitch negative = looking down + expect(b.y).toBeGreaterThan(0); + }); +}); diff --git a/src/stage/embodiedWalk.ts b/src/stage/embodiedWalk.ts new file mode 100644 index 00000000..202c2151 --- /dev/null +++ b/src/stage/embodiedWalk.ts @@ -0,0 +1,143 @@ +// Bridges the twin Rig's Walk mode to a CoD `EmbodiedController`: maps the Rig's +// key/yaw state (`e.code` convention) to the controller's normalized input, steps +// the physics body, folds head-bob + landing-punch into the returned eye +// position, and fires footstep audio + dust. R3F/hook wiring stays in the +// composition root — this is the pure glue installed as `rig.walkMove`. + +import type { EmbodiedController, Vec3Like } from '@/lib/cod'; +import type { Rig } from './Rig'; + +/** Camera view while riding the bike. */ +export type BikeView = 'first' | 'third'; + +/** + * Unit "backward" direction for the third-person chase cam: the negation of the + * camera's forward vector for a YXZ (pitch-about-X, yaw-about-Y) rotation. Placing + * the camera at `eye + back · chaseBackDir` puts the player exactly on the view + * axis, so the body/bike stays centred in frame whatever the aim — the fix for the + * chase cam losing the rider. Always unit length (cos²+sin² = 1), as the world + * raycast (`cameraDistance`) requires. Pure + exported for a regression test. + */ +export function chaseBackDir( + yaw: number, + pitch: number +): { x: number; y: number; z: number } { + const cp = Math.cos(pitch); + return { x: Math.sin(yaw) * cp, y: -Math.sin(pitch), z: Math.cos(yaw) * cp }; +} + +export interface WalkFeelDeps { + /** Shared first/third-person toggle (flipped by V while riding). */ + viewRef: { current: BikeView }; + /** Footstep cadence + audio; returns true on a step (→ dust puff). */ + stepAudio: ( + dist: number, + grounded: boolean, + surface: string, + gait: string + ) => boolean; + emitDust: ( + x: number, + y: number, + z: number, + surface: string, + intensity: number + ) => void; + tickDust: (dt: number) => void; + /** Adds head-bob + landing dip onto `camera.position` in place. */ + applyCameraFeel: ( + camera: { position: Vec3Like }, + cc: { grounded: boolean; landingSpeed: number }, + moved: number, + dt: number, + yaw: number, + bobScale?: number + ) => void; +} + +/** + * Build the `Rig.walkMove` delegate for an `EmbodiedController`. The Rig owns + * look (yaw/pitch); this owns the feet + stance and returns the eye position for + * the Rig to place the camera. + */ +export function makeWalkMove( + ctrl: EmbodiedController, + deps: WalkFeelDeps +): (dt: number, rig: Rig) => Vec3Like { + // Persistent scratch — no per-frame allocation. + const eye = { position: { x: 0, y: 0, z: 0 } }; + let prevV = false; + let prevFacing: number | null = null; // last bike heading, for the steer→view delta + return (dt, rig) => { + ctrl.setInput({ + forward: (rig.down('KeyW') ? 1 : 0) - (rig.down('KeyS') ? 1 : 0), + right: (rig.down('KeyD') ? 1 : 0) - (rig.down('KeyA') ? 1 : 0), + jump: rig.down('Space'), + sprint: rig.down('ShiftLeft') || rig.down('ShiftRight'), + crouch: rig.down('KeyC'), + prone: rig.down('KeyX'), + mount: rig.down('KeyB'), // B toggles the bike + yaw: rig.yaw, + }); + ctrl.step(dt); + + // V toggles first/third-person — on foot AND on the bike — so you can watch + // your character walk, crouch and lie down, not just ride. + const vDown = rig.down('KeyV'); + if (vDown && !prevV) { + deps.viewRef.current = deps.viewRef.current === 'first' ? 'third' : 'first'; + } + prevV = vDown; + + // While riding, steering nudges the view by the SAME delta the bike heading + // turns — added to the Rig's yaw, not replacing it — so A/D turns what you see + // AND the mouse can still swivel your head freely on top. (Hard-setting + // yaw = heading is what locked the head in place.) On foot, look is entirely + // the Rig's own mouse yaw — untouched. + if (ctrl.riding) { + if (prevFacing === null) prevFacing = ctrl.facingYaw; + rig.yaw += ctrl.facingYaw - prevFacing; + prevFacing = ctrl.facingYaw; + } else { + prevFacing = null; + } + + ctrl.eyePosition(eye.position); + if (deps.viewRef.current === 'third') { + // Chase cam: sit `back` metres behind the player ALONG THE CURRENT VIEW + // DIRECTION (the Rig's pitch+yaw, which is what actually orients the + // camera). Placing the camera at −forward·back puts the player exactly on + // the view axis, so the body/bike stays CENTRED no matter where you aim — + // the old fixed rear+up offset let it fall out of frame (bug: "3rd person + // has no view of the bike"). Raycast the pull-back so the camera tucks in + // against a wall/building behind you instead of clipping through it. + const back = 6; + const ex = eye.position.x; + const ey = eye.position.y; + const ez = eye.position.z; + const b = chaseBackDir(rig.yaw, rig.pitch); + const allowed = ctrl.cameraDistance(ex, ey, ez, b.x, b.y, b.z, back, 0.35); + eye.position.x = ex + b.x * allowed; + eye.position.y = ey + b.y * allowed; + eye.position.z = ez + b.z * allowed; + } else { + // First-person / on-foot: fold head-bob + landing punch into the eye. + deps.applyCameraFeel(eye, ctrl, ctrl.movedThisFrame, dt, rig.yaw, ctrl.bobScale); + } + // No footfalls while rolling on the bike. + if (!ctrl.riding) { + const stepped = deps.stepAudio( + ctrl.movedThisFrame, + ctrl.grounded, + ctrl.groundSurfaceName, + ctrl.gait + ); + if (stepped) { + const p = ctrl.position; + deps.emitDust(p.x, p.y, p.z, ctrl.groundSurfaceName, ctrl.dustScale); + } + } + deps.tickDust(dt); + return eye.position; + }; +} diff --git a/src/stage/materialKit.ts b/src/stage/materialKit.ts index 185bb510..50268363 100644 --- a/src/stage/materialKit.ts +++ b/src/stage/materialKit.ts @@ -9,8 +9,13 @@ export const materialKit = { ...opts, }); }, - drapedGround(texture: Texture) { + drapedGround(texture: Texture, anisotropy = 1) { texture.colorSpace = SRGBColorSpace; + // Anisotropic filtering keeps the aerial imagery sharp at the grazing angles + // you see at street level (default 1 = a smeared mip). Caller passes the + // renderer's max. + texture.anisotropy = Math.max(texture.anisotropy, anisotropy); + texture.needsUpdate = true; return new MeshStandardMaterial({ map: texture, roughness: 1, diff --git a/src/twin/TwinCanvas.client.tsx b/src/twin/TwinCanvas.client.tsx index aa0cc793..5377ba94 100644 --- a/src/twin/TwinCanvas.client.tsx +++ b/src/twin/TwinCanvas.client.tsx @@ -12,13 +12,35 @@ import { LinearSRGBColorSpace, type DirectionalLight, type Group, + type Mesh, type Vector3, } from 'three'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import StageCore, { StageHandle } from '@/stage/StageCore'; import { Rig, RigMode, RigWaypoint } from '@/stage/Rig'; +import { + EmbodiedController, + useFootsteps, + useAmbientCity, + useFootstepDust, + useCameraFeel, + useWeather, + type WeatherKind, + bus, +} from '@/lib/cod'; +import { makeWalkMove, type BikeView } from '@/stage/embodiedWalk'; import TwinWorld from '@/world/TwinWorld'; import Trolley from '@/agents/trolley'; +import Bike from '@/agents/bike'; +import PlayerCharacter from '@/agents/playerCharacter'; +import ProceduralSky from '@/components/game/ProceduralSky'; import Hud, { HudCaption, HudLink, HudOption, HudSlider } from '@/stage/Hud'; import PlacementEditor from './PlacementEditor'; import SelectedBuildingCard from './SelectedBuildingCard'; @@ -85,7 +107,8 @@ export function modesForSite( const MODE_HINTS: Partial> = { walk: { name: 'Walk', - blurb: 'Click the scene to look around · WASD to move · Shift to sprint', + blurb: + 'Click to look · WASD · Shift sprint · B bike · V view · C crouch · X prone · Space jump', }, follow: { name: 'Ride', @@ -115,6 +138,22 @@ export function parseOrthoParam(search: string): { }; } +/** `?hour=N` (0–24) sets the procedural-sky time of day; default 13 (bright + * afternoon). ONLY the sky dome follows it — the Walk key lighting stays fixed + * and bright, so the street reads at any hour (no moody day-cycle recoupling, + * which is what darkened the scene before). Malformed → the default. */ +export function parseHourParam(search: string): number { + const v = new URLSearchParams(search).get('hour'); + if (v == null || v.trim() === '') return 13; // absent or bare `?hour=` + const n = Number(v); + return Number.isFinite(n) ? Math.min(24, Math.max(0, n)) : 13; +} + +/** `?weather=rain` turns on ambient rain in Walk mode; anything else → none. */ +export function parseWeatherParam(search: string): WeatherKind { + return new URLSearchParams(search).get('weather') === 'rain' ? 'rain' : 'none'; +} + function SceneInner({ slug, manifest, @@ -135,6 +174,8 @@ function SceneInner({ registerHandle, registerRig, onFps, + onNearBike, + onWalkReady, warehouseModels, modelOverrides, selectedModel, @@ -169,6 +210,10 @@ function SceneInner({ registerRig?: (rig: Rig) => void; /** ~2 Hz averaged frame rate for the HUD counter (#259 perf work). */ onFps?: (fps: number) => void; + /** Walk mode: near/far transitions of the parked bike, for the ride prompt. */ + onNearBike?: (near: boolean) => void; + /** Fires once the embodied walk controller is built (deep-link gating). */ + onWalkReady?: () => void; warehouseModels?: WarehouseModelsInfo | null; modelOverrides?: Record; selectedModel?: string | null; @@ -253,15 +298,22 @@ function SceneInner({ // the lens after mount. useEffect(() => { const cam = camera as import('three').PerspectiveCamera; - cam.fov = PALETTES[paletteKey].fov; + // First-person needs a normal-lens fov; the "toy" palette's telephoto 34 + // is claustrophobic on foot. Wider only in Walk; palette fov otherwise. + cam.fov = mode === 'walk' ? 72 : PALETTES[paletteKey].fov; cam.updateProjectionMatrix(); - }, [camera, paletteKey]); + }, [camera, paletteKey, mode]); const d = useMemo(() => computeDay(day), [day]); - const grade = useMemo( - () => applyProfile(d.gradeBase, PALETTES[paletteKey]), - [d, paletteKey] - ); + const grade = useMemo(() => { + const g = applyProfile(d.gradeBase, PALETTES[paletteKey]); + // Walk mode drops the moody miniature grade (heavy vignette + boosted sepia + // saturation/contrast) for a bright, neutral street-level look, so the aerial + // ground + façades read true instead of crushed dark-brown. + return mode === 'walk' + ? { saturation: 1.02, contrast: 0.98, vignette: 0 } + : g; + }, [d, paletteKey, mode]); // Stable object identities: fresh literals here would re-trigger StageCore's // effects and rebuild the merged building geometry on every re-render (the // tour emits a caption every few seconds). @@ -274,6 +326,26 @@ function SceneInner({ }), [paletteKey, crisp] ); + // Time of day for the procedural sky (?hour=N, default 13). Read once — the + // URL param is a static override like ?walk/?house. Drives ONLY the sky dome. + const skyHour = useMemo( + () => + parseHourParam( + typeof window !== 'undefined' ? window.location.search : '' + ), + [] + ); + // Ambient weather (?weather=rain) — reuses the CoD GPU particle layer, only in + // Walk. tick() is a no-op when disabled, so the useFrame is free otherwise. + const weatherKind = useMemo( + () => + parseWeatherParam( + typeof window !== 'undefined' ? window.location.search : '' + ), + [] + ); + const weather = useWeather(mode === 'walk' ? weatherKind : 'none'); + useFrame((_, dt) => weather.tick(dt)); const bricks = useMemo( () => ({ bricks: PALETTES[paletteKey].bricks }), [paletteKey] @@ -397,6 +469,158 @@ function SceneInner({ (x: number, z: number) => groundAtRef.current?.(x, z) ?? 0, [] ); + + // ── Walk-mode embodied physics (#226) ────────────────────────────────────── + // The world hands over its terrain + buildings meshes; we bake BOTH into a CoD + // StaticWorld and let an EmbodiedController own the first-person avatar + // (gravity, jump, step-up, slopes, crouch/prone) in Walk mode. The Rig keeps + // look and calls `walkMove`, which returns the eye position. Feel/audio/dust + // come from the toolkit hooks (safe here — SceneInner is inside ). + const { resume: resumeAudio, step: stepAudio } = useFootsteps(); + // Looping procedural city bed (wind + distant traffic + birds), sitting UNDER + // the footsteps mix. Its own AudioContext; woken by the same gesture (below). + const { + resume: resumeAmbient, + start: startAmbient, + stop: stopAmbient, + } = useAmbientCity(); + const { emit: emitDust, tick: tickDust } = useFootstepDust(256); + const { apply: applyCameraFeel } = useCameraFeel(); + + const buildingsMeshRef = useRef(null); + const terrainMeshRef = useRef(null); + const walkCtrlRef = useRef(null); + const spawnRef = useRef<{ x: number; z: number } | null>(null); + const [walkCtrl, setWalkCtrl] = useState(null); + const viewRef = useRef('first'); // first/third-person while on the bike + + // Build the embodied controller once BOTH the terrain (floor) and buildings + // (walls) meshes have arrived. The terrain is the floor gravity needs. + const tryBuildWalk = useCallback(() => { + const buildings = buildingsMeshRef.current; + const terrain = terrainMeshRef.current; + if (!buildings || !terrain) return; + walkCtrlRef.current?.dispose(); + const ctrl = EmbodiedController.fromMeshes( + [ + { mesh: terrain, surface: 'dirt' }, + { mesh: buildings, surface: 'concrete' }, + ], + { onStanceChange: (s) => bus.emit('player:stance', { stance: s }) } + ); + // Spawn on a street: the embedded-twin location if known, else the framing + // home focus, at terrain height. teleport() depenetrates + probes ground. + const sx = spawnRef.current?.x ?? framing.homeFocus[0]; + const sz = spawnRef.current?.z ?? framing.homeFocus[2]; + const sy = groundAtRef.current?.(sx, sz) ?? 0; + ctrl.teleport(sx, sy, sz); + ctrl.parkBike(sx, sy, sz); // the bike starts parked at your feet + walkCtrlRef.current = ctrl; + setWalkCtrl(ctrl); + }, [framing]); + + const handleBuildingsMesh = useCallback( + (mesh: Mesh) => { + buildingsMeshRef.current = mesh; + tryBuildWalk(); + }, + [tryBuildWalk] + ); + const handleTerrainMesh = useCallback( + (mesh: Mesh) => { + terrainMeshRef.current = mesh; + tryBuildWalk(); + }, + [tryBuildWalk] + ); + // Record the embedded-twin street position as the walk spawn, then lift it up. + const handleTwinPlaced = useCallback( + (t: { x: number; z: number; label: string }) => { + spawnRef.current = { x: t.x, z: t.z }; + onTwinPlaced?.(t); + }, + [onTwinPlaced] + ); + + // Bind the Rig seams whenever the rig or the built controller changes. + // groundHeight (analytic sampler) serves follow mode; walkMove + collide serve + // the embodied walk. Cleared on rebind/unmount; the controller is disposed on + // rebuild (above) and on unmount (below). + useEffect(() => { + rig.groundHeight = (x, z) => groundAtRef.current?.(x, z) ?? 0; + if (walkCtrl) { + rig.walkMove = makeWalkMove(walkCtrl, { + stepAudio, + emitDust, + tickDust, + applyCameraFeel, + viewRef, + }); + rig.collide = (pos) => walkCtrl.collide(pos, 0.4); // unboarded-follow reuse + } + return () => { + rig.groundHeight = null; + rig.walkMove = null; + rig.collide = null; + }; + }, [rig, walkCtrl, stepAudio, emitDust, tickDust, applyCameraFeel]); + + // Dispose the physics world on unmount. + useEffect( + () => () => { + walkCtrlRef.current?.dispose(); + walkCtrlRef.current = null; + }, + [] + ); + + // Tell the composition root the embodied controller is live, so a `?walk` + // deep-link can enter Walk only now (not during the kinematic-glide window). + useEffect(() => { + if (walkCtrl) onWalkReady?.(); + }, [walkCtrl, onWalkReady]); + + // Resume Web Audio on the FIRST gesture in Walk — click OR keypress. The + // `?walk` deep-link ("Play") auto-enters Walk with no canvas click, so keydown + // (WASD) must also wake the AudioContext or footsteps stay silent. resume() is + // idempotent. + useEffect(() => { + if (mode !== 'walk') return; + const dom = gl.domElement; + const kick = () => { + resumeAudio(); + resumeAmbient(); + }; + dom.addEventListener('click', kick); + window.addEventListener('keydown', kick); + return () => { + dom.removeEventListener('click', kick); + window.removeEventListener('keydown', kick); + }; + }, [mode, gl, resumeAudio, resumeAmbient]); + + // Play the ambient city bed only while in Walk mode: build + start the loops on + // enter (they sound once the gesture above resumes the context), fade + stop on + // exit/unmount. Separate from the resume effect so the bed's lifecycle is tied + // to Walk, not to the first gesture. + useEffect(() => { + if (mode !== 'walk') return; + startAmbient(); + return () => stopAmbient(); + }, [mode, startAmbient, stopAmbient]); + + // First-person needs a tiny near plane. The twin's default (framing.cameraNear + // ≈ 5 m, tuned for the miniature orbit) sits FARTHER than the ground under your + // feet, so Walk mode clips straight through the terrain. Shrink it while + // walking; restore on exit. + useEffect(() => { + const cam = camera as import('three').PerspectiveCamera; + const near = mode === 'walk' ? 0.1 : framing.cameraNear; + if (cam.near !== near) { + cam.near = near; + cam.updateProjectionMatrix(); + } + }, [mode, camera, framing.cameraNear]); const handleTrolleyTick = useCallback((pos: Vector3, heading: number) => { trolleyTarget.current.position.x = pos.x; trolleyTarget.current.position.y = pos.y; @@ -423,17 +647,42 @@ function SceneInner({ ortho={ortho} registerHandle={registerHandle} > + {/* First-person Walk gets a real procedural sky dome + IBL (which also + lifts the buildings); the miniature modes keep the flat colour. */} + {mode === 'walk' && } {/* Sky background + atmospheric fog, ranged to the model's extents so - they add depth without hiding the city. */} - - - - + they add depth without hiding the city. Walk brightens the fill, + neutralises the brown ground-bounce, and hazes toward the sky. */} + + + {/* Walk mode uses a fixed bright key (independent of the moody day/grade) so + the street reads in full daylight; miniature modes keep the day cycle. + Ambient is kept MODERATE (not flooded) so building colours stay saturated + and the sun/hemisphere still model form — too much flat ambient washes the + façades pastel. */} + + {gizmoTarget && gizmoBase && patchOverride ? ( )} + {/* The rideable bike — parked in the world once the walk controller exists. */} + {walkCtrl && ( + + )} + {/* Your visible body — a rigged, animated human shown in third-person (V), + posed by stance/riding. Suspends while the glTF loads. */} + {walkCtrl && ( + + + + )} ); } @@ -615,9 +877,31 @@ function TwinCanvasInner({ ), [] ); + // `?walk` drops you straight into first-person Walk mode (the navbar "Play" + // link), instead of the default orbit/tour — so the game isn't buried behind + // the ⋯ mode overflow. + const walkParam = useMemo( + () => + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('walk'), + [] + ); const [mode, setMode] = useState( orthoParam.on ? 'ortho' : hasTour ? 'tour' : 'orbit' ); + // `?walk` deep-link (the navbar "Play" link): do NOT enter Walk until the + // embodied controller has been built from the city meshes. Entering early makes + // the Rig fall back to its kinematic glide — which moves at the orbit + // move-speed (~1,200 m/s) with no terrain-follow: "running under the city at + // high speed". So land in orbit while the city loads, then switch once. + const [walkReady, setWalkReady] = useState(false); + const didAutoWalk = useRef(false); + useEffect(() => { + if (walkParam && walkReady && !didAutoWalk.current) { + didAutoWalk.current = true; + setMode('walk'); + } + }, [walkParam, walkReady]); const orthoFrame = useMemo(() => { if (mode !== 'ortho') return undefined; if (!orthoParam.frame) return framing.ortho; @@ -645,6 +929,12 @@ function TwinCanvasInner({ z: number; label: string; } | null>(null); + // Walk-mode stance (#226): the embodied controller (inside ) emits + // `player:stance` across the bus; the badge below (walk only) reflects it. + const [stance, setStance] = useState('stand'); + useEffect(() => bus.on('player:stance', (e) => setStance(e.stance)), []); + // Walk mode: true while standing next to the parked bike (drives the prompt). + const [nearBike, setNearBike] = useState(false); // --- Warehouse layer: directory + placement editor (#259) --- // The whole editor surface (edit mode, selection, overrides + persistence, @@ -826,7 +1116,7 @@ function TwinCanvasInner({ buildingsOpacity={buildingsOpacity} // Tilt-shift blur off for close-up study AND while editing — 0.5 m // nudges are invisible through the miniature blur. - crisp={houseFocused || editMode} + crisp={houseFocused || editMode || mode === 'walk'} paletteKey={paletteKey} day={day} mode={mode} @@ -838,6 +1128,8 @@ function TwinCanvasInner({ registerHandle={registerHandle} registerRig={registerRig} onFps={showFps ? setFps : undefined} + onNearBike={setNearBike} + onWalkReady={() => setWalkReady(true)} warehouseModels={warehouseModels} modelOverrides={modelOverrides} selectedModel={selectedModel} @@ -888,6 +1180,47 @@ function TwinCanvasInner({ ) : null} ) : null} + {mode === 'walk' && ( +
+ {stance.toUpperCase()} +
+ )} + {mode === 'walk' && nearBike && ( +
+ 🚲 Press B to ride +
+ )} {worldError && (
{ + it('defaults to 13 (bright afternoon) when absent or malformed', () => { + expect(parseHourParam('')).toBe(13); + expect(parseHourParam('?diorama&walk')).toBe(13); + expect(parseHourParam('?hour=')).toBe(13); + expect(parseHourParam('?hour=abc')).toBe(13); + }); + + it('reads the hour and clamps it to [0, 24]', () => { + expect(parseHourParam('?hour=6')).toBe(6); + expect(parseHourParam('?hour=18.5')).toBe(18.5); + expect(parseHourParam('?walk&hour=20')).toBe(20); + expect(parseHourParam('?hour=-3')).toBe(0); // clamp low + expect(parseHourParam('?hour=30')).toBe(24); // clamp high + }); +}); + +describe('parseWeatherParam — ?weather=', () => { + it('is none unless explicitly rain', () => { + expect(parseWeatherParam('')).toBe('none'); + expect(parseWeatherParam('?diorama&walk')).toBe('none'); + expect(parseWeatherParam('?weather=snow')).toBe('none'); + expect(parseWeatherParam('?weather=')).toBe('none'); + }); + it('turns on rain', () => { + expect(parseWeatherParam('?weather=rain')).toBe('rain'); + expect(parseWeatherParam('?walk&weather=rain')).toBe('rain'); + }); +}); diff --git a/src/world/Buildings.tsx b/src/world/Buildings.tsx index 715cfc1e..342b42ca 100644 --- a/src/world/Buildings.tsx +++ b/src/world/Buildings.tsx @@ -1,13 +1,18 @@ 'use client'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { Shape, ExtrudeGeometry, BufferGeometry, BufferAttribute, Color, + MeshStandardMaterial, + RepeatWrapping, + type Mesh, } from 'three'; +import { useThree } from '@react-three/fiber'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; +import { MaterialSystem } from '@/lib/cod'; import type { Building, TerrainGrid, Manifest } from '@/lib/manifest'; import { ringToShape } from './geometry'; import { elevationAt, minElevation } from './terrainSample'; @@ -78,6 +83,7 @@ export default function Buildings({ grid, manifest, opacity = 1, + onMeshReady, }: { buildings: Building[]; palette: BuildingPalette; @@ -85,7 +91,12 @@ export default function Buildings({ manifest: Manifest; /** Layer fade (registration checks against the aerial); 1 = opaque. */ opacity?: number; + /** Hands the merged buildings mesh to the composition root once built, so a + * physics layer (Walk-mode collision, #226) can bake it into a BVH. Fires + * whenever the merged geometry changes. */ + onMeshReady?: (mesh: Mesh) => void; }) { + const meshRef = useRef(null); const { geometry } = useMemo(() => { const minE = minElevation(grid); const nonHero = buildings.filter((b) => !b.swap); @@ -109,24 +120,95 @@ export default function Buildings({ }; }, [buildings, palette, grid, manifest]); + // Publish the merged mesh for the physics layer (#226). Keyed on `geometry` + // so a rebuild (new footprints) hands over a fresh mesh; guarded on the ref so + // the faded-out (opacity 0, unmounted) frames never emit a stale handle. + useEffect(() => { + if (meshRef.current && onMeshReady) onMeshReady(meshRef.current); + }, [geometry, onMeshReady]); + + // Skin the buildings with the CoD procedural-PBR forge for brick RELIEF + // (normalMap + ORM roughness) while the WALL COLOUR comes from the per-building + // `vertexColors` tint (the palette brick). The brick *albedo* map is deliberately + // NOT used: multiplying that dark brick albedo (linear ~0.03–0.15) by the warm + // tint drove every mass to ~(0.07,0.015,0.004) — near-black, and the same hue as + // the aerial ground, so the whole city vanished at street level (regression from + // the "brick city" change). Dropping the albedo restores the bright, visible + // palette colour the masses had before, and the normal/roughness keep the façade + // texture. The forge needs a live renderer, so the mocked-Canvas unit test (no + // gl) falls back to the flat material. Bakes once. Mirrors CodSkeleton's + // render-target save/restore. Stays UNCONDITIONALLY transparent so the opacity + // fade can recompile-free (see the old note). + const gl = useThree((s) => s.gl); + const forgeRef = useRef(null); + const material = useMemo(() => { + const flat = () => + new MeshStandardMaterial({ + vertexColors: true, + roughness: 0.92, + metalness: 0, + transparent: true, + opacity, + }); + if (!gl) return flat(); + try { + if (!forgeRef.current) { + const forge = new MaterialSystem({ renderer: gl }); + void forge.init({}); // synchronous body → full bake + forgeRef.current = forge; + } + const prevRT = gl.getRenderTarget(); + const prevAutoClear = gl.autoClear; + const set = forgeRef.current.getTextureSet('brick'); + gl.setRenderTarget(prevRT); + gl.autoClear = prevAutoClear; + if (!set || !set.albedo) return flat(); + const maxAniso = gl.capabilities.getMaxAnisotropy(); + // UVs are in metres (ExtrudeGeometry over metre footprints); ~3 m per tile + // reads like a real façade. Tune `rep` if bricks look too big / small. + const rep = 1 / 3; + // Tile only the relief maps — the albedo is intentionally unused (see note). + for (const t of [set.normal, set.orm]) { + if (!t) continue; + t.wrapS = RepeatWrapping; + t.wrapT = RepeatWrapping; + t.repeat.set(rep, rep); + t.anisotropy = maxAniso; + } + return new MeshStandardMaterial({ + // No `map`: the vertexColors tint IS the wall colour (bright, visible); + // the dark brick albedo would multiply it to near-black. + normalMap: set.normal, + roughnessMap: set.orm, // ORM: roughness in .g + metalnessMap: set.orm, // ORM: metalness in .b + vertexColors: true, + roughness: 1, + metalness: 0, + transparent: true, + opacity, + }); + } catch (err) { + console.warn('[Buildings] forge skin failed; flat fallback', err); + return flat(); + } + }, [gl, opacity]); + + useEffect( + () => () => { + forgeRef.current?.dispose(); + forgeRef.current = null; + }, + [] + ); + if (opacity <= 0) return null; return ( - = 1} receiveShadow> - {/* UNCONDITIONALLY transparent: three bakes an OPAQUE define into the - program when a material mounts with transparent=false, and flipping - `transparent` at runtime never recompiles — the fade would silently - no-op until a full unmount/remount cycle. One merged mesh, so the - transparent-pass sorting cost is nil. depthWrite stays on while - fading: self-overlap artifacts are acceptable for a diagnostic layer - and it avoids sort popping. Shadows drop while faded so a ghosted - layer doesn't cast solid shadows on the aerial. */} - - + = 1} + receiveShadow + /> ); } diff --git a/src/world/CityProps.tsx b/src/world/CityProps.tsx new file mode 100644 index 00000000..4f86d315 --- /dev/null +++ b/src/world/CityProps.tsx @@ -0,0 +1,238 @@ +'use client'; +import { useEffect, useMemo, useRef } from 'react'; +import { + BoxGeometry, + Color, + CylinderGeometry, + IcosahedronGeometry, + MeshStandardMaterial, + Object3D, + type InstancedMesh, +} from 'three'; +import type { Street, TerrainGrid, Manifest } from '@/lib/manifest'; +import { elevationAt, minElevation } from './terrainSample'; + +/** + * Zero-asset "city life" for the wide/Walk diorama: instanced procedural street + * trees + parked cars scattered along the (reprojected) streets, so the city + * isn't dead-empty. Three InstancedMeshes total (trunk / foliage / car) — a + * couple thousand instances at three draw calls, deterministic via a seeded RNG. + * Pedestrians are deferred (a believable crowd needs rigged figures). + */ + +// Deterministic scatter (mulberry32) — same layout every load, no per-frame alloc. +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const ROAD_HALF = 4; // matches Roads.tsx ROAD_WIDTH / 2 +const MAX_TREES = 1600; +const MAX_CARS = 420; +const CAR_COLORS = [0x3a4b6b, 0x8a2f2f, 0x2f6b45, 0xd9d2c4, 0x30343a, 0xa8863f]; + +interface Placed { + x: number; + y: number; + z: number; + rotY: number; + scale: number; + colorIdx: number; +} + +/** + * Scatter street trees + parked cars along the streets (pure + testable). Trees + * on both sidewalks, cars along one curb. Placement steps along the WHOLE + * polyline (cumulative distance) so densely-sampled short segments still get + * props — per-segment stepping placed almost nothing. Deterministic (seeded RNG). + */ +export function scatterCityProps( + streets: Street[], + grid: TerrainGrid, + manifest: Manifest +): { trees: Placed[]; cars: Placed[] } { + const minE = minElevation(grid); + const yAt = (x: number, z: number) => + elevationAt(grid, manifest, x, z) - minE; + const rng = mulberry32(0x5eed01); + const trees: Placed[] = []; + const cars: Placed[] = []; + for (const s of streets) { + const p = s.pts; + let acc = 0; + let nextTree = 5 + rng() * 8; + let nextCar = 12 + rng() * 12; + for (let i = 0; i + 3 < p.length; i += 2) { + const x0 = p[i], + z0 = p[i + 1], + x1 = p[i + 2], + z1 = p[i + 3]; + let dx = x1 - x0, + dz = z1 - z0; + const len = Math.hypot(dx, dz); + if (len < 1e-3) continue; + dx /= len; + dz /= len; + const nx = -dz, + nz = dx; // street perpendicular + const along = Math.atan2(dx, dz); // face along the segment + while (nextTree <= acc + len && trees.length < MAX_TREES) { + const d = nextTree - acc; + const cx = x0 + dx * d, + cz = z0 + dz * d; + for (const side of [1, -1] as const) { + if (rng() < 0.3) continue; // gaps + const off = (ROAD_HALF + 2.2 + rng() * 1.2) * side; + const tx = cx + nx * off + (rng() - 0.5) * 1.4; + const tz = cz + nz * off + (rng() - 0.5) * 1.4; + trees.push({ + x: tx, + y: yAt(tx, tz), + z: tz, + rotY: rng() * Math.PI * 2, + scale: 0.85 + rng() * 0.7, + colorIdx: 0, + }); + } + nextTree += 11 + rng() * 6; + } + while (nextCar <= acc + len && cars.length < MAX_CARS) { + const d = nextCar - acc; + const cx = x0 + dx * d, + cz = z0 + dz * d; + const off = ROAD_HALF - 0.4; + const kx = cx + nx * off, + kz = cz + nz * off; + cars.push({ + x: kx, + y: yAt(kx, kz), + z: kz, + rotY: along, + scale: 1, + colorIdx: (rng() * CAR_COLORS.length) | 0, + }); + nextCar += 16 + rng() * 14; + } + acc += len; + } + } + return { trees, cars }; +} + +export default function CityProps({ + streets, + grid, + manifest, +}: { + streets: Street[]; + grid: TerrainGrid; + manifest: Manifest; +}) { + const { trees, cars } = useMemo( + () => scatterCityProps(streets, grid, manifest), + [streets, grid, manifest] + ); + + // Base geometries sit with their base at y=0 (translated up) so an instance + // placed at ground height rests ON the ground. + const trunkGeo = useMemo(() => { + const g = new CylinderGeometry(0.16, 0.24, 2.2, 6); + g.translate(0, 1.1, 0); + return g; + }, []); + const foliageGeo = useMemo(() => { + const g = new IcosahedronGeometry(1.5, 0); + g.translate(0, 3.1, 0); + return g; + }, []); + const carGeo = useMemo(() => { + const g = new BoxGeometry(4.4, 1.4, 1.9); + g.translate(0, 0.7, 0); + return g; + }, []); + const trunkMat = useMemo( + () => new MeshStandardMaterial({ color: 0x5b4432, roughness: 0.95 }), + [] + ); + const foliageMat = useMemo( + () => new MeshStandardMaterial({ color: 0x3f6f3a, roughness: 0.9 }), + [] + ); + const carMat = useMemo( + // White base so per-instance instanceColor shows; a little metalness reads + // as painted sheet metal. + () => new MeshStandardMaterial({ color: 0xffffff, roughness: 0.45, metalness: 0.3 }), + [] + ); + + const trunkRef = useRef(null); + const foliageRef = useRef(null); + const carRef = useRef(null); + + useEffect(() => { + const o = new Object3D(); + for (let i = 0; i < trees.length; i++) { + const t = trees[i]; + o.position.set(t.x, t.y, t.z); + o.rotation.set(0, t.rotY, 0); + o.scale.setScalar(t.scale); + o.updateMatrix(); + trunkRef.current?.setMatrixAt(i, o.matrix); + foliageRef.current?.setMatrixAt(i, o.matrix); + } + if (trunkRef.current) trunkRef.current.instanceMatrix.needsUpdate = true; + if (foliageRef.current) foliageRef.current.instanceMatrix.needsUpdate = true; + }, [trees]); + + useEffect(() => { + const o = new Object3D(); + const c = new Color(); + for (let i = 0; i < cars.length; i++) { + const car = cars[i]; + o.position.set(car.x, car.y, car.z); + o.rotation.set(0, car.rotY, 0); + o.scale.setScalar(1); + o.updateMatrix(); + carRef.current?.setMatrixAt(i, o.matrix); + carRef.current?.setColorAt(i, c.setHex(CAR_COLORS[car.colorIdx])); + } + if (carRef.current) { + carRef.current.instanceMatrix.needsUpdate = true; + if (carRef.current.instanceColor) + carRef.current.instanceColor.needsUpdate = true; + } + }, [cars]); + + if (trees.length === 0 && cars.length === 0) return null; + return ( + <> + {/* frustumCulled off: the per-instance bounds aren't tracked, so a + bounding-sphere cull off instance 0 would wrongly hide the whole set. */} + + + + + ); +} diff --git a/src/world/Roads.tsx b/src/world/Roads.tsx new file mode 100644 index 00000000..5133ce22 --- /dev/null +++ b/src/world/Roads.tsx @@ -0,0 +1,156 @@ +'use client'; +import { useEffect, useMemo, useRef } from 'react'; +import { + BufferGeometry, + Float32BufferAttribute, + MeshStandardMaterial, + RepeatWrapping, + DoubleSide, +} from 'three'; +import { useThree } from '@react-three/fiber'; +import { MaterialSystem } from '@/lib/cod'; +import type { Street, TerrainGrid, Manifest } from '@/lib/manifest'; +import { elevationAt, minElevation } from './terrainSample'; + +/** Street ribbon width, metres (streets.json carries only centrelines, so this + * is a single downtown-ish default; tune for feel). */ +const ROAD_WIDTH = 8; +/** Lift above the draped terrain so the ribbon reads as a surface without + * z-fighting the aerial. */ +const ROAD_LIFT = 0.12; + +/** + * Extruded asphalt road ribbons for the wide/Walk diorama. `Streets.tsx` draws + * 1-px lines (fine for the miniature orbit); at street level you need a real + * surface. Each centreline segment becomes a terrain-riding quad, forge-skinned + * with the CoD `asphalt` PBR set (albedo + normal + ORM) — the same bake the + * buildings use. Flat dark fallback when there's no live renderer (unit test). + * + * Normals are forced +Y (roads are near-horizontal) and the material is + * DoubleSide, so lighting is correct regardless of per-segment winding. + */ +export default function Roads({ + streets, + grid, + manifest, +}: { + streets: Street[]; + grid: TerrainGrid; + manifest: Manifest; +}) { + const geometry = useMemo(() => { + const minE = minElevation(grid); + const yAt = (x: number, z: number) => + elevationAt(grid, manifest, x, z) - minE + ROAD_LIFT; + const half = ROAD_WIDTH / 2; + const positions: number[] = []; + const uvs: number[] = []; + for (const s of streets) { + const p = s.pts; + for (let i = 0; i + 3 < p.length; i += 2) { + const x0 = p[i], + z0 = p[i + 1], + x1 = p[i + 2], + z1 = p[i + 3]; + let dx = x1 - x0, + dz = z1 - z0; + const len = Math.hypot(dx, dz); + if (len < 1e-3) continue; + dx /= len; + dz /= len; + // Perpendicular offset in XZ (road half-width to each side). + const nx = -dz * half, + nz = dx * half; + // Corners: A=left@start B=right@start C=left@end D=right@end. + const aX = x0 + nx, + aZ = z0 + nz, + bX = x0 - nx, + bZ = z0 - nz, + cX = x1 + nx, + cZ = z1 + nz, + dX = x1 - nx, + dZ = z1 - nz; + const aY = yAt(aX, aZ), + bY = yAt(bX, bZ), + cY = yAt(cX, cZ), + dY = yAt(dX, dZ); + // Two triangles (A,B,C) + (C,B,D). + positions.push(aX, aY, aZ, bX, bY, bZ, cX, cY, cZ); + positions.push(cX, cY, cZ, bX, bY, bZ, dX, dY, dZ); + // Planar top-down UVs in metres → the asphalt tiles by `repeat`. + uvs.push(aX, aZ, bX, bZ, cX, cZ); + uvs.push(cX, cZ, bX, bZ, dX, dZ); + } + } + const g = new BufferGeometry(); + g.setAttribute('position', new Float32BufferAttribute(positions, 3)); + g.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); + // Roads are near-horizontal — a flat +Y normal keeps lighting right under + // DoubleSide without depending on triangle winding. + const n = new Float32Array(positions.length); + for (let i = 1; i < n.length; i += 3) n[i] = 1; + g.setAttribute('normal', new Float32BufferAttribute(n, 3)); + return g; + }, [streets, grid, manifest]); + + // Forge-skin with the CoD `asphalt` PBR set (mirrors Buildings' bake: live + // renderer required, render-target save/restore, flat fallback under the + // mocked-Canvas unit test). Baked once. + const gl = useThree((s) => s.gl); + const forgeRef = useRef(null); + const material = useMemo(() => { + const flat = () => + new MeshStandardMaterial({ + color: 0x30323a, + roughness: 0.96, + metalness: 0, + side: DoubleSide, + }); + if (!gl) return flat(); + try { + if (!forgeRef.current) { + const forge = new MaterialSystem({ renderer: gl }); + void forge.init({}); + forgeRef.current = forge; + } + const prevRT = gl.getRenderTarget(); + const prevAutoClear = gl.autoClear; + const set = forgeRef.current.getTextureSet('asphalt'); + gl.setRenderTarget(prevRT); + gl.autoClear = prevAutoClear; + if (!set || !set.albedo) return flat(); + const maxAniso = gl.capabilities.getMaxAnisotropy(); + // UVs are planar metres; ~4 m per tile reads like real asphalt aggregate. + const rep = 1 / 4; + for (const t of [set.albedo, set.normal, set.orm]) { + if (!t) continue; + t.wrapS = RepeatWrapping; + t.wrapT = RepeatWrapping; + t.repeat.set(rep, rep); + t.anisotropy = maxAniso; + } + return new MeshStandardMaterial({ + map: set.albedo, + normalMap: set.normal, + roughnessMap: set.orm, // ORM: roughness in .g + metalnessMap: set.orm, // ORM: metalness in .b + roughness: 1, + metalness: 0, + side: DoubleSide, + }); + } catch (err) { + console.warn('[Roads] forge skin failed; flat fallback', err); + return flat(); + } + }, [gl]); + + useEffect( + () => () => { + forgeRef.current?.dispose(); + forgeRef.current = null; + }, + [] + ); + + return ; +} diff --git a/src/world/Terrain.tsx b/src/world/Terrain.tsx index b2c524b8..5504f41a 100644 --- a/src/world/Terrain.tsx +++ b/src/world/Terrain.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useMemo } from 'react'; -import { PlaneGeometry, Texture } from 'three'; +import { useEffect, useMemo, useRef } from 'react'; +import { useThree } from '@react-three/fiber'; +import { PlaneGeometry, Texture, type Mesh } from 'three'; import type { TerrainGrid, Manifest } from '@/lib/manifest'; import { bilinear, assertExtent, minElevation } from './terrainSample'; import { materialKit } from '@/stage/materialKit'; @@ -9,11 +10,17 @@ export default function Terrain({ grid, drape, manifest, + onMeshReady, }: { grid: TerrainGrid; drape: Texture; manifest: Manifest; + /** Hands the displaced ground mesh to the composition root once built, so a + * physics layer (Walk-mode gravity/step/slope, #226) can bake it as the floor. + * Fires whenever the geometry rebuilds. */ + onMeshReady?: (mesh: Mesh) => void; }) { + const meshRef = useRef(null); const geometry = useMemo(() => { const w = manifest.groundWm, h = manifest.groundHm; @@ -33,6 +40,20 @@ export default function Terrain({ return g; }, [grid, manifest]); - const material = useMemo(() => materialKit.drapedGround(drape), [drape]); - return ; + // Anisotropic filtering keeps the aerial sharp at grazing street-level angles. + const maxAniso = useThree((s) => s.gl.capabilities.getMaxAnisotropy()); + const material = useMemo( + () => materialKit.drapedGround(drape, maxAniso), + [drape, maxAniso] + ); + + // Publish the ground mesh for the physics floor (#226). Keyed on `geometry` so + // a rebuild hands over the fresh mesh; guarded on the ref. + useEffect(() => { + if (meshRef.current && onMeshReady) onMeshReady(meshRef.current); + }, [geometry, onMeshReady]); + + return ( + + ); } diff --git a/src/world/TwinWorld.tsx b/src/world/TwinWorld.tsx index af744b7f..3291dd75 100644 --- a/src/world/TwinWorld.tsx +++ b/src/world/TwinWorld.tsx @@ -1,6 +1,6 @@ 'use client'; import { Suspense, useEffect, useMemo, useState } from 'react'; -import { TextureLoader, Texture } from 'three'; +import { TextureLoader, Texture, type Mesh } from 'three'; import { loadSiteJson, siteAssetUrl, hasWideExtent } from '@/lib/manifest'; import { createProjection } from '@/lib/enu'; import type { @@ -47,6 +47,8 @@ export default function TwinWorld({ onGroundReady, onError, onTwinPlaced, + onBuildingsMesh, + onTerrainMesh, }: { slug: string; manifest: Manifest; @@ -77,6 +79,11 @@ export default function TwinWorld({ /** Wide sites only: reports the embedded twin's wide-frame position + label * once placed, so the HUD can offer an in-diorama fly-to (#332). */ onTwinPlaced?: (t: { x: number; z: number; label: string }) => void; + /** Wide sites only: hands over the merged buildings mesh for Walk-mode BVH + * collision (#226). */ + onBuildingsMesh?: (mesh: Mesh) => void; + /** Wide sites only: hands over the ground mesh for the Walk-mode physics floor. */ + onTerrainMesh?: (mesh: Mesh) => void; }) { const [data, setData] = useState(null); // Wide sites (chatt) render the full atlasBox city + embedded twin (WideCity) @@ -148,8 +155,12 @@ export default function TwinWorld({ slug={slug} manifest={manifest} palette={palette} + warehouseModels={warehouseModels} onError={onError} onTwinPlaced={onTwinPlaced} + onGroundReady={onGroundReady} + onBuildingsMesh={onBuildingsMesh} + onTerrainMesh={onTerrainMesh} /> ); } diff --git a/src/world/WideCity.tsx b/src/world/WideCity.tsx index 4e90c5cf..dc3dfe44 100644 --- a/src/world/WideCity.tsx +++ b/src/world/WideCity.tsx @@ -1,17 +1,24 @@ 'use client'; -import { Suspense, useEffect, useState } from 'react'; -import { TextureLoader, Texture } from 'three'; +import { Suspense, useEffect, useMemo, useState } from 'react'; +import { TextureLoader, Texture, type Mesh } from 'three'; import { createProjection } from '@/lib/enu'; import { loadSiteJson, siteAssetUrl, loadHouse } from '@/lib/manifest'; import type { Building, + Street, TerrainGrid, Manifest, HouseInfo, + WarehouseModelsInfo, } from '@/lib/manifest'; import Buildings, { type BuildingPalette } from './Buildings'; import Terrain from './Terrain'; import HouseModel from './HouseModel'; +import Water from './Water'; +import Roads from './Roads'; +import CityProps from './CityProps'; +import WarehouseModels from './WarehouseModels'; +import { elevationAt, minElevation } from './terrainSample'; /** buildings-wide.json entry — raw WGS84 footprints (src/twin/cesium/overpass.ts * `LiveBuilding`). `lonLat` is a FLAT [lon,lat,lon,lat,…] ring. */ @@ -25,6 +32,7 @@ interface WideLiveBuilding { interface WideData { grid: TerrainGrid; buildings: Building[]; + streets: Street[]; drape: Texture; wideManifest: Manifest; twin: { slug: string; house: HouseInfo } | null; @@ -46,16 +54,32 @@ export default function WideCity({ slug, manifest, palette, + warehouseModels, onError, onTwinPlaced, + onGroundReady, + onBuildingsMesh, + onTerrainMesh, }: { slug: string; manifest: Manifest; palette: BuildingPalette; + /** Real landmark GLBs (models.json), narrow-frame — reprojected into the wide + * frame here and rendered read-only (no editor gizmo in the walk path). */ + warehouseModels?: WarehouseModelsInfo | null; onError?: (message: string) => void; /** Reports the embedded twin's wide-frame position + label once placed, so * the HUD can offer an in-diorama fly-to instead of a separate page (#332). */ onTwinPlaced?: (t: { x: number; z: number; label: string }) => void; + /** Hands the composition root a terrain sampler (runtime Y at ENU x/z) once + * the wide grid loads — so Walk-mode ground-follow (and the trolley) ride ON + * the hills. The narrow TwinWorld path wires this too; the wide path did not + * until #226. */ + onGroundReady?: (groundAt: (x: number, z: number) => number) => void; + /** Hands over the merged buildings mesh for Walk-mode BVH collision (#226). */ + onBuildingsMesh?: (mesh: Mesh) => void; + /** Hands over the ground mesh for the Walk-mode physics floor (#226). */ + onTerrainMesh?: (mesh: Mesh) => void; }) { const [data, setData] = useState(null); @@ -115,8 +139,36 @@ export default function WideCity({ } return { id: b.id, ring, height: b.heightM, rule: b.rule }; }); + // Roads: streets.json is baked in the NARROW box frame. Reproject each + // polyline into the wide/atlasBox frame the SAME offset-exact way buildings + // are — narrow ENU → lon/lat (narrowProj.enuToLonLat) → wide ENU + // (proj.lonLatToEnu); both projections use the same site vectorOffsetM, so + // the round-trip recovers the true position. Best-effort: a site without + // streets.json (or a bad parse) just renders no roads, never a blank city. + // Coverage is the narrow corridor (where you spawn/walk), not the whole + // atlasBox — acceptable for v1; a wide streets bake would extend it. + let streets: Street[] = []; + try { + const narrow = await loadSiteJson(slug, 'streets.json'); + const narrowProj = createProjection( + manifest.box, + manifest.vectorOffsetM + ); + streets = narrow.map((s) => { + const pts: number[] = []; + for (let i = 0; i + 1 < s.pts.length; i += 2) { + const [lon, lat] = narrowProj.enuToLonLat(s.pts[i], s.pts[i + 1]); + const [wx, wz] = proj.lonLatToEnu(lon, lat); + pts.push(wx, wz); + } + return { pts }; + }); + } catch (e) { + console.warn('[WideCity] streets skipped:', e); + } + if (!alive) return; - setData({ grid, buildings, drape, wideManifest, twin }); + setData({ grid, buildings, streets, drape, wideManifest, twin }); if (twin && embed) onTwinPlaced?.({ x: twin.house.x, @@ -132,6 +184,47 @@ export default function WideCity({ }; }, [slug, manifest, onError, onTwinPlaced]); + // Publish the wide terrain sampler once the grid loads — same normalization + // (elevationAt − minE) the mesh uses, so Walk-mode feet ride ON the terrain + // the same way buildings are seated on it. Mirrors TwinWorld's narrow path. + useEffect(() => { + if (!data || !onGroundReady) return; + const { grid, wideManifest } = data; + const min = minElevation(grid); + onGroundReady((x, z) => elevationAt(grid, wideManifest, x, z) - min); + }, [data, onGroundReady]); + + // Real landmark GLBs are anchored (models.json) in the NARROW box frame. + // Reproject each anchor into the wide/atlasBox frame the same offset-exact way + // buildings/streets are (narrow enuToLonLat → wide lonLatToEnu), so they land + // at their true locations. Hook stays above the early return (rules of hooks). + const wideModels = useMemo(() => { + if (!warehouseModels) return null; + const narrowProj = createProjection(manifest.box, manifest.vectorOffsetM); + const wideProj = createProjection( + manifest.atlasBox ?? manifest.box, + manifest.vectorOffsetM + ); + return { + ...warehouseModels, + models: warehouseModels.models.map((e) => { + const [lon, lat] = narrowProj.enuToLonLat(e.x, e.z); + const [wx, wz] = wideProj.lonLatToEnu(lon, lat); + return { ...e, x: wx, z: wz }; + }), + }; + }, [warehouseModels, manifest]); + + // Hide the massing box under each landmark GLB so the model IS the building + // there (no double geometry) — mirrors TwinWorld's narrow visibleBuildings. + const visibleBuildings = useMemo(() => { + if (!data) return []; + const hide = new Set(warehouseModels?.hideBuildingIds ?? []); + return hide.size + ? data.buildings.filter((b) => !hide.has(b.id)) + : data.buildings; + }, [data, warehouseModels]); + if (!data) return null; return ( <> @@ -139,12 +232,33 @@ export default function WideCity({ grid={data.grid} drape={data.drape} manifest={data.wideManifest} + onMeshReady={onTerrainMesh} + /> + {/* The Tennessee River — a full-extent Y=0.5 plane that shows through only + where the wide terrain carves the channel to the valley floor (~Y=0). + Same layer the narrow TwinWorld path renders; chatt's manifest is + water:true. */} + {manifest.site.water === true && } + {/* Road ribbons — narrow streets reprojected into the wide frame (corridor + coverage). Terrain-riding asphalt so streets read at ground level. */} + + {/* Zero-asset city life — instanced street trees + parked cars scattered + along the streets so the city isn't dead-empty. */} + {data.twin ? ( @@ -156,6 +270,18 @@ export default function WideCity({ /> ) : null} + {/* Real landmark GLBs at their true (reprojected) locations — read-only in + the wide/Walk path (the editor gizmo is narrow-path only). */} + {wideModels ? ( + + + + ) : null} ); } diff --git a/src/world/__tests__/cityProps.test.ts b/src/world/__tests__/cityProps.test.ts new file mode 100644 index 00000000..c595fa69 --- /dev/null +++ b/src/world/__tests__/cityProps.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { scatterCityProps } from '../CityProps'; +import type { Street, TerrainGrid, Manifest } from '@/lib/manifest'; + +// Flat terrain + minimal manifest so elevationAt returns a stable ground height. +const grid: TerrainGrid = { cols: 2, rows: 2, heights: [0, 0, 0, 0] }; +const manifest = { groundWm: 1000, groundHm: 1000 } as unknown as Manifest; + +describe('scatterCityProps', () => { + it('populates a long street built from MANY short segments (cumulative placement)', () => { + // 300 m street sampled every 5 m — exactly the densely-sampled short-segment + // case where per-segment stepping placed almost nothing. Cumulative distance + // along the whole polyline must still scatter trees + cars. + const pts: number[] = []; + for (let d = 0; d <= 300; d += 5) pts.push(d, 0); + const streets: Street[] = [{ pts }]; + + const { trees, cars } = scatterCityProps(streets, grid, manifest); + expect(trees.length).toBeGreaterThan(10); + expect(cars.length).toBeGreaterThan(3); + + // Deterministic (seeded) — same layout every call. + const again = scatterCityProps(streets, grid, manifest); + expect(again.trees.length).toBe(trees.length); + expect(again.cars.length).toBe(cars.length); + }); + + it('scatters nothing when there are no streets', () => { + const { trees, cars } = scatterCityProps([], grid, manifest); + expect(trees.length).toBe(0); + expect(cars.length).toBe(0); + }); +}); diff --git a/tests/e2e/twin-walk-visible.spec.ts b/tests/e2e/twin-walk-visible.spec.ts new file mode 100644 index 00000000..042ed556 --- /dev/null +++ b/tests/e2e/twin-walk-visible.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test'; +import sharp from 'sharp'; + +/** + * Visual regression guard for first-person Walk (`/chatt?diorama&walk`). + * + * WHY THIS EXISTS: every scene regression in the walk-realism arc — the unlit + * "dark void", the buildings vanishing into the ground, the over-sepia grade — + * sailed through console/error checks because nothing threw. Only the *pixels* + * were wrong. This spec reads the composited frame (a real screenshot, so it + * captures WebGL via the compositor regardless of preserveDrawingBuffer) and + * fails if the street level is too dark or too uniform to be the city. + * + * WebGL honesty (see tests/e2e/twin-glass-contrast.spec.ts + #288): headless + * Chromium needs software GL (playwright.visual.config.ts forces SwiftShader). If + * WebGL is still unavailable we SKIP rather than false-green on a blank canvas. + * + * Run: docker exec sh-cod-scripthammer-1 pnpm exec playwright test \ + * --config playwright.visual.config.ts + */ + +// Floors calibrated against the daylit walk scene (buildings + sky + ground). +// A dark-void regression drives mean luminance toward 0; a flat single-colour +// frame (no geometry / all fog) drives inter-tile variance toward 0. +const MIN_MEAN_LUMINANCE = 0.1; +const MIN_VARIANCE = 0.0015; + +test.describe('walk scene is visible (not a dark void / empty frame)', () => { + test('street level renders lit geometry', async ({ page }) => { + // The exported CI build serves at /chatt; the dev container serves under a + // basePath — set APP_BASE_PATH=/ScriptHammer when pointing at the dev server. + const base = process.env.APP_BASE_PATH ?? ''; + await page.goto(`${base}/chatt/?diorama&walk`); + // The canvas element mounts into the DOM immediately (attached), even before + // the scene draws — wait for that, not visibility (a 0-size/unpainted canvas + // never becomes "visible" under software GL). + await page + .locator('canvas') + .first() + .waitFor({ state: 'attached', timeout: 30_000 }); + + // Gate on a REAL GPU. Software renderers (SwiftShader / llvmpipe — headless + // CI and this dev container) can't render the heavy R3F scene, so a pixel + // guard there cannot tell "the app is dark" from "the env can't draw" (the + // #288 limitation the twin-contrast specs document). Skip cleanly rather than + // false-fail; the guard still runs on real-GPU dev machines / GPU CI runners. + const gpu = await page.evaluate(() => { + const c = document.createElement('canvas'); + const gl = (c.getContext('webgl2') || + c.getContext('webgl')) as WebGLRenderingContext | null; + if (!gl) return { webgl: false, renderer: '' }; + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + const renderer = ext + ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) + : ''; + return { webgl: true, renderer }; + }); + test.skip(!gpu.webgl, 'no WebGL (see #288) — the visual guard needs a GPU'); + test.skip( + /swiftshader|llvmpipe|softwarerasterizer|swrast|software/i.test( + gpu.renderer + ), + `software WebGL (${gpu.renderer}) — the visual guard needs a real GPU (see #288); it runs on dev machines / GPU CI` + ); + + // Real GPU: wait for Walk to activate, then settle the first frames. + await page + .locator('[data-stance]') + .first() + .waitFor({ timeout: 25_000 }) + .catch(() => {}); + await page.waitForTimeout(8000); + + const shot = await page.locator('canvas').first().screenshot(); + + // Downsample to a coarse grid and measure mean luminance + inter-tile + // variance. sharp decodes the PNG the compositor produced. + const W = 32; + const H = 18; + const { data, info } = await sharp(shot) + .resize(W, H, { fit: 'fill' }) + .raw() + .toBuffer({ resolveWithObject: true }); + const ch = info.channels; + const lum: number[] = []; + for (let i = 0; i < W * H; i++) { + const r = data[i * ch]; + const g = data[i * ch + 1]; + const b = data[i * ch + 2]; + lum.push((0.2126 * r + 0.7152 * g + 0.0722 * b) / 255); + } + const mean = lum.reduce((a, b) => a + b, 0) / lum.length; + const variance = + lum.reduce((a, b) => a + (b - mean) ** 2, 0) / lum.length; + + // eslint-disable-next-line no-console + console.log( + `[visual-smoke] mean=${mean.toFixed(4)} variance=${variance.toFixed(5)}` + ); + + expect( + mean, + `frame too dark (mean luminance ${mean.toFixed(3)}) — likely an unlit / dark-void regression` + ).toBeGreaterThan(MIN_MEAN_LUMINANCE); + expect( + variance, + `frame too uniform (variance ${variance.toFixed(4)}) — likely no geometry on screen (buildings gone / flat fog)` + ).toBeGreaterThan(MIN_VARIANCE); + }); +});