diff --git a/docs/README.md b/docs/README.md index ebf50ee5..d39e3bb5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ contributors. This page routes deeper work without duplicating it. bridge, SpacetimeDB, rendering, and delivery - [Lowlands renderer](design/hegemony-lowlands-terrain.md) — terrain, presentation, and performance principles +- [Living Realm V1](design/living-realm-v1.md) — coherent environmental motion, + bounded surface response, ecology budgets, and fail-closed design - [Genesis water](design/genesis-water.md) — canonical coast, river, and fog layout - [Lowlands audio](design/lowlands-audio.md) — scene transitions and runtime diff --git a/docs/design/living-realm-v1.md b/docs/design/living-realm-v1.md new file mode 100644 index 00000000..f9a2ce23 --- /dev/null +++ b/docs/design/living-realm-v1.md @@ -0,0 +1,161 @@ +# Living Realm V1 + +## Scope and baseline + +Living Realm V1 is a browser-presentation upgrade for Genesis 001. It adds a +coherent wind contract, bounded local surface disturbance, subtle forest +motion, analytic water ripples, and tiny camera-local ambient ecology. None of +those effects is game state: SpacetimeDB remains authoritative for world +membership, Workers, routes, resources, ownership, schedules, and outcomes. + +Implementation began from `0999e07b6aa36bb96613731e2837d096ae5a10ed` on the +`agent/living-realm-visual-ecology` branch. The audited toolchain was Node +22.23.1, npm 10.9.8, and Three.js 0.185.1. The unchanged baseline passed +`npm run check`: 266 Vitest files and 2,961 tests, TypeScript, licensing, +runtime-asset provenance, tracked-file size policy, production build, +production exclusions, and the Farcaster Mini App contract. + +The canonical rendered-browser command failed before page launch because the +host Chrome bundle did not satisfy its single-link, clean-bundle attestation. +The Chrome installation was not modified. Deterministic loopback-only baseline +captures were instead recorded with the isolated in-app browser; the standard +command remains a required final check and any continued host failure will be +reported rather than hidden. + +| Case | Grass instances / triangles / draws | Water triangles / draws | Canonical forest triangles / draws | Ambient cap | +| --- | ---: | ---: | ---: | ---: | +| High 1920×1080 | 2,167 / 58,509 / 3 | 21,198 / 3 | 136,418 / 1 | 30 Hz | +| Balanced 1280×720 | 837 / 17,577 / 2 | 21,198 / 3 | 76,334 / 1 | 22 Hz | +| Balanced tablet 1024×768 | 837 / 17,577 / 2 | 21,198 / 3 | 76,334 / 1 | 22 Hz | +| Balanced portrait 390×844 | 837 / 17,577 / 2 | 21,198 / 3 | 76,334 / 1 | 22 Hz | +| Balanced short landscape 667×375 | 837 / 17,577 / 2 | 21,198 / 3 | 76,334 / 1 | 22 Hz | +| Reduced 1280×720 | 156 / 2,340 / 1 | 21,198 / 3 | 30,139 / 1 | idle | + +The baseline images and aggregate datasets are local QA artifacts rather than +runtime or repository assets. They contain only the synthetic 100-castle +fixture and no production identity, account, balance, token, route, or private +state. + +## Adaptation decisions + +Warpkeep uses the visual lesson of a landscape responding coherently, not the +implementation scale of a close first-person meadow: + +- The existing `RealmAmbientScheduler` remains the only ambient clock. Its + frame cap is the maximum needed by active subsystems, never their sum. +- Grass, water, and forest reuse their existing draws. Living Realm may add at + most one instanced bird draw, one points draw, and one compact Rabbit draw in + High or Balanced, and adds none in Reduced or reduced motion. +- Worker wakes sample the owning renderer's sanitized current poses after its + ordinary update. Resource wagons do not yet expose an equivalent clean pose + API, so this version does not duplicate their interpolation or inspect DOM + transforms; wagon wakes are a later owner-layer follow-up. +- Disturbances live in a fixed-capacity renderer-only pool with preallocated + snapshots. They never create database rows, affect picking, alter routes, or + scan the 10,000-cell world. +- Forest wind uses compact normalized byte attributes where geometry ownership + permits and a root-anchored local-height fallback for leased instanced + primitives. Material failure keeps the existing static forest. +- Ambient life is deterministic, camera-local, non-pickable, and visually + subordinate to units, labels, routes, resources, selection, and hover. + +This work contains original Warpkeep-native code. The public +[TUMBLE meadow](https://grass-world-meadow.netlify.app/) was used only as a +visual reference for camera-local density, coherent motion, reflective water, +and ambient wildlife. No TUMBLE source, shader, artwork, preset, or binary was +copied into the repository, decompiled, or made a dependency. + +## Explicitly rejected techniques + +A production 4096×4096 water solver is rejected for this scope. It would add +large floating-point targets, extra render passes, continuous simulation work, +and mobile memory pressure while bypassing Warpkeep's canonical welded water +geometry. Water response instead uses compile-time-bounded analytic ripple +slots in the existing materials. + +Gameplay depth of field and a post-processing chain are also rejected. They +would blur labels, routes, resources, selection, and touch-readable strategy +surfaces. The fixed canonical sun, generated environment map, ACES output, and +existing fog remain the lighting and depth contract. + +## Fail-closed and lifecycle contract + +Reduced quality, reduced motion, strategic overview, hidden documents, +inactive presentation, context loss, shader-contract drift, and disposal all +disable optional moving ambience. The compact Rabbit also fails closed on a +missing, changed, oversized, or structurally incompatible model. No subsystem +owns a second animation frame loop or interval. A failed optional material, +asset, or ecology layer leaves terrain, water topology, forest placement, +Workers, interaction, and the Realm intact. + +## Implemented presentation contract + +- `realmLivingEnvironment.ts` is the single renderer-neutral wind and gust + definition. Grass and forest inject the same bounded world-space field into + their existing standard materials. +- Grass keeps its established instance pools and draw counts. High/Balanced + patches carry twelve/nine blades, denser meadow and Lowlands candidate fields, + brighter green authored palettes, and a faint chlorophyll fill so distant + blades remain green. Its lighting normal follows its bounded bend, sun-side + transmission remains restrained, and an unrolled quality-specific uniform + array accepts at most eight/four local disturbances. +- Water keeps the canonical welded geometry, analytic picking, fog treatment, + and three active draws. Four/two unrolled ripple slots contribute an + analytic Gaussian-ring height and derivative; deeper blue-grey body colour, + restrained Fresnel reflection, directional currents, and bounded foam make + it read as water without breaking the rivers' physical edge weld. +- Canonical forest batches and the immediate procedural fallback carry two + normalized `Uint8` attributes: root-anchored wind weight and local phase. + They retain one draw. Reduced and reduced-motion install no moving shader. +- Ambient ecology uses one tiny two-triangle-per-instance bird mesh and one + points draw shared by motes and transient material particles. It owns no + timer, animation frame, ray target, identifier, network request, or database + state, and is absent in Reduced/reduced-motion and Realm overview. +- Lowlands Rabbits use the exact 14,808-byte, 146-triangle compact static model + from the reviewed public Warpkeep-Assets release. Ten/six deterministic + camera-local instances share one non-pickable draw and transform-only hop; + the same-origin loader verifies byte length, SHA-256, mesh, vertex, and + triangle counts before presentation. No Rabbit transform is game state. +- Worker wakes read only the owning Worker layer's sanitized current pose + after its normal interpolation update. The per-material pool is fixed, + newest-first, and independently capped. Replacement of an oldest live slot + is reported as an aggregate eviction; a genuine failed insert is reported + separately as a drop. Neither report contains identities or positions. + +## Final verification + +The completed branch passed `npm run check`: 272 Vitest files and 2,982 tests, +TypeScript, licensing, all runtime-asset and provenance checks, tracked-file +size policy, production build, production exclusions, and the Farcaster Mini +App contract. Focused shader tests also compile against the pinned Three.js +shader chunks and assert static fallback on marker drift. + +The same fixed-size in-app WebGL pass used for the baseline reported no grass, +water, forest, or Rabbit fallback. High presented 2,789 grass patches / 100,404 +grass triangles / 3 grass draws plus 10 Rabbits; Balanced desktop and portrait +presented 1,243 / 33,561 / 2 plus 6 Rabbits. Existing water and forest topology +and draw counts remain unchanged. + +The canonical `npm run qa:rendered-webgl` command was also re-run and failed +before page launch because the reviewed host Google Chrome executable was +unavailable to its fail-closed attestation. The host browser installation was +not modified; this is reported separately from the successful isolated in-app +WebGL evidence above. + +| Case | Existing grass / water / forest draws | New draws / triangles | Living slots | Ambient cap | +| --- | ---: | ---: | ---: | ---: | +| High 1920×1080 | 3 / 3 / 1 | 3 / 1,484 | grass 8, water 4 | 30 Hz | +| Balanced 1280×720 | 2 / 3 / 1 | 3 / 888 | grass 4, water 2 | 22 Hz | +| Balanced tablet 1024×768 | 2 / 3 / 1 | 3 / 888 | grass 4, water 2 | 22 Hz | +| Balanced portrait 390×844 | 2 / 3 / 1 | 3 / 888 | grass 4, water 2 | 22 Hz | +| Balanced short landscape 667×375 | 2 / 3 / 1 | 3 / 888 | grass 4, water 2 | 22 Hz | +| Reduced 1280×720 | 1 / 3 / 1 | 0 / 0 | grass 0, water 0 | idle | + +High and Balanced canonical forest wind attributes use 763,710 and 454,054 +bytes respectively (two normalized bytes per merged vertex). The active +Balanced Worker fixture held exactly four grass disturbances and 48 transient +particles, replacing oldest fixed slots during sustained motion without a +genuine drop. Its Rabbit model passed the runtime loader with one draw, 876 +triangles, and no fallback; High used 1,460 Rabbit triangles. Reduced held zero +moving ecology, zero Rabbit fetches, zero ripple/disturbance slots, zero new +draws, and no ambient scheduler demand. diff --git a/docs/reference/assets/2026-08-03-lowlands-rabbit/README.md b/docs/reference/assets/2026-08-03-lowlands-rabbit/README.md new file mode 100644 index 00000000..a70a6245 --- /dev/null +++ b/docs/reference/assets/2026-08-03-lowlands-rabbit/README.md @@ -0,0 +1,30 @@ +# Lowlands Rabbit compact runtime record + +This record covers the exact visual-only compact Rabbit integrated into draft +PR #181. The source is the public Warpkeep-Assets release +[`rabbit-runtime-ui-bundle-2026-07-30`](https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/rabbit-runtime-ui-bundle-2026-07-30). +The project owner explicitly requested this Rabbit integration on 2026-08-03. +That instruction authorizes the exact compact runtime in this public Warpkeep +PR, but does not approve merging or deployment and does not create a separate +open-content license. + +| Runtime file | Bytes | Triangles | Uploaded vertices | SHA-256 | +| --- | ---: | ---: | ---: | --- | +| `public/models/hegemony/environment/wildlife/rabbit/hegemony-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb` | 14,808 | 146 | 384 | `2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c` | + +The GLB is glTF 2.0, +Y up, +Z forward, one mesh, one material, no textures, +no external URIs, no skin, and no animation clips. It retains embedded vertex +colors and the supplied `KHR_materials_specular` declaration. The runtime +loader rechecks exact length, SHA-256, primitive count, vertex count, and +triangle count before presentation. + +High and Balanced use this compact mesh as one camera-local instanced draw. +Motion is a renderer-owned transform animation on the existing Realm ambient +scheduler; it is not AI, collision, pathing, ownership, population, or +gameplay state. Rabbits are non-pickable, hidden in overview, absent from +Reduced and reduced-motion, and fail closed if the asset cannot be verified. + +The complete public release also contains rigged High/Balanced LODs and UI +art, but those files are not copied into this runtime PR. The source release +records public archival/distribution authorization and project-owned +provenance while explicitly declining to assert a separate open-license grant. diff --git a/docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json b/docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json new file mode 100644 index 00000000..7879ba44 --- /dev/null +++ b/docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json @@ -0,0 +1,38 @@ +{ + "assetId": "warpkeep.environment.wildlife.rabbit", + "category": "Environment/Wildlife", + "designation": { + "gameplayAuthority": false, + "visualOnly": true + }, + "runtime": { + "bytes": 14808, + "embeddedBufferBytes": 13164, + "file": "Warpkeep_Rabbit_LOD2_Compact_Static_Runtime.glb", + "rigged": false, + "sha256": "2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c", + "triangles": 146, + "uploadedVertices": 384 + }, + "provenance": { + "license": "Project-owned; authored for Warpkeep", + "publicArchiveAuthorization": "authorized by Ael", + "release": "https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/rabbit-runtime-ui-bundle-2026-07-30", + "separateOpenLicense": "not asserted", + "source": "Source/Warpkeep_Rabbit_Editable.blend" + }, + "runtimeContract": { + "collision": false, + "embeddedTextures": 0, + "externalDependencies": 0, + "buffers": 1, + "frontFacing": "+Z", + "materials": 1, + "meshes": 1, + "metersPerUnit": 1, + "pivot": "ground contact centered between hind feet", + "vertexColorAttribute": "COLOR_0 / WK_Color" + }, + "schema": "warpkeep.rabbit-runtime-integration-record.v1", + "version": "1.0.0" +} diff --git a/public/models/hegemony/environment/wildlife/rabbit/hegemony-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb b/public/models/hegemony/environment/wildlife/rabbit/hegemony-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb new file mode 100644 index 00000000..19115cdc Binary files /dev/null and b/public/models/hegemony/environment/wildlife/rabbit/hegemony-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb differ diff --git a/scripts/install-lowlands-rabbit-runtime.mjs b/scripts/install-lowlands-rabbit-runtime.mjs new file mode 100644 index 00000000..e5c403a2 --- /dev/null +++ b/scripts/install-lowlands-rabbit-runtime.mjs @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; + +import { + ensureContainedDirectory, + installAtomicFileFamily, + readContainedRegularFile +} from './atomic-install-file-family.mjs'; + +const root = resolve(import.meta.dirname, '..'); +const suppliedRoot = process.env.WARPKEEP_RABBIT_RUNTIME_ROOT + ? resolve(process.env.WARPKEEP_RABBIT_RUNTIME_ROOT) + : undefined; +const sourceFilename = 'Warpkeep_Rabbit_LOD2_Compact_Static_Runtime.glb'; +const destinationFilename = + 'hegemony-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb'; +const expectedBytes = 14_808; +const expectedHash = + '2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c'; + +if (!suppliedRoot) { + throw new Error( + 'Set WARPKEEP_RABBIT_RUNTIME_ROOT to the exact Rabbit Runtime/Environment/Wildlife/Rabbit directory.' + ); +} + +const bytes = readContainedRegularFile({ + root: suppliedRoot, + relativePath: sourceFilename, + label: 'Lowlands Rabbit compact supplied runtime', + expectedBytes +}); +const hash = createHash('sha256').update(bytes).digest('hex'); +if (hash !== expectedHash) { + throw new Error('Lowlands Rabbit compact supplied runtime hash changed: ' + hash + '.'); +} +const jsonLength = bytes.readUInt32LE(12); +const json = JSON.parse(bytes.subarray(20, 20 + jsonLength).toString('utf8').trim()); +const primitive = json.meshes?.[0]?.primitives?.[0]; +const embeddedBuffer = json.buffers?.[0]; +if ( + bytes.subarray(0, 4).toString('ascii') !== 'glTF' + || bytes.readUInt32LE(4) !== 2 + || bytes.readUInt32LE(8) !== bytes.byteLength + || json.asset?.copyright !== 'Copyright Ael / Warpkeep; project-authored rabbit runtime asset' + || json.scenes?.length !== 1 + || json.scenes[0]?.name !== 'WK_Rabbit_AuthoringScene' + || json.nodes?.length !== 1 + || json.nodes[0]?.name !== 'WK_Rabbit_LOD2_Compact_Static' + || json.meshes?.length !== 1 + || json.meshes[0]?.name !== 'WK_Rabbit_LOD2_Compact_Static_Mesh' + || json.meshes[0]?.primitives?.length !== 1 + || primitive?.indices !== 3 + || primitive?.material !== 0 + || primitive?.attributes?.POSITION !== 0 + || primitive?.attributes?.NORMAL !== 1 + || primitive?.attributes?.COLOR_0 !== 2 + || json.accessors?.[0]?.count !== 384 + || json.accessors?.[3]?.count !== 438 + || json.materials?.length !== 1 + || json.buffers?.length !== 1 + || embeddedBuffer?.byteLength !== 13_164 + || Object.prototype.hasOwnProperty.call(embeddedBuffer ?? {}, 'uri') + || json.images !== undefined + || json.textures !== undefined + || json.samplers !== undefined + || json.animations !== undefined + || json.skins !== undefined +) { + throw new Error('Lowlands Rabbit compact supplied runtime structure changed.'); +} + +const destinationRoot = ensureContainedDirectory({ + root, + relativePath: 'public/models/hegemony/environment/wildlife/rabbit', + label: 'Lowlands Rabbit runtime directory' +}); +installAtomicFileFamily({ + destinationRoot, + entries: [{ + bytes, + label: 'Lowlands Rabbit compact runtime', + relativePath: destinationFilename + }] +}); +console.log( + 'Lowlands Rabbit compact: 14,808 bytes, 146 triangles, sha256 ' + expectedHash +); diff --git a/scripts/verify-runtime-assets.mjs b/scripts/verify-runtime-assets.mjs index 622db3b0..a138bb9c 100644 --- a/scripts/verify-runtime-assets.mjs +++ b/scripts/verify-runtime-assets.mjs @@ -11,6 +11,10 @@ import { import { inspectEmbeddedWebpGlb } from './rewrite-embedded-webp-glb.mjs'; const root = resolve(import.meta.dirname, '..'); +const lowlandsRabbitRuntimeDirectory = + 'public/models/hegemony/environment/wildlife/rabbit'; +const lowlandsRabbitRuntimePath = + `${lowlandsRabbitRuntimeDirectory}/hegemony-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb`; assertNoStaleAtomicFamilyTransactions( resolve(root, 'public/models/hegemony'), 'Hegemony runtime model directory' @@ -18,6 +22,7 @@ assertNoStaleAtomicFamilyTransactions( const assets = Object.freeze([ ['public/models/title/warpkeep-title-high.glb', 3_844_364, '2354a57d88be80e5568afb5754102c20c9ea0fe9a83aa5ac49c0d8dd67ae9ff5', true], ['public/models/title/warpkeep-title-compact.glb', 1_714_060, 'd29435dfa3a5fbf5103a825cc00bb3ffcef7694167a7fb7303fa89af242d7af8', true], + [lowlandsRabbitRuntimePath, 14_808, '2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c', true], ['public/models/hegemony/hegemony-main-castle-high-9fe06a26446387e0.glb', 2_215_972, '9fe06a26446387e007ea32acfccbf6657e7a6763d73e2cb3890f103fb590afe8', true], ['public/models/hegemony/hegemony-main-castle-balanced-a9df1a9acd36e720.glb', 892_788, 'a9df1a9acd36e7208b764396854053a6e3c591f2eb04a83a6e2437c55a3aa157', true], ['public/models/hegemony/hegemony-main-castle-compact-b665d75e10e3e289.glb', 453_628, 'b665d75e10e3e289dac09ebb9f0eeec75469dda77fb25265b03b5ad6081c627b', true], @@ -314,6 +319,7 @@ const expectedHegemonyGlbNames = new Set( assets .map(([path]) => path) .filter((path) => path.startsWith('public/models/hegemony/') && path.endsWith('.glb')) + .filter((path) => path.split('/').length === 4) .map((path) => basename(path)) ); const observedHegemonyGlbEntries = readdirSync(resolve(root, 'public/models/hegemony'), { @@ -345,6 +351,52 @@ if ( ); } +const expectedLowlandsRabbitGlbName = basename(lowlandsRabbitRuntimePath); +const observedLowlandsRabbitEntries = readdirSync( + resolve(root, lowlandsRabbitRuntimeDirectory), + { withFileTypes: true } +); +if ( + observedLowlandsRabbitEntries.length !== 1 + || observedLowlandsRabbitEntries[0]?.name !== expectedLowlandsRabbitGlbName + || !observedLowlandsRabbitEntries[0]?.isFile() +) { + throw new Error( + 'Lowlands Rabbit runtime directory must contain only the exact reviewed compact GLB.' + ); +} + +const lowlandsRabbitRecord = JSON.parse(readContainedRegularFile({ + root, + relativePath: 'docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json', + label: 'Lowlands Rabbit runtime provenance record' +}).toString('utf8')); +if ( + lowlandsRabbitRecord.schema !== 'warpkeep.rabbit-runtime-integration-record.v1' + || lowlandsRabbitRecord.assetId !== 'warpkeep.environment.wildlife.rabbit' + || lowlandsRabbitRecord.designation?.gameplayAuthority !== false + || lowlandsRabbitRecord.designation?.visualOnly !== true + || lowlandsRabbitRecord.runtime?.file + !== 'Warpkeep_Rabbit_LOD2_Compact_Static_Runtime.glb' + || lowlandsRabbitRecord.runtime?.bytes !== 14_808 + || lowlandsRabbitRecord.runtime?.embeddedBufferBytes !== 13_164 + || lowlandsRabbitRecord.runtime?.sha256 + !== '2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c' + || lowlandsRabbitRecord.runtime?.triangles !== 146 + || lowlandsRabbitRecord.runtime?.uploadedVertices !== 384 + || lowlandsRabbitRecord.runtimeContract?.buffers !== 1 + || lowlandsRabbitRecord.runtimeContract?.embeddedTextures !== 0 + || lowlandsRabbitRecord.runtimeContract?.externalDependencies !== 0 + || lowlandsRabbitRecord.runtimeContract?.materials !== 1 + || lowlandsRabbitRecord.runtimeContract?.meshes !== 1 + || lowlandsRabbitRecord.provenance?.release + !== 'https://github.com/ael-dev3/Warpkeep-Assets/releases/tag/rabbit-runtime-ui-bundle-2026-07-30' +) { + throw new Error( + 'Lowlands Rabbit runtime provenance record does not match the reviewed compact asset.' + ); +} + const requiredCastleExtensions = Object.freeze([ 'EXT_meshopt_compression', 'EXT_texture_webp', @@ -389,7 +441,7 @@ for (const relativePath of retiredAdmissionRequestRuntimeAssets) { for (const [relativePath, expectedBytes, expectedHash, glb] of assets) { if ( - hegemonyModelStructure.has(relativePath) + (hegemonyModelStructure.has(relativePath) || relativePath === lowlandsRabbitRuntimePath) && !relativePath.endsWith(`-${expectedHash.slice(0, 16)}.glb`) ) { throw new Error(`${relativePath} must carry its SHA-256 prefix as an immutable cache coordinate.`); @@ -414,6 +466,52 @@ for (const [relativePath, expectedBytes, expectedHash, glb] of assets) { || bytes.readUInt32LE(8) !== bytes.byteLength )) throw new Error(`${relativePath} is not an intact glTF 2.0 binary.`); + if (relativePath === lowlandsRabbitRuntimePath) { + const jsonLength = bytes.readUInt32LE(12); + const jsonEnd = 20 + jsonLength; + const json = JSON.parse(bytes.subarray(20, jsonEnd).toString('utf8').trim()); + const primitive = json.meshes?.[0]?.primitives?.[0]; + const position = json.accessors?.[primitive?.attributes?.POSITION]; + const indices = json.accessors?.[primitive?.indices]; + const embeddedBuffer = json.buffers?.[0]; + if ( + json.asset?.copyright !== 'Copyright Ael / Warpkeep; project-authored rabbit runtime asset' + || json.asset?.generator !== 'Khronos glTF Blender I/O v5.2.39' + || json.scene !== 0 + || json.scenes?.length !== 1 + || json.scenes[0]?.name !== 'WK_Rabbit_AuthoringScene' + || !exactVector(json.scenes[0]?.nodes, [0]) + || json.nodes?.length !== 1 + || json.nodes[0]?.name !== 'WK_Rabbit_LOD2_Compact_Static' + || json.nodes[0]?.mesh !== 0 + || json.nodes[0]?.extras?.wk_asset !== 'rabbit' + || json.nodes[0]?.extras?.wk_lod !== 'LOD2_Compact' + || json.meshes?.length !== 1 + || json.meshes[0]?.name !== 'WK_Rabbit_LOD2_Compact_Static_Mesh' + || json.meshes[0]?.primitives?.length !== 1 + || !exactRecord(primitive?.attributes, { POSITION: 0, NORMAL: 1, COLOR_0: 2 }) + || primitive?.indices !== 3 + || primitive?.material !== 0 + || position?.count !== 384 + || position?.componentType !== 5126 + || position?.type !== 'VEC3' + || indices?.count !== 438 + || indices?.componentType !== 5123 + || json.materials?.length !== 1 + || json.materials[0]?.name !== 'WK_Rabbit_VertexColor_PBR' + || json.materials[0]?.doubleSided !== true + || json.buffers?.length !== 1 + || embeddedBuffer?.byteLength !== 13_164 + || Object.prototype.hasOwnProperty.call(embeddedBuffer ?? {}, 'uri') + || json.images !== undefined + || json.textures !== undefined + || json.samplers !== undefined + || json.animations !== undefined + || json.skins !== undefined + || !exactVector(json.extensionsUsed, ['KHR_materials_specular']) + ) throw new Error(`${relativePath} structure no longer matches the reviewed compact Rabbit profile.`); + } + const expectedStructure = hegemonyModelStructure.get(relativePath); if (expectedStructure) { const jsonLength = bytes.readUInt32LE(12); diff --git a/src/components/realm/RealmMapScreen.tsx b/src/components/realm/RealmMapScreen.tsx index ffc7cb0d..6b88aa6c 100644 --- a/src/components/realm/RealmMapScreen.tsx +++ b/src/components/realm/RealmMapScreen.tsx @@ -319,6 +319,8 @@ type RealmMapScreenProps = Readonly<{ presentationMode?: 'player' | 'observer'; /** DEV-only phase/coordinate projection evidence for the synthetic QA fixture. */ localQaWorkerProjectionTelemetry?: boolean; + /** DEV-only frozen Living Realm clock; ignored outside observer fixtures. */ + localQaLivingVisualTimeSeconds?: number; }>; type RendererMode = 'loading' | 'webgl' | 'fallback'; @@ -4790,6 +4792,9 @@ function CanonicalRealmMapScreen(props: RealmMapScreenProps) { terrainMetadata: projectedTileMetadata, quality: qualitySpec, reducedMotion, + livingVisualTimeSeconds: observerMode + ? props.localQaLivingVisualTimeSeconds + : undefined, baseUrl: import.meta.env.BASE_URL || '/', isCoordPassable: isSceneCoordPassable, onCameraModeChange: (mode) => { @@ -5233,6 +5238,7 @@ function CanonicalRealmMapScreen(props: RealmMapScreenProps) { keepCoord, markRendererFailure, observerMode, + props.localQaLivingVisualTimeSeconds, ownCastle.castleId, peerCastles, projectedTileMetadata, diff --git a/src/components/realm/createLowPolyGrassGeometry.ts b/src/components/realm/createLowPolyGrassGeometry.ts index 2921f540..a7d463e4 100644 --- a/src/components/realm/createLowPolyGrassGeometry.ts +++ b/src/components/realm/createLowPolyGrassGeometry.ts @@ -8,8 +8,8 @@ export type RealmGrassGeometryProfile = 'high' | 'balanced' | 'reduced'; * variation for deterministic variants to read as a small meadow, not a spike. */ export const REALM_GRASS_BLADES_PER_PATCH: Readonly> = Object.freeze({ - high: 9, - balanced: 7, + high: 12, + balanced: 9, reduced: 5 }); export const REALM_GRASS_VARIANT_COUNTS: Readonly> = Object.freeze({ @@ -78,7 +78,10 @@ const ROOTS: Readonly> = Obje root(-0.42, 0.17), root(0.02, 0.44), root(-0.28, -0.31), - root(0.41, -0.19) + root(0.41, -0.19), + root(-0.08, -0.11), + root(0.18, 0.04), + root(-0.22, 0.39) ]), balanced: Object.freeze([ root(-0.34, -0.05), @@ -87,7 +90,9 @@ const ROOTS: Readonly> = Obje root(-0.12, 0.22), root(0.27, 0.29), root(-0.42, 0.17), - root(0.02, 0.44) + root(0.02, 0.44), + root(-0.28, -0.31), + root(0.18, 0.04) ]), reduced: Object.freeze([root(-0.34, -0.05), root(0.11, -0.39), root(0.39, 0.08), root(-0.12, 0.22), root(0.27, 0.29)]) }); diff --git a/src/components/realm/createRealmAmbientEcologyLayer.ts b/src/components/realm/createRealmAmbientEcologyLayer.ts new file mode 100644 index 00000000..25885ac6 --- /dev/null +++ b/src/components/realm/createRealmAmbientEcologyLayer.ts @@ -0,0 +1,264 @@ +import * as THREE from 'three'; + +import type { RealmLivingRealmBudget } from './realmQuality'; +import type { RealmSurfaceDisturbanceSnapshot } from './realmSurfaceDisturbanceField'; + +export type RealmAmbientEcologyTelemetry = Readonly<{ + enabled: boolean; + animated: boolean; + overviewHidden: boolean; + birdCount: number; + moteCount: number; + transientParticleCount: number; + transientParticleCapacity: number; + drawCalls: number; + triangleCount: number; + plannerHz: number; + plannerTickCount: number; +}>; + +export type RealmAmbientEcologyLayer = Readonly<{ + group: THREE.Group; + update: ( + elapsedSeconds: number, + focus: Readonly<{ x: number; y?: number; z: number }>, + mode: 'realm' | 'approach' | 'keep', + disturbances?: RealmSurfaceDisturbanceSnapshot | null + ) => boolean; + isAnimationActive: () => boolean; + getTelemetry: () => RealmAmbientEcologyTelemetry; + dispose: () => void; +}>; + +export type CreateRealmAmbientEcologyLayerOptions = Readonly<{ + budget: RealmLivingRealmBudget; + /** Deterministic rendered-QA seam; production follows scheduler elapsed time. */ + frozenVisualTimeSeconds?: number; +}>; + +const BIRD_TRIANGLES = 2; + +function birdGeometry() { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute([ + 0, 0, 0.1, + -0.3, 0.02, -0.08, + -0.04, 0, -0.02, + 0, 0, 0.1, + 0.04, 0, -0.02, + 0.3, 0.02, -0.08 + ], 3)); + geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + return geometry; +} + +function hashUnit(index: number, salt: number) { + const value = Math.sin((index + 1) * 91.733 + salt * 47.119) * 43_758.5453; + return value - Math.floor(value); +} + +export function createRealmAmbientEcologyLayer( + options: CreateRealmAmbientEcologyLayerOptions +): RealmAmbientEcologyLayer { + const budget = options.budget; + const group = new THREE.Group(); + group.name = 'realm-living-ambient-ecology'; + group.visible = false; + const enabled = budget.birdInstances > 0 + || budget.moteCount > 0 + || budget.transientParticleCount > 0; + let birdMesh: THREE.InstancedMesh | undefined; + let birdMaterial: THREE.MeshBasicMaterial | undefined; + let birds: THREE.BufferGeometry | undefined; + let pointCloud: THREE.Points | undefined; + let pointMaterial: THREE.PointsMaterial | undefined; + let points: THREE.BufferGeometry | undefined; + let pointPositions: THREE.BufferAttribute | undefined; + if (budget.birdInstances > 0) { + birds = birdGeometry(); + birdMaterial = new THREE.MeshBasicMaterial({ + color: '#53655a', + side: THREE.DoubleSide, + transparent: false, + depthWrite: true, + fog: true, + toneMapped: true + }); + birdMesh = new THREE.InstancedMesh( + birds, + birdMaterial, + budget.birdInstances + ); + birdMesh.name = 'realm-living-birds'; + birdMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + birdMesh.frustumCulled = false; + birdMesh.raycast = () => {}; + group.add(birdMesh); + } + const pointCapacity = budget.moteCount + budget.transientParticleCount; + if (pointCapacity > 0) { + points = new THREE.BufferGeometry(); + pointPositions = new THREE.BufferAttribute( + new Float32Array(pointCapacity * 3), + 3 + ); + pointPositions.setUsage(THREE.DynamicDrawUsage); + points.setAttribute('position', pointPositions); + points.setDrawRange(0, 0); + points.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 16); + pointMaterial = new THREE.PointsMaterial({ + color: '#f1d486', + size: 0.055, + sizeAttenuation: true, + transparent: true, + opacity: 0.56, + depthWrite: false, + fog: true, + toneMapped: true + }); + pointCloud = new THREE.Points(points, pointMaterial); + pointCloud.name = 'realm-living-motes-and-transients'; + pointCloud.frustumCulled = false; + pointCloud.raycast = () => {}; + group.add(pointCloud); + } + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const rotation = new THREE.Quaternion(); + const scale = new THREE.Vector3(1, 1, 1); + const up = new THREE.Vector3(0, 1, 0); + let disposed = false; + let overviewHidden = true; + let lastTime = -1; + let lastFocusX = Number.NaN; + let lastFocusY = Number.NaN; + let lastFocusZ = Number.NaN; + let lastPlannerSeconds = Number.NEGATIVE_INFINITY; + let plannerTickCount = 0; + let transientParticleCount = 0; + + const visualTime = (elapsedSeconds: number) => Number.isFinite( + options.frozenVisualTimeSeconds + ) + ? Math.max(0, options.frozenVisualTimeSeconds!) + : Math.max(0, Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0); + + const telemetry = (): RealmAmbientEcologyTelemetry => Object.freeze({ + enabled: enabled && !disposed, + animated: enabled && !disposed && !overviewHidden, + overviewHidden, + birdCount: disposed || overviewHidden ? 0 : budget.birdInstances, + moteCount: disposed || overviewHidden ? 0 : budget.moteCount, + transientParticleCount: disposed || overviewHidden ? 0 : transientParticleCount, + transientParticleCapacity: disposed ? 0 : budget.transientParticleCount, + drawCalls: disposed || overviewHidden + ? 0 + : Number(budget.birdInstances > 0) + Number(pointCapacity > 0), + triangleCount: disposed || overviewHidden + ? 0 + : budget.birdInstances * BIRD_TRIANGLES, + plannerHz: budget.plannerHz, + plannerTickCount + }); + + return Object.freeze({ + group, + update: (elapsedSeconds, focus, mode, disturbances = null) => { + if (disposed || !enabled) return false; + const time = visualTime(elapsedSeconds); + const focusX = Number.isFinite(focus.x) ? focus.x : 0; + const focusY = Number.isFinite(focus.y) ? focus.y! : 0; + const focusZ = Number.isFinite(focus.z) ? focus.z : 0; + const nextOverviewHidden = mode === 'realm'; + const changed = nextOverviewHidden !== overviewHidden + || time !== lastTime + || focusX !== lastFocusX + || focusY !== lastFocusY + || focusZ !== lastFocusZ; + overviewHidden = nextOverviewHidden; + group.visible = !overviewHidden; + if (overviewHidden) { + lastTime = time; + lastFocusX = focusX; + lastFocusY = focusY; + lastFocusZ = focusZ; + transientParticleCount = 0; + return changed; + } + if (budget.plannerHz > 0 && time - lastPlannerSeconds >= 1 / budget.plannerHz) { + lastPlannerSeconds = time; + plannerTickCount += 1; + } + if (birdMesh) { + for (let index = 0; index < budget.birdInstances; index += 1) { + const lane = index % 3; + const orbit = time * (0.12 + lane * 0.018) + hashUnit(index, 2) * Math.PI * 2; + const radius = 2.8 + hashUnit(index, 3) * 3.6; + position.set( + focusX + Math.cos(orbit) * radius, + focusY + 1.15 + hashUnit(index, 4) * 0.72 + Math.sin(time * 0.8 + index) * 0.08, + focusZ + Math.sin(orbit) * radius + ); + rotation.setFromAxisAngle(up, -orbit + Math.PI * 0.5); + const flap = 0.82 + Math.sin(time * 4.2 + index * 1.7) * 0.12; + scale.set(flap, 1, 0.82); + matrix.compose(position, rotation, scale); + birdMesh.setMatrixAt(index, matrix); + } + birdMesh.instanceMatrix.needsUpdate = true; + } + transientParticleCount = Math.min( + budget.transientParticleCount, + Math.max(0, Math.trunc(disturbances?.count ?? 0)) * 12 + ); + if (points && pointPositions) { + for (let index = 0; index < budget.moteCount; index += 1) { + const angle = hashUnit(index, 11) * Math.PI * 2 + time * 0.035; + const radius = 0.8 + hashUnit(index, 12) * 4.8; + pointPositions.setXYZ( + index, + focusX + Math.cos(angle) * radius, + focusY + 0.12 + hashUnit(index, 13) * 0.72 + + Math.sin(time * 0.48 + index) * 0.06, + focusZ + Math.sin(angle) * radius + ); + } + for (let index = 0; index < transientParticleCount; index += 1) { + const sourceCount = Math.max(1, Math.trunc(disturbances?.count ?? 0)); + const source = index % sourceCount; + const centerX = disturbances?.centers[source * 2] ?? focusX; + const centerZ = disturbances?.centers[source * 2 + 1] ?? focusZ; + const age = disturbances?.params[source * 4 + 2] ?? 0; + const angle = hashUnit(index, 21) * Math.PI * 2; + const radius = (0.08 + hashUnit(index, 22) * 0.5) * (0.3 + age); + pointPositions.setXYZ( + budget.moteCount + index, + centerX + Math.cos(angle) * radius, + focusY + 0.035 + hashUnit(index, 23) * 0.16 + age * 0.12, + centerZ + Math.sin(angle) * radius + ); + } + points.setDrawRange(0, budget.moteCount + transientParticleCount); + pointPositions.needsUpdate = true; + } + lastTime = time; + lastFocusX = focusX; + lastFocusY = focusY; + lastFocusZ = focusZ; + return changed || transientParticleCount > 0; + }, + isAnimationActive: () => enabled && !disposed && !overviewHidden, + getTelemetry: telemetry, + dispose: () => { + if (disposed) return; + disposed = true; + group.clear(); + birdMesh?.dispose(); + birds?.dispose(); + birdMaterial?.dispose(); + points?.dispose(); + pointMaterial?.dispose(); + } + }); +} diff --git a/src/components/realm/createRealmForestWindMaterial.ts b/src/components/realm/createRealmForestWindMaterial.ts new file mode 100644 index 00000000..b8a08f92 --- /dev/null +++ b/src/components/realm/createRealmForestWindMaterial.ts @@ -0,0 +1,118 @@ +import * as THREE from 'three'; + +import { + REALM_LIVING_ENVIRONMENT_REVISION, + REALM_LIVING_GUST_GLSL, + REALM_LIVING_WIND_GLSL +} from './realmLivingEnvironment'; + +export const REALM_FOREST_WIND_SHADER_CONTRACT = `realm-forest-wind-v1-${REALM_LIVING_ENVIRONMENT_REVISION}-three-r185`; +export const REALM_FOREST_LIVING_CANOPY_MOTION_STATE = 'shared-gust' as const; + +export type RealmForestWindMaterialController = Readonly<{ + setTime: (seconds: number) => boolean; + isActive: () => boolean; + getTelemetry: () => Readonly<{ + enabled: boolean; + fallbackActive: boolean; + fallbackCount: number; + fallbackReason: string | null; + }>; +}>; + +export function injectRealmForestWindVertexShader(vertexShader: string) { + const marker = '#include '; + if (!vertexShader.includes(marker)) { + throw new Error('REALM_FOREST_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED'); + } + return ` +attribute float realmForestWindWeight; +attribute float realmForestWindPhase; +uniform float uRealmForestTime; +${REALM_LIVING_GUST_GLSL} +${vertexShader.replace(marker, `${marker} + mat4 realmForestWorldMatrix = modelMatrix; + #ifdef USE_INSTANCING + realmForestWorldMatrix = modelMatrix * instanceMatrix; + #endif + vec4 realmForestWorldPosition = realmForestWorldMatrix * vec4(position, 1.0); + vec2 realmForestWindDirection = ${REALM_LIVING_WIND_GLSL}; + mat3 realmForestBasis = mat3(realmForestWorldMatrix); + mat2 realmForestLocalToWorldXZ = mat2(realmForestBasis[0].xz, realmForestBasis[2].xz); + float realmForestBasisDeterminant = determinant(realmForestLocalToWorldXZ); + mat2 realmForestWorldToLocalXZ = abs(realmForestBasisDeterminant) > 0.000001 + ? inverse(realmForestLocalToWorldXZ) + : mat2(1.0); + float realmForestGust = realmLivingGust(realmForestWorldPosition.xz, uRealmForestTime); + float realmForestSway = sin( + uRealmForestTime * 0.72 + + realmForestWindPhase * 6.283185 + + dot(realmForestWorldPosition.xz, realmForestWindDirection) * 0.11 + ) * mix(0.28, 1.0, realmForestGust); + float realmForestRootedWeight = realmForestWindWeight * realmForestWindWeight; + transformed.xz += (realmForestWorldToLocalXZ * realmForestWindDirection) + * realmForestSway * realmForestRootedWeight * 0.028; +`)}`; +} + +export function applyRealmForestWindMaterial( + material: THREE.MeshStandardMaterial, + enabled: boolean +): RealmForestWindMaterialController { + const uniforms = { uRealmForestTime: { value: 0 } }; + let fallbackActive = false; + let fallbackCount = 0; + let fallbackReason: string | null = null; + let disposed = false; + let lastTime = 0; + material.userData.realmForestWindEnabled = enabled; + material.userData.realmForestWindUniforms = uniforms; + material.userData.realmForestWindFallbackActive = false; + material.userData.realmForestWindFallbackCount = 0; + material.userData.realmForestWindFallbackReason = null; + if (enabled) { + material.onBeforeCompile = (shader) => { + if (fallbackActive) return; + const originalVertexShader = shader.vertexShader; + try { + shader.vertexShader = injectRealmForestWindVertexShader(originalVertexShader); + shader.uniforms.uRealmForestTime = uniforms.uRealmForestTime; + } catch (error) { + shader.vertexShader = originalVertexShader; + fallbackActive = true; + fallbackCount += 1; + fallbackReason = error instanceof Error + ? error.message + : 'REALM_FOREST_SHADER_INJECTION_FAILED'; + material.userData.realmForestWindFallbackActive = true; + material.userData.realmForestWindFallbackCount = fallbackCount; + material.userData.realmForestWindFallbackReason = fallbackReason; + } + }; + material.customProgramCacheKey = () => fallbackActive + ? `${REALM_FOREST_WIND_SHADER_CONTRACT}:static-fallback` + : REALM_FOREST_WIND_SHADER_CONTRACT; + } + return Object.freeze({ + setTime: (seconds) => { + if ( + disposed + || !enabled + || fallbackActive + || !Number.isFinite(seconds) + ) return false; + const safeSeconds = Math.max(0, seconds); + if (Math.abs(safeSeconds - lastTime) < 0.000001) return false; + lastTime = safeSeconds; + uniforms.uRealmForestTime.value = safeSeconds; + return true; + }, + isActive: () => !disposed && enabled && !fallbackActive, + getTelemetry: () => Object.freeze({ + enabled, + fallbackActive, + fallbackCount, + fallbackReason + }) + }); +} diff --git a/src/components/realm/createRealmGrassLayer.ts b/src/components/realm/createRealmGrassLayer.ts index 9ae36450..547422f1 100644 --- a/src/components/realm/createRealmGrassLayer.ts +++ b/src/components/realm/createRealmGrassLayer.ts @@ -29,6 +29,8 @@ import { REALM_GRASS_VARIANT_COUNTS } from './createLowPolyGrassGeometry'; import { createRealmGrassMaterial, REALM_GRASS_MAX_WIND_SWAY } from './createRealmGrassMaterial'; +import type { RealmLivingRealmBudget } from './realmQuality'; +import type { RealmSurfaceDisturbanceSnapshot } from './realmSurfaceDisturbanceField'; import { createRealmGrassCellCache, resolveRealmGrassActiveWindow, @@ -82,6 +84,8 @@ export type RealmGrassTelemetry = Readonly<{ activeSandCellCount: number; averageSandCoverageOfActiveCells: number; overviewHidden: boolean; + disturbanceSlotCount: number; + activeDisturbanceCount: number; }>; export type CreateRealmGrassLayerOptions = Readonly<{ @@ -100,6 +104,7 @@ export type CreateRealmGrassLayerOptions = Readonly<{ isWorldExcluded?: (world: HexWorldPosition) => boolean; visualizeLegacyLakes?: boolean; suppressCastleSlots?: boolean; + livingBudget?: RealmLivingRealmBudget; }>; export type RealmGrassLayer = Readonly<{ @@ -110,7 +115,7 @@ export type RealmGrassLayer = Readonly<{ updateView: (focus: HexWorldPosition, mode: RealmGrassCameraMode) => boolean; /** Mark camera-local trunk/root exclusions dirty for the next view update. */ invalidateExclusions: () => boolean; - updateWind: (seconds: number) => boolean; + updateWind: (seconds: number, disturbances?: RealmSurfaceDisturbanceSnapshot | null) => boolean; setInteraction: (selected: HexCoord | null, hovered: HexCoord | null) => void; isAnimationActive: () => boolean; getTelemetry: () => RealmGrassTelemetry; @@ -202,7 +207,9 @@ function emptyTelemetry(plan: RealmGrassRenderPlan, alphaToCoverage = false): Re retainedInDryTransition: 0, activeSandCellCount: 0, averageSandCoverageOfActiveCells: 0, - overviewHidden: true + overviewHidden: true, + disturbanceSlotCount: 0, + activeDisturbanceCount: 0 }); } @@ -238,7 +245,8 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re const materialLayer = createRealmGrassMaterial( options.reducedMotion ? 0 : plan.windStrengthMultiplier, !options.reducedMotion && plan.animationFrameCap > 0, - options.alphaToCoverage ?? false + options.alphaToCoverage ?? false, + options.livingBudget?.grassDisturbanceSlots ?? 0 ); const geometries = Array.from({ length: variantCount }, (_, variant) => createLowPolyGrassGeometry(plan.geometryProfile, variant) @@ -529,7 +537,9 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re activeSandCellCount, averageSandCoverageOfActiveCells: activeCellSandCoverageTotal / Math.max(1, activeCellCount), - overviewHidden: false + overviewHidden: false, + disturbanceSlotCount: shaderTelemetry.disturbanceSlotCount, + activeDisturbanceCount: shaderTelemetry.activeDisturbanceCount }); if (telemetry.triangleCount > plan.maximumActiveTriangles) { throw new Error('REALM_GRASS_TRIANGLE_BUDGET_EXCEEDED'); @@ -557,13 +567,14 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re exclusionsDirty = true; return true; }, - updateWind: (seconds) => { + updateWind: (seconds, disturbances = null) => { if ( disposed || !telemetry.animated || materialLayer.getShaderTelemetry().fallbackActive ) return false; - return materialLayer.setTime(seconds); + const disturbancesChanged = materialLayer.setDisturbances(disturbances); + return materialLayer.setTime(seconds) || disturbancesChanged; }, setInteraction: (selected, hovered) => { if (disposed) return; @@ -578,13 +589,17 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re telemetry.shaderFallbackActive === shaderTelemetry.fallbackActive && telemetry.shaderFallbackCount === shaderTelemetry.fallbackCount && telemetry.shaderFallbackReason === shaderTelemetry.fallbackReason + && telemetry.disturbanceSlotCount === shaderTelemetry.disturbanceSlotCount + && telemetry.activeDisturbanceCount === shaderTelemetry.activeDisturbanceCount ) return telemetry; return Object.freeze({ ...telemetry, animated: telemetry.animated && !shaderTelemetry.fallbackActive, shaderFallbackActive: shaderTelemetry.fallbackActive, shaderFallbackCount: shaderTelemetry.fallbackCount, - shaderFallbackReason: shaderTelemetry.fallbackReason + shaderFallbackReason: shaderTelemetry.fallbackReason, + disturbanceSlotCount: shaderTelemetry.disturbanceSlotCount, + activeDisturbanceCount: shaderTelemetry.activeDisturbanceCount }); }, dispose: () => { diff --git a/src/components/realm/createRealmGrassMaterial.ts b/src/components/realm/createRealmGrassMaterial.ts index 21ebf464..71c7e9e7 100644 --- a/src/components/realm/createRealmGrassMaterial.ts +++ b/src/components/realm/createRealmGrassMaterial.ts @@ -1,9 +1,15 @@ import * as THREE from 'three'; import { REALM_PREVAILING_WIND } from '../../game/map/realmPrevailingWind'; +import { REALM_SUN_DIRECTION } from './createRealmEnvironment'; +import { + REALM_LIVING_ENVIRONMENT_REVISION, + REALM_LIVING_GUST_GLSL +} from './realmLivingEnvironment'; +import type { RealmSurfaceDisturbanceSnapshot } from './realmSurfaceDisturbanceField'; export const REALM_GRASS_THREE_SHADER_CONTRACT = 'three-r185'; -export const REALM_GRASS_SHADER_CACHE_KEY = `warpkeep-procedural-grass-v2-natural-gust-v6-bounded-tips-${REALM_GRASS_THREE_SHADER_CONTRACT}`; +export const REALM_GRASS_SHADER_CACHE_KEY = `warpkeep-procedural-grass-v3-living-gust-bent-normals-transmission-${REALM_LIVING_ENVIRONMENT_REVISION}-${REALM_GRASS_THREE_SHADER_CONTRACT}`; export const REALM_GRASS_MAX_WIND_SWAY = 0.075; export const REALM_GRASS_CROSS_WIND_RATIO = 0.16; export const REALM_GRASS_MAX_PRIMARY_BEND = REALM_GRASS_MAX_WIND_SWAY / Math.hypot(1, REALM_GRASS_CROSS_WIND_RATIO); @@ -20,6 +26,10 @@ export type RealmGrassUniforms = Readonly<{ uGrassHoveredCell: THREE.IUniform; uGrassInteractionProgress: THREE.IUniform; uGrassInteractionFlattening: THREE.IUniform; + uGrassSunDirection: THREE.IUniform; + uGrassDisturbanceCount: THREE.IUniform; + uGrassDisturbanceCenters: THREE.IUniform; + uGrassDisturbanceParams: THREE.IUniform; }>; export type RealmGrassMaterial = Readonly<{ @@ -30,6 +40,7 @@ export type RealmGrassMaterial = Readonly<{ hovered: Readonly<{ q: number; r: number }> | null ) => void; setTime: (seconds: number) => boolean; + setDisturbances: (snapshot: RealmSurfaceDisturbanceSnapshot | null) => boolean; setVisible: (visible: boolean) => void; getShaderTelemetry: () => RealmGrassShaderTelemetry; dispose: () => void; @@ -39,6 +50,8 @@ export type RealmGrassShaderTelemetry = Readonly<{ fallbackActive: boolean; fallbackCount: number; fallbackReason: string | null; + disturbanceSlotCount: number; + activeDisturbanceCount: number; }>; const NO_SELECTED_CELL = 100_000; @@ -60,15 +73,18 @@ uniform vec2 uGrassSelectedCell; uniform vec2 uGrassHoveredCell; uniform float uGrassInteractionProgress; uniform float uGrassInteractionFlattening; +uniform vec3 uGrassSunDirection; varying float vGrassEdgeFade; varying float vGrassBladeAcross; varying float vGrassBladeVertical; +varying float vGrassSunTransmission; `; const FRAGMENT_DECLARATIONS = ` varying float vGrassEdgeFade; varying float vGrassBladeAcross; varying float vGrassBladeVertical; +varying float vGrassSunTransmission; float realmGrassCoverage() { float edgeCoverage = 1.0 - smoothstep(0.92, 1.0, abs(vGrassBladeAcross)); float tipCoverage = mix(0.96, 0.48, smoothstep(0.68, 1.0, vGrassBladeVertical)); @@ -81,11 +97,64 @@ float realmGrassCoverage() { * than silently shipping a material whose wind injection no longer matches * the pinned Three.js 0.185 shader chunks. */ -export function injectRealmGrassVertexShader(vertexShader: string) { +function grassDisturbanceDeclarations(slotCount: number) { + return slotCount > 0 ? ` +uniform int uGrassDisturbanceCount; +uniform vec2 uGrassDisturbanceCenters[${slotCount}]; +uniform vec4 uGrassDisturbanceParams[${slotCount}]; +` : ''; +} + +function grassDisturbanceBend(slotCount: number) { + if (slotCount <= 0) return ''; + return Array.from({ length: slotCount }, (_, slot) => ` +if (uGrassDisturbanceCount > ${slot}) { + vec2 grassDisturbanceDelta${slot} = grassWorldPosition.xz - uGrassDisturbanceCenters[${slot}]; + float grassDisturbanceDistance${slot} = length(grassDisturbanceDelta${slot}); + float grassDisturbanceRadius${slot} = max(0.05, uGrassDisturbanceParams[${slot}].x); + float grassDisturbanceFalloff${slot} = 1.0 - smoothstep( + grassDisturbanceRadius${slot} * 0.22, + grassDisturbanceRadius${slot}, + grassDisturbanceDistance${slot} + ); + vec2 grassDisturbanceWorldDirection${slot} = grassDisturbanceDistance${slot} > 0.0001 + ? grassDisturbanceDelta${slot} / grassDisturbanceDistance${slot} + : grassWorldDirection; + vec2 grassDisturbanceLocalDirection${slot} = grassWorldToLocalXZ * grassDisturbanceWorldDirection${slot}; + float grassDisturbancePulse${slot} = sin(clamp(uGrassDisturbanceParams[${slot}].z, 0.0, 1.0) * 3.14159265); + float grassDisturbanceBend${slot} = grassDisturbanceFalloff${slot} + * uGrassDisturbanceParams[${slot}].y + * mix(0.55, 1.0, grassDisturbancePulse${slot}) + * grassFlexAmount * 0.055; + transformed.xz += grassDisturbanceLocalDirection${slot} * grassDisturbanceBend${slot}; +} +`).join(''); +} + +export function injectRealmGrassVertexShader(vertexShader: string, disturbanceSlotCount = 0) { const marker = '#include '; - if (!vertexShader.includes(marker)) { + const normalMarker = '#include '; + if (!vertexShader.includes(marker) || !vertexShader.includes(normalMarker)) { throw new Error('REALM_GRASS_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED'); } + const safeSlotCount = Math.max(0, Math.min(8, Math.trunc(disturbanceSlotCount))); + const normal = ` +${normalMarker} +vec4 grassNormalWorldPosition = modelMatrix * instanceMatrix * vec4(position, 1.0); +vec2 grassNormalWorldDirection = normalize(uGrassWindDirection + vec2(0.00001)); +mat3 grassNormalInstanceBasis = mat3(modelMatrix * instanceMatrix); +mat2 grassNormalLocalToWorldXZ = mat2(grassNormalInstanceBasis[0].xz, grassNormalInstanceBasis[2].xz); +float grassNormalBasisDeterminant = determinant(grassNormalLocalToWorldXZ); +mat2 grassNormalWorldToLocalXZ = abs(grassNormalBasisDeterminant) > 0.000001 + ? inverse(grassNormalLocalToWorldXZ) + : mat2(1.0); +vec2 grassNormalLocalDirection = grassNormalWorldToLocalXZ * grassNormalWorldDirection; +float grassNormalGust = realmLivingGust(grassNormalWorldPosition.xz, uGrassTime); +float grassNormalLean = grassBladeData.y * grassBladeData.w * grassWindScale + * uGrassWindStrength * mix(0.035, 0.11, grassNormalGust); +objectNormal.xz -= grassNormalLocalDirection * grassNormalLean; +objectNormal = normalize(objectNormal); +`; const wind = ` ${marker} float grassBladeAcross = grassBladeData.x; @@ -108,6 +177,8 @@ transformed.y *= grassSelectionScale; vec4 grassWorldPosition = modelMatrix * instanceMatrix * vec4(transformed, 1.0); vec2 grassWorldDirection = normalize(uGrassWindDirection + vec2(0.00001, 0.00001)); vec2 grassWorldCrossDirection = vec2(-grassWorldDirection.y, grassWorldDirection.x); +vGrassSunTransmission = smoothstep(0.28, 0.96, grassBladeVertical) + * clamp(dot(normalize(vec3(-grassWorldDirection.x, 0.42, -grassWorldDirection.y)), normalize(uGrassSunDirection)), 0.0, 1.0); // begin_vertex is instance-local. Undo the horizontal instance/model basis so // the later project_vertex transform restores one shared world wind direction. mat3 grassInstanceBasis = mat3(modelMatrix * instanceMatrix); @@ -118,11 +189,6 @@ mat2 grassWorldToLocalXZ = abs(grassBasisDeterminant) > 0.000001 : mat2(1.0); vec2 grassLocalDirection = grassWorldToLocalXZ * grassWorldDirection; vec2 grassLocalCrossDirection = grassWorldToLocalXZ * grassWorldCrossDirection; -float grassGustFront = sin( - dot(grassWorldPosition.xz, grassWorldDirection) * 0.21 - - uGrassTime * 0.34 -); -float grassGustBand = smoothstep(0.18, 0.92, 0.5 + grassGustFront * 0.5); float grassPrimary = sin( dot(grassWorldPosition.xz, grassWorldDirection) * 1.18 + uGrassTime * 1.24 @@ -135,15 +201,18 @@ float grassSecondary = sin( + grassPhase * 0.10 + grassBladePhase * 0.31 ); -float grassGust = mix(0.66, 1.0, grassGustBand); +float grassGust = mix(0.66, 1.0, realmLivingGust(grassWorldPosition.xz, uGrassTime)); float grassFlexAmount = pow(max(grassFlex, 0.0), 1.85); float grassBend = clamp((grassPrimary + grassSecondary * 0.28) * grassGust * grassWindScale * grassStiffness * grassBladeStiffness * uGrassWindStrength * ${REALM_GRASS_MAX_WIND_SWAY.toFixed(3)}, -${REALM_GRASS_MAX_PRIMARY_BEND.toFixed(6)}, ${REALM_GRASS_MAX_PRIMARY_BEND.toFixed(6)}); transformed.xz += grassLocalDirection * grassBend * grassFlexAmount; transformed.xz += grassLocalCrossDirection * grassBend * grassFlexAmount * ${REALM_GRASS_CROSS_WIND_RATIO.toFixed(2)}; +${grassDisturbanceBend(safeSlotCount)} `; - return `${VERTEX_DECLARATIONS}\n${vertexShader.replace(marker, wind)}`; + return `${VERTEX_DECLARATIONS}\n${REALM_LIVING_GUST_GLSL}\n${grassDisturbanceDeclarations(safeSlotCount)}\n${vertexShader + .replace(normalMarker, normal) + .replace(marker, wind)}`; } export function injectRealmGrassFragmentShader(fragmentShader: string) { @@ -159,7 +228,8 @@ export function injectRealmGrassFragmentShader(fragmentShader: string) { const colour = ` ${colorMarker} float grassVerticalLift = smoothstep(0.0, 1.0, vGrassBladeVertical); -diffuseColor.rgb *= mix(0.94, 1.015, grassVerticalLift); +diffuseColor.rgb *= mix(0.96, 1.04, grassVerticalLift); +diffuseColor.rgb *= vec3(1.0) + vec3(0.09, 0.11, 0.025) * vGrassSunTransmission * 0.45; diffuseColor.a *= realmGrassCoverage(); `; return `${FRAGMENT_DECLARATIONS}\n${fragmentShader.replace(colorMarker, colour)}`; @@ -168,8 +238,21 @@ diffuseColor.a *= realmGrassCoverage(); export function createRealmGrassMaterial( windStrength = 1, animateInteractions = true, - alphaToCoverage = false + alphaToCoverage = false, + disturbanceSlotCount = 0 ): RealmGrassMaterial { + const safeDisturbanceSlotCount = Math.max( + 0, + Math.min(8, Math.trunc(Number.isFinite(disturbanceSlotCount) ? disturbanceSlotCount : 0)) + ); + const disturbanceCenters = Array.from( + { length: safeDisturbanceSlotCount }, + () => new THREE.Vector2() + ); + const disturbanceParams = Array.from( + { length: safeDisturbanceSlotCount }, + () => new THREE.Vector4() + ); const uniforms: RealmGrassUniforms = Object.freeze({ uGrassTime: { value: 0 }, uGrassWindDirection: { @@ -195,10 +278,24 @@ export function createRealmGrassMaterial( value: new THREE.Vector2(NO_SELECTED_CELL, NO_SELECTED_CELL) }, uGrassInteractionProgress: { value: 1 }, - uGrassInteractionFlattening: { value: 1 } + uGrassInteractionFlattening: { value: 1 }, + uGrassSunDirection: { + value: new THREE.Vector3( + REALM_SUN_DIRECTION.x, + REALM_SUN_DIRECTION.y, + REALM_SUN_DIRECTION.z + ) + }, + uGrassDisturbanceCount: { value: 0 }, + uGrassDisturbanceCenters: { value: disturbanceCenters }, + uGrassDisturbanceParams: { value: disturbanceParams } }); const material = new THREE.MeshStandardMaterial({ color: '#ffffff', + // A faint chlorophyll fill keeps thin distant blades green under the + // strategic camera without flattening their sun-facing PBR response. + emissive: '#315820', + emissiveIntensity: 0.14, // InstancedMesh.instanceColor is enabled independently by Three.js. A // base colour attribute would consume a vertex slot without providing data. vertexColors: false, @@ -244,7 +341,10 @@ export function createRealmGrassMaterial( const originalVertexShader = shader.vertexShader; const originalFragmentShader = shader.fragmentShader; try { - shader.vertexShader = injectRealmGrassVertexShader(originalVertexShader); + shader.vertexShader = injectRealmGrassVertexShader( + originalVertexShader, + safeDisturbanceSlotCount + ); shader.fragmentShader = injectRealmGrassFragmentShader(originalFragmentShader); Object.assign(shader.uniforms, uniforms); } catch (error) { @@ -258,7 +358,9 @@ export function createRealmGrassMaterial( }; material.customProgramCacheKey = () => shaderFallbackActive ? `${REALM_GRASS_SHADER_CACHE_KEY}:static-fallback` - : REALM_GRASS_SHADER_CACHE_KEY; + : safeDisturbanceSlotCount > 0 + ? `${REALM_GRASS_SHADER_CACHE_KEY}:disturbances-${safeDisturbanceSlotCount}` + : REALM_GRASS_SHADER_CACHE_KEY; const setCell = (uniform: THREE.IUniform, cell: Readonly<{ q: number; r: number }> | null) => { uniform.value.set( @@ -313,6 +415,37 @@ export function createRealmGrassMaterial( lastTime = safeSeconds; return timeChanged || interactionProgress !== priorInteractionProgress; }, + setDisturbances: (snapshot) => { + if (disposed || shaderFallbackActive || safeDisturbanceSlotCount === 0) return false; + const nextCount = Math.min( + safeDisturbanceSlotCount, + Math.max(0, Math.trunc(snapshot?.count ?? 0)) + ); + let changed = uniforms.uGrassDisturbanceCount.value !== nextCount; + uniforms.uGrassDisturbanceCount.value = nextCount; + for (let slot = 0; slot < safeDisturbanceSlotCount; slot += 1) { + const centerOffset = slot * 2; + const paramOffset = slot * 4; + const nextCenterX = slot < nextCount ? snapshot?.centers[centerOffset] ?? 0 : 0; + const nextCenterZ = slot < nextCount ? snapshot?.centers[centerOffset + 1] ?? 0 : 0; + const nextRadius = slot < nextCount ? snapshot?.params[paramOffset] ?? 0 : 0; + const nextStrength = slot < nextCount ? snapshot?.params[paramOffset + 1] ?? 0 : 0; + const nextAge = slot < nextCount ? snapshot?.params[paramOffset + 2] ?? 0 : 0; + const nextLifetime = slot < nextCount ? snapshot?.params[paramOffset + 3] ?? 0 : 0; + const center = disturbanceCenters[slot]!; + const params = disturbanceParams[slot]!; + changed = changed + || center.x !== nextCenterX + || center.y !== nextCenterZ + || params.x !== nextRadius + || params.y !== nextStrength + || params.z !== nextAge + || params.w !== nextLifetime; + center.set(nextCenterX, nextCenterZ); + params.set(nextRadius, nextStrength, nextAge, nextLifetime); + } + return changed; + }, setVisible: (visible) => { if (disposed) return; uniforms.uGrassGlobalVisibility.value = visible ? 1 : 0; @@ -320,7 +453,9 @@ export function createRealmGrassMaterial( getShaderTelemetry: () => Object.freeze({ fallbackActive: shaderFallbackActive, fallbackCount: shaderFallbackCount, - fallbackReason: shaderFallbackReason + fallbackReason: shaderFallbackReason, + disturbanceSlotCount: safeDisturbanceSlotCount, + activeDisturbanceCount: uniforms.uGrassDisturbanceCount.value }), dispose: () => { if (disposed) return; diff --git a/src/components/realm/createRealmProceduralForestFallback.ts b/src/components/realm/createRealmProceduralForestFallback.ts index 5c9ee834..cffa1228 100644 --- a/src/components/realm/createRealmProceduralForestFallback.ts +++ b/src/components/realm/createRealmProceduralForestFallback.ts @@ -284,7 +284,8 @@ function appendCanopyLobe( * is intentionally low-poly enough for every existing forest fallback budget. */ export function createRealmProceduralForestFallbackGeometry( - targetHeightInput: number + targetHeightInput: number, + includeWindAttributes = false ) { const targetHeight = Number.isFinite(targetHeightInput) && targetHeightInput > 0 ? Math.max(0.2, targetHeightInput) @@ -352,6 +353,31 @@ export function createRealmProceduralForestFallbackGeometry( 'color', new THREE.Float32BufferAttribute(output.colors, 3) ); + if (includeWindAttributes) { + const windWeights = new Uint8Array(output.positions.length / 3); + const windPhases = new Uint8Array(output.positions.length / 3); + for (let index = 0; index < windWeights.length; index += 1) { + const x = output.positions[index * 3] ?? 0; + const y = output.positions[index * 3 + 1] ?? 0; + const z = output.positions[index * 3 + 2] ?? 0; + const normalizedHeight = THREE.MathUtils.clamp( + (y / targetHeight - 0.18) / 0.72, + 0, + 1 + ); + windWeights[index] = Math.round(normalizedHeight * 255); + const phase = Math.sin(x * 91.7 + z * 63.1 + y * 17.3) * 0.5 + 0.5; + windPhases[index] = Math.round(THREE.MathUtils.clamp(phase, 0, 1) * 255); + } + geometry.setAttribute( + 'realmForestWindWeight', + new THREE.Uint8BufferAttribute(windWeights, 1, true) + ); + geometry.setAttribute( + 'realmForestWindPhase', + new THREE.Uint8BufferAttribute(windPhases, 1, true) + ); + } geometry.setIndex(new THREE.Uint16BufferAttribute(output.indices, 1)); geometry.computeVertexNormals(); geometry.computeBoundingBox(); @@ -378,16 +404,16 @@ export function createRealmProceduralForestFallbackMaterial() { export function realmForestFallbackInstanceColor( habitat: RealmForestEcologyHabitat ) { - if (habitat === 'grove') return '#3f6a43'; - if (habitat === 'forest') return '#497548'; - return '#68845a'; + if (habitat === 'grove') return '#477d47'; + if (habitat === 'forest') return '#538653'; + return '#73955f'; } -/** Restrained near-white tint for authored model instances. */ +/** Restrained green-white tint for authored model instances. */ export function realmForestModelInstanceTint( habitat: RealmForestEcologyHabitat ) { - if (habitat === 'grove') return '#dbe7d7'; - if (habitat === 'forest') return '#e7eee1'; - return '#f1eddc'; + if (habitat === 'grove') return '#cfe9c8'; + if (habitat === 'forest') return '#d7eed1'; + return '#e6f0d7'; } diff --git a/src/components/realm/createRealmRabbitLayer.ts b/src/components/realm/createRealmRabbitLayer.ts new file mode 100644 index 00000000..bafc3396 --- /dev/null +++ b/src/components/realm/createRealmRabbitLayer.ts @@ -0,0 +1,292 @@ +import * as THREE from 'three'; + +import { loadRealmRabbitAsset, type RealmRabbitPrefab } from './loadRealmRabbitAsset'; +import { REALM_RABBIT_RUNTIME_ASSET } from './realmRabbitRuntimeAsset'; + +export type RealmRabbitTelemetry = Readonly<{ + enabled: boolean; + assetReady: boolean; + overviewHidden: boolean; + instanceCapacity: number; + instanceCount: number; + drawCalls: number; + triangleCount: number; + loadFallbackCount: number; +}>; + +export type RealmRabbitLayer = Readonly<{ + group: THREE.Group; + update: ( + elapsedSeconds: number, + focus: Readonly<{ x: number; z: number }>, + mode: 'realm' | 'approach' | 'keep' + ) => boolean; + isAnimationActive: () => boolean; + getTelemetry: () => RealmRabbitTelemetry; + dispose: () => void; +}>; + +export type CreateRealmRabbitLayerOptions = Readonly<{ + instanceCount: number; + baseUrl: string; + heightAtWorld: (world: Readonly<{ x: number; z: number }>) => number; + isHabitat?: (world: Readonly<{ x: number; z: number }>) => boolean; + frozenVisualTimeSeconds?: number; + onModelReady?: () => void; + /** Test seam; production uses the loader's bounded default. */ + requestTimeoutMs?: number; +}>; + +const ANCHOR_STEP = 3; +const HOME_ATTEMPTS = 12; +const MODEL_SCALE = 1.28; + +function hashUnit(index: number, salt: number) { + const value = Math.sin((index + 1) * 71.417 + salt * 39.133) * 43_758.5453; + return value - Math.floor(value); +} + +function finite(value: number, fallback = 0) { + return Number.isFinite(value) ? value : fallback; +} + +export function createRealmRabbitLayer( + options: CreateRealmRabbitLayerOptions +): RealmRabbitLayer { + const capacity = Math.max(0, Math.min(16, Math.trunc(finite(options.instanceCount)))); + const group = new THREE.Group(); + group.name = 'realm-living-lowlands-rabbits'; + group.visible = false; + const homesX = new Float32Array(capacity); + const homesZ = new Float32Array(capacity); + const abortController = new AbortController(); + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const rotation = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + const up = new THREE.Vector3(0, 1, 0); + let prefab: RealmRabbitPrefab | null = null; + let rabbitMesh: THREE.InstancedMesh | null = null; + let rabbitMaterial: THREE.Material | null = null; + let disposed = false; + let overviewHidden = true; + let assetReady = false; + let activeCount = 0; + let loadFallbackCount = 0; + let lastAnchorX = Number.NaN; + let lastAnchorZ = Number.NaN; + let lastElapsedSeconds = 0; + let lastFocusX = 0; + let lastFocusZ = 0; + let lastMode: 'realm' | 'approach' | 'keep' = 'realm'; + + const visualTime = (elapsedSeconds: number) => Number.isFinite( + options.frozenVisualTimeSeconds + ) + ? Math.max(0, options.frozenVisualTimeSeconds!) + : Math.max(0, finite(elapsedSeconds)); + + const notifyModelReady = () => { + try { + options.onModelReady?.(); + } catch { + // The optional notification cannot change asset or scene truth. + } + }; + + const resolveHomes = (anchorX: number, anchorZ: number) => { + activeCount = 0; + const anchorSeed = Math.round(anchorX / ANCHOR_STEP) * 97 + + Math.round(anchorZ / ANCHOR_STEP) * 193; + for (let index = 0; index < capacity; index += 1) { + for (let attempt = 0; attempt < HOME_ATTEMPTS; attempt += 1) { + const seedIndex = anchorSeed + index * HOME_ATTEMPTS + attempt; + const angle = hashUnit(seedIndex, 3) * Math.PI * 2; + const radius = 0.9 + hashUnit(seedIndex, 5) * 3.6; + const world = { + x: anchorX + Math.cos(angle) * radius, + z: anchorZ + Math.sin(angle) * radius + }; + if (options.isHabitat && !options.isHabitat(world)) continue; + homesX[activeCount] = world.x; + homesZ[activeCount] = world.z; + activeCount += 1; + break; + } + } + if (rabbitMesh) rabbitMesh.count = activeCount; + }; + + const updateMatrices = (time: number) => { + if (!rabbitMesh) return; + for (let index = 0; index < activeCount; index += 1) { + const phase = (time * (0.34 + hashUnit(index, 7) * 0.12) + + hashUnit(index, 8)) % 1; + const hopWindow = phase < 0.62 ? phase / 0.62 : 0; + const stride = phase < 0.62 ? Math.sin(hopWindow * Math.PI * 2) * 0.11 : 0; + const hop = phase < 0.62 + ? Math.max(0, Math.sin(hopWindow * Math.PI * 2)) * 0.052 + : 0; + const heading = hashUnit(index, 9) * Math.PI * 2 + + Math.sin(time * 0.08 + index) * 0.28; + const x = homesX[index]! + Math.sin(heading) * stride; + const z = homesZ[index]! + Math.cos(heading) * stride; + const groundY = finite(options.heightAtWorld({ x, z })); + position.set(x, groundY + hop, z); + rotation.setFromAxisAngle(up, heading); + const individualScale = MODEL_SCALE * (0.92 + hashUnit(index, 10) * 0.16); + const hopStretch = hop / 0.052; + scale.set( + individualScale * (1 - hopStretch * 0.035), + individualScale * (1 + hopStretch * 0.07), + individualScale * (1 - hopStretch * 0.035) + ); + matrix.compose(position, rotation, scale); + rabbitMesh.setMatrixAt(index, matrix); + } + rabbitMesh.instanceMatrix.needsUpdate = true; + }; + + const update = ( + elapsedSeconds: number, + focus: Readonly<{ x: number; z: number }>, + mode: 'realm' | 'approach' | 'keep' + ) => { + if (disposed || capacity === 0) return false; + const time = visualTime(elapsedSeconds); + const focusX = finite(focus.x); + const focusZ = finite(focus.z); + const anchorX = Math.round(focusX / ANCHOR_STEP) * ANCHOR_STEP; + const anchorZ = Math.round(focusZ / ANCHOR_STEP) * ANCHOR_STEP; + const nextOverviewHidden = mode === 'realm'; + const changed = time !== lastElapsedSeconds + || nextOverviewHidden !== overviewHidden + || anchorX !== lastAnchorX + || anchorZ !== lastAnchorZ; + lastElapsedSeconds = time; + lastFocusX = focusX; + lastFocusZ = focusZ; + lastMode = mode; + overviewHidden = nextOverviewHidden; + if (anchorX !== lastAnchorX || anchorZ !== lastAnchorZ) { + lastAnchorX = anchorX; + lastAnchorZ = anchorZ; + resolveHomes(anchorX, anchorZ); + } + group.visible = assetReady && !overviewHidden && activeCount > 0; + if (group.visible) updateMatrices(time); + return changed; + }; + + if (capacity > 0) { + void loadRealmRabbitAsset({ + baseUrl: options.baseUrl, + signal: abortController.signal, + requestTimeoutMs: options.requestTimeoutMs + }).then((loaded) => { + if (disposed) { + loaded.release(); + return; + } + let nextMaterial: THREE.Material | null = null; + let nextMesh: THREE.InstancedMesh | null = null; + try { + nextMaterial = loaded.material.clone(); + nextMaterial.name = 'realm-lowlands-rabbit-compact-material'; + nextMesh = new THREE.InstancedMesh( + loaded.geometry, + nextMaterial, + capacity + ); + nextMesh.name = 'realm-lowlands-rabbit-compact-instances'; + nextMesh.count = 0; + nextMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + nextMesh.frustumCulled = false; + nextMesh.castShadow = false; + nextMesh.receiveShadow = false; + nextMesh.raycast = () => {}; + prefab = loaded; + rabbitMaterial = nextMaterial; + rabbitMesh = nextMesh; + group.add(nextMesh); + assetReady = true; + update(lastElapsedSeconds, { x: lastFocusX, z: lastFocusZ }, lastMode); + } catch (error) { + nextMesh?.removeFromParent(); + try { + nextMesh?.dispose(); + } catch { + // Continue retiring the independently owned material and prefab. + } + try { + nextMaterial?.dispose(); + } catch { + // Continue retiring the verified source prefab. + } + try { + loaded.release(); + } catch { + // Preserve the adoption failure; no source resource may stay live. + } + if (prefab === loaded) prefab = null; + if (rabbitMaterial === nextMaterial) rabbitMaterial = null; + if (rabbitMesh === nextMesh) rabbitMesh = null; + assetReady = false; + activeCount = 0; + group.visible = false; + throw error; + } + notifyModelReady(); + }).catch(() => { + if (disposed || abortController.signal.aborted) return; + loadFallbackCount += 1; + notifyModelReady(); + }); + } + + return Object.freeze({ + group, + update, + isAnimationActive: () => ( + !disposed && assetReady && !overviewHidden && activeCount > 0 + ), + getTelemetry: () => Object.freeze({ + enabled: !disposed && capacity > 0, + assetReady: !disposed && assetReady, + overviewHidden, + instanceCapacity: disposed ? 0 : capacity, + instanceCount: disposed || overviewHidden || !assetReady ? 0 : activeCount, + drawCalls: disposed || overviewHidden || !assetReady || activeCount === 0 ? 0 : 1, + triangleCount: disposed || overviewHidden || !assetReady + ? 0 + : activeCount * REALM_RABBIT_RUNTIME_ASSET.triangles, + loadFallbackCount + }), + dispose: () => { + if (disposed) return; + disposed = true; + abortController.abort(); + group.clear(); + try { + rabbitMesh?.dispose(); + } catch { + // Continue retiring independent Rabbit GPU resources. + } + try { + rabbitMaterial?.dispose(); + } catch { + // Continue retiring the verified source prefab. + } + try { + prefab?.release(); + } catch { + // Scene cleanup remains idempotent even on browser disposal failure. + } + prefab = null; + rabbitMesh = null; + rabbitMaterial = null; + assetReady = false; + activeCount = 0; + } + }); +} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 1517214f..1d834843 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -55,6 +55,15 @@ import { } from '../../game/map/terrainPlacements'; import { createTerrainDecorationLayers } from './createTerrainDecorations'; import { createRealmGrassLayer, type RealmGrassLayer, type RealmGrassTelemetry } from './createRealmGrassLayer'; +import { + createRealmAmbientEcologyLayer, + type RealmAmbientEcologyLayer +} from './createRealmAmbientEcologyLayer'; +import { + createRealmRabbitLayer, + type RealmRabbitLayer +} from './createRealmRabbitLayer'; +import { createRealmSurfaceDisturbanceField } from './realmSurfaceDisturbanceField'; import { createRealmTerrainFeatureLayers } from './createRealmTerrainFeatures'; import { createRealmForestLayer, type RealmForestLayer } from './realmForestLayer'; import { @@ -66,6 +75,7 @@ import type { RealmForestFallbackType, RealmForestGroundingMode } from './createRealmProceduralForestFallback'; +import type { REALM_FOREST_LIVING_CANOPY_MOTION_STATE } from './createRealmForestWindMaterial'; import { estimateRealmForestViewportRadiusCells } from './realmForestActiveWindow'; import { createTerrainGeometryData, @@ -195,6 +205,7 @@ import { } from './realmPickArbitration'; import { REALM_LIGHTING_SPECS, + resolveRealmLivingRealmBudget, resolveRealmPixelRatio, resolveRealmRenderPlan, type RealmQualitySpec @@ -704,7 +715,7 @@ export type RealmTerrainPresentationTelemetry = Readonly<{ forestFallbackType: RealmForestFallbackType; forestContactShadowCount: number; forestGroundingMode: RealmForestGroundingMode; - forestCanopyMotionState: 'static'; + forestCanopyMotionState: 'static' | typeof REALM_FOREST_LIVING_CANOPY_MOTION_STATE; forestStructureCellCounts: RealmForestStructureCounts; forestSilhouetteCoverageRatio: number; forestSnowTintedTreeCount: number; @@ -933,6 +944,8 @@ export type CreateRealmSceneOptions = Readonly<{ terrainMetadata: readonly RealmTerrainSemanticRow[]; quality: RealmQualitySpec; reducedMotion: boolean; + /** DEV-only frozen visual clock for deterministic rendered fixtures. */ + livingVisualTimeSeconds?: number; baseUrl: string; /** Optional authoritative metadata boundary for camera navigation. */ isCoordPassable?: (coord: HexCoord) => boolean; @@ -1430,6 +1443,10 @@ function initializeRealmScene( dynamicShadows: renderPlan.dynamicShadows, shadowMapSize: renderPlan.shadowMapSize }; + const livingBudget = resolveRealmLivingRealmBudget( + runtimeQuality.id, + options.reducedMotion + ); const climatePlayableRadius = Math.max(1, options.surface.playableMap.radius); const northernSnow = createRealmNorthernSnowField({ worldSeed: presentationSurface.renderMap.worldSeed, @@ -1879,6 +1896,12 @@ function initializeRealmScene( options.canvas.dataset.waterShaderFallbackCount = String( telemetry?.shaderFallbackCount ?? 0 ); + options.canvas.dataset.waterRippleSlotCount = String( + telemetry?.rippleSlotCount ?? 0 + ); + options.canvas.dataset.waterActiveRippleCount = String( + telemetry?.activeRippleCount ?? 0 + ); options.canvas.dataset.waterRiverFallbackReasons = JSON.stringify( telemetry?.riverFallbackReasons ?? [] ); @@ -1973,6 +1996,7 @@ function initializeRealmScene( map: presentationSurface.renderMap, terrainPlacements, quality: runtimeQuality, + reducedMotion: options.reducedMotion, baseUrl: options.baseUrl, northernSnow, southernDesert, @@ -2088,6 +2112,7 @@ function initializeRealmScene( ]), plan: renderPlan.grass, reducedMotion: options.reducedMotion, + livingBudget, hexSize: HEX_SIZE, alphaToCoverage: grassAlphaToCoverage, vegetationField, @@ -2105,6 +2130,73 @@ function initializeRealmScene( // Decorative failure must not take the terrain, input, or castle layer down. options.canvas.dataset.grassPresentation = 'unavailable'; } + const surfaceDisturbances = createRealmSurfaceDisturbanceField({ + grassCapacity: livingBudget.grassDisturbanceSlots, + waterCapacity: livingBudget.waterRippleSlots + }); + cleanup.add(surfaceDisturbances.dispose); + let ambientEcologyLayer: RealmAmbientEcologyLayer | null = null; + try { + const nextAmbientEcologyLayer = createRealmAmbientEcologyLayer({ + budget: livingBudget, + frozenVisualTimeSeconds: options.livingVisualTimeSeconds + }); + ambientEcologyLayer = nextAmbientEcologyLayer; + scene.add(nextAmbientEcologyLayer.group); + cleanup.add(() => { + scene.remove(nextAmbientEcologyLayer.group); + nextAmbientEcologyLayer.dispose(); + if (ambientEcologyLayer === nextAmbientEcologyLayer) { + ambientEcologyLayer = null; + } + }); + } catch { + // Optional ecology is independent of terrain, interaction, and authority. + ambientEcologyLayer = null; + } + let rabbitLayer: RealmRabbitLayer | null = null; + try { + const rabbitProtectedTileKeys = new Set([ + ...terrainSemantics.castleSlotKeys, + ...fullCellWaterCoordinateKeys, + ...(options.goldNodes ?? []).map((node) => hexKey(node.coord)), + ...(options.foodNodes ?? []).map((node) => hexKey(node.coord)), + ...(options.woodNodes ?? []).map((node) => hexKey(node.coord)), + ...(options.stoneNodes ?? []).map((node) => hexKey(node.coord)) + ]); + const nextRabbitLayer = createRealmRabbitLayer({ + instanceCount: livingBudget.rabbitInstances, + baseUrl: options.baseUrl, + frozenVisualTimeSeconds: options.livingVisualTimeSeconds, + heightAtWorld: (world) => terrainHeightAtWorld( + presentationSurface.renderMap, + world, + HEX_SIZE, + terrainPlacements + ), + isHabitat: (world) => { + const coord = worldToNearestAxial(world, HEX_SIZE); + const key = hexKey(coord); + const kind = terrainSemantics.terrainKindsByKey.get(key) ?? 'lowland'; + return presentationSurface.playableKeys.has(key) + && !rabbitProtectedTileKeys.has(key) + && (kind === 'lowland' || kind === 'meadow' || kind === 'heath'); + }, + onModelReady: () => { + if (!cleanup.isDisposed()) render(); + } + }); + rabbitLayer = nextRabbitLayer; + scene.add(nextRabbitLayer.group); + cleanup.add(() => { + scene.remove(nextRabbitLayer.group); + nextRabbitLayer.dispose(); + if (rabbitLayer === nextRabbitLayer) rabbitLayer = null; + }); + } catch { + // Wildlife is optional, non-pickable, and independent of Realm authority. + rabbitLayer = null; + } const emptyGrassTelemetry: RealmGrassTelemetry = Object.freeze({ candidateCellCount: 0, activeCellCount: 0, @@ -2160,7 +2252,9 @@ function initializeRealmScene( retainedInDryTransition: 0, activeSandCellCount: 0, averageSandCoverageOfActiveCells: 0, - overviewHidden: true + overviewHidden: true, + disturbanceSlotCount: 0, + activeDisturbanceCount: 0 }); const terrainPresentationTelemetry = () => { terrainTelemetryAggregationCount += 1; @@ -2776,6 +2870,12 @@ function initializeRealmScene( let contextLossCount = 0; let contextRestoreCount = 0; let ambientScheduler: RealmAmbientScheduler | null = null; + let livingElapsedSeconds = 0; + const workerWakeSamples = new Map(); let workerMovementWakeTimer: number | null = null; let workerMovementWakeGeneration = 0; let workerMovementWakeSuspended = false; @@ -2857,6 +2957,9 @@ function initializeRealmScene( ) > 0 && ( grassLayer?.isAnimationActive() === true + || forestLayer?.isAnimationActive() === true + || ambientEcologyLayer?.isAnimationActive() === true + || rabbitLayer?.isAnimationActive() === true || decorations.animated || goldNodeLayer?.hasMovingWagons() === true || foodNodeLayer?.hasMovingWagons() === true @@ -3076,6 +3179,84 @@ function initializeRealmScene( if (options.canvas.dataset[key] === value) return; options.canvas.dataset[key] = value; }; + const sampleWorkerSurfaceWakes = (seconds: number) => { + if ( + livingBudget.grassDisturbanceSlots === 0 + && livingBudget.waterRippleSlots === 0 + ) return; + if (workerWakeSamples.size > 128) workerWakeSamples.clear(); + for (const worker of workerLayer?.getPresenceRecords() ?? []) { + if (worker.direction !== 'outbound' && worker.direction !== 'returning') { + workerWakeSamples.delete(worker.workerId); + continue; + } + const previous = workerWakeSamples.get(worker.workerId); + const next = { + x: worker.world.x, + z: worker.world.z, + sampledAtSeconds: seconds + }; + if (!previous) { + workerWakeSamples.set(worker.workerId, next); + continue; + } + if (seconds - previous.sampledAtSeconds < 0.16) continue; + const distance = Math.hypot( + next.x - previous.x, + next.z - previous.z + ); + // Ignore sub-pixel jitter and discontinuous catalog/reconciliation jumps. + if (distance < 0.12) continue; + workerWakeSamples.set(worker.workerId, next); + if (distance > 1.5) continue; + const water = waterCellCoordinateKeys.has(hexKey(worker.coord)); + surfaceDisturbances.push({ + kind: water ? 'water' : 'grass', + x: next.x, + z: next.z, + radius: water ? 0.82 : 0.62, + strength: Math.min(1, 0.34 + distance * 1.35), + createdAtSeconds: seconds, + lifetimeSeconds: water ? 2.2 : 0.92 + }); + } + }; + const syncLivingRealmTelemetry = () => { + const disturbances = surfaceDisturbances.getTelemetry(livingElapsedSeconds); + const ecology = ambientEcologyLayer?.getTelemetry(); + const rabbits = rabbitLayer?.getTelemetry(); + const forest = forestLayer?.getPresentationTelemetry(); + const values = { + realmLivingGrassDisturbanceSlots: livingBudget.grassDisturbanceSlots, + realmLivingWaterRippleSlots: livingBudget.waterRippleSlots, + realmLivingActiveGrassDisturbances: disturbances.activeGrassCount, + realmLivingActiveWaterRipples: disturbances.activeWaterCount, + realmLivingDisturbanceInsertions: disturbances.insertedCount, + realmLivingDisturbanceEvictions: disturbances.evictedCount, + realmLivingDisturbanceDrops: disturbances.droppedCount, + realmLivingForestMotion: forest?.canopyMotionState ?? 'static', + realmLivingForestDrawCalls: forest?.drawCalls ?? 0, + realmLivingForestWindAttributeBytes: forest?.windAttributeBytes ?? 0, + realmLivingForestShaderFallbackCount: forest?.shaderFallbackCount ?? 0, + realmLivingEcologyDrawCalls: (ecology?.drawCalls ?? 0) + (rabbits?.drawCalls ?? 0), + realmLivingEcologyTriangles: (ecology?.triangleCount ?? 0) + + (rabbits?.triangleCount ?? 0), + realmLivingBirdCount: ecology?.birdCount ?? 0, + realmLivingRabbitCount: rabbits?.instanceCount ?? 0, + realmLivingRabbitCapacity: rabbits?.instanceCapacity ?? 0, + realmLivingRabbitAssetReady: rabbits?.assetReady ?? false, + realmLivingRabbitLoadFallbackCount: rabbits?.loadFallbackCount ?? 0, + realmLivingMoteCount: ecology?.moteCount ?? 0, + realmLivingTransientParticleCount: ecology?.transientParticleCount ?? 0, + realmLivingPlannerHz: ecology?.plannerHz ?? 0, + realmLivingPlannerTickCount: ecology?.plannerTickCount ?? 0, + realmLivingOverviewHidden: ecology?.overviewHidden ?? true + } as const; + for (const [key, value] of Object.entries(values)) { + setCanvasDatasetValue(key, String(value)); + } + }; + let lastPostCompileLivingShaderSignature = ''; const render = () => { if (cleanup.isDisposed()) return; if (contextLost) return; @@ -3160,6 +3341,20 @@ function initializeRealmScene( const expeditionPresentationNowMicros = localPresentationNowMicros(); workerLayer?.setCameraMode(pose.mode); workerLayer?.update(expeditionPresentationNowMicros); + sampleWorkerSurfaceWakes(livingElapsedSeconds); + const grassDisturbanceSnapshot = surfaceDisturbances.snapshot( + 'grass', + livingElapsedSeconds, + livingBudget.grassDisturbanceSlots + ); + ambientEcologyLayer?.update( + livingElapsedSeconds, + pose.focus, + pose.mode, + grassDisturbanceSnapshot + ); + rabbitLayer?.update(livingElapsedSeconds, pose.focus, pose.mode); + syncLivingRealmTelemetry(); const workerTelemetry = workerLayer?.getPresentationTelemetry(); if ( workerTelemetry @@ -3388,10 +3583,30 @@ function initializeRealmScene( // Shader compilation happens inside renderer.render. Publish its result // once per material contract transition, without rebuilding the aggregate // on every ambient animation frame. + const grassShaderTelemetry = grassLayer?.getTelemetry(); + const forestShaderTelemetry = forestLayer?.getPresentationTelemetry(); + const waterShaderTelemetry = waterLayer?.getTelemetry(); + const postCompileLivingShaderSignature = [ + grassShaderTelemetry?.shaderFallbackActive ?? false, + grassShaderTelemetry?.shaderFallbackCount ?? 0, + grassShaderTelemetry?.shaderFallbackReason ?? '', + forestShaderTelemetry?.canopyMotionState ?? 'static', + forestShaderTelemetry?.shaderFallbackCount ?? 0, + waterShaderTelemetry?.shaderFallbackCount ?? 0 + ].join(':'); + const livingShaderTelemetryChanged = postCompileLivingShaderSignature + !== lastPostCompileLivingShaderSignature; + lastPostCompileLivingShaderSignature = postCompileLivingShaderSignature; if ( - terrainMaterialLayer.getTelemetryRevision() - !== lastEmittedTerrainMaterialTelemetryRevision + livingShaderTelemetryChanged + || terrainMaterialLayer.getTelemetryRevision() + !== lastEmittedTerrainMaterialTelemetryRevision ) emitTerrainPresentationTelemetry(); + // Shader hooks compile inside renderer.render(). Re-read their fail-closed + // state before deciding whether the demand-driven RAF loop stays alive. + syncWaterPresentationTelemetry(); + syncLivingRealmTelemetry(); + ambientScheduler?.setActive(ambientIsNeeded()); options.canvas.dataset.realmLastSuccessfulRenderedGeneration = String(rendererGeneration); projectCastleLabels(); projectResourceMarkers(); @@ -3684,6 +3899,8 @@ function initializeRealmScene( }); const handleRenderVisibility = () => { if (document.hidden) { + workerWakeSamples.clear(); + surfaceDisturbances.clear(); workerMovementWakeSuspended = ( presentationActive && options.canvas.dataset.realmCanvasActive !== 'false' @@ -3736,16 +3953,37 @@ function initializeRealmScene( active: ambientIsNeeded(), onStep: (elapsedSeconds) => { if (cleanup.isDisposed()) return; - const grassChanged = grassLayer?.updateWind(elapsedSeconds) === true; + livingElapsedSeconds = elapsedSeconds; + const grassDisturbanceSnapshot = surfaceDisturbances.snapshot( + 'grass', + elapsedSeconds, + livingBudget.grassDisturbanceSlots + ); + const waterDisturbanceSnapshot = surfaceDisturbances.snapshot( + 'water', + elapsedSeconds, + livingBudget.waterRippleSlots + ); + const grassChanged = grassLayer?.updateWind( + elapsedSeconds, + grassDisturbanceSnapshot + ) === true; + const forestChanged = forestLayer?.updateWind(elapsedSeconds) === true; const terrainChanged = decorations.updateWind(elapsedSeconds); const wagonsMoving = goldNodeLayer?.hasMovingWagons() === true; const foodWagonsMoving = foodNodeLayer?.hasMovingWagons() === true; const woodWagonsMoving = woodNodeLayer?.hasMovingWagons() === true; const stoneWagonsMoving = stoneNodeLayer?.hasMovingWagons() === true; const workersMoving = workerLayer?.hasMovingWorkers() === true; - const waterChanged = waterLayer?.updateEnvironment(elapsedSeconds) === true; + const waterChanged = waterLayer?.updateEnvironment( + elapsedSeconds, + waterDisturbanceSnapshot + ) === true; + const ecologyChanged = ambientEcologyLayer?.isAnimationActive() === true; + const rabbitsChanged = rabbitLayer?.isAnimationActive() === true; if ( grassChanged + || forestChanged || terrainChanged || wagonsMoving || foodWagonsMoving @@ -3753,6 +3991,8 @@ function initializeRealmScene( || stoneWagonsMoving || workersMoving || waterChanged + || ecologyChanged + || rabbitsChanged ) render(); } }); @@ -4375,6 +4615,8 @@ function initializeRealmScene( options.canvas.dataset.realmRendererContextRestoreCount = String(contextRestoreCount); cancelAllPointers(pointerGestures.blur()); cancelWorkerMovementWake(); + workerWakeSamples.clear(); + surfaceDisturbances.clear(); ambientScheduler?.setActive(false); options.onRendererFailure?.({ code: 'context-lost', @@ -5139,6 +5381,8 @@ function initializeRealmScene( workerMovementWakeSuspended = false; renderPendingWhileHidden = false; cancelWorkerMovementWake(); + workerWakeSamples.clear(); + surfaceDisturbances.clear(); } ambientScheduler?.setActive(active && ambientIsNeeded()); if (active) render(); diff --git a/src/components/realm/createRealmTerrainMaterial.ts b/src/components/realm/createRealmTerrainMaterial.ts index 6bf62094..f18b3058 100644 --- a/src/components/realm/createRealmTerrainMaterial.ts +++ b/src/components/realm/createRealmTerrainMaterial.ts @@ -193,7 +193,11 @@ float terrainSnow = clamp(vTerrainSnowCoverage, 0.0, 1.0); float terrainSand = clamp(vTerrainSandCoverage, 0.0, 1.0); diffuseColor.rgb *= 1.0 - terrainHollow * 0.085; diffuseColor.rgb *= 1.0 + terrainCrest * 0.032; -diffuseColor.rgb *= 1.0 - terrainVegetation * 0.025; +diffuseColor.rgb = mix( + diffuseColor.rgb, + diffuseColor.rgb * vec3(0.99, 1.035, 0.97), + terrainVegetation * 0.16 +); diffuseColor.rgb = mix( diffuseColor.rgb, diffuseColor.rgb * vec3(0.86, 0.91, 0.96), diff --git a/src/components/realm/loadRealmRabbitAsset.ts b/src/components/realm/loadRealmRabbitAsset.ts new file mode 100644 index 00000000..f371bcac --- /dev/null +++ b/src/components/realm/loadRealmRabbitAsset.ts @@ -0,0 +1,226 @@ +import * as THREE from 'three'; + +import { + disposeRealmObject, + readExactRealmModelResponseBody, + resolveIntegrityPinnedRealmAssetUrl +} from './loadHegemonyKeep'; +import { REALM_RABBIT_RUNTIME_ASSET } from './realmRabbitRuntimeAsset'; + +export type RealmRabbitPrefab = Readonly<{ + assetUrl: string; + geometry: THREE.BufferGeometry; + material: THREE.Material; + sourceRoot: THREE.Object3D; + release: () => void; +}>; + +export type LoadRealmRabbitAssetOptions = Readonly<{ + baseUrl: string; + signal?: AbortSignal; + /** Bounds the transport and exact-body verification work. */ + requestTimeoutMs?: number; +}>; + +export const DEFAULT_REALM_RABBIT_REQUEST_TIMEOUT_MS = 20_000; +const MAX_REALM_RABBIT_REQUEST_TIMEOUT_MS = 60_000; +const GLB_JSON_CHUNK_TYPE = 0x4e4f534a; + +async function sha256Hex(bytes: ArrayBuffer) { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, '0')) + .join(''); +} + +function normalizedRequestTimeout(timeoutMs: number | undefined) { + if (!Number.isFinite(timeoutMs)) return DEFAULT_REALM_RABBIT_REQUEST_TIMEOUT_MS; + return Math.max(1, Math.min( + MAX_REALM_RABBIT_REQUEST_TIMEOUT_MS, + Math.trunc(timeoutMs ?? DEFAULT_REALM_RABBIT_REQUEST_TIMEOUT_MS) + )); +} + +function abortError() { + return new DOMException('Aborted', 'AbortError'); +} + +function isObjectRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Defense in depth around GLTFLoader's resource resolver. The reviewed GLB is + * self-contained, so parsing may never discover a dependent URL even after a + * future asset refresh updates the pinned digest and byte coordinates. + */ +export function assertEmbeddedRealmRabbitRuntime(bytes: ArrayBuffer) { + const asset = REALM_RABBIT_RUNTIME_ASSET; + if (bytes.byteLength !== asset.bytes || bytes.byteLength < 28) { + throw new Error('Lowlands Rabbit compact runtime container changed.'); + } + const view = new DataView(bytes); + const jsonLength = view.getUint32(12, true); + const jsonChunkType = view.getUint32(16, true); + const jsonEnd = 20 + jsonLength; + if ( + view.getUint32(0, true) !== 0x46546c67 + || view.getUint32(4, true) !== 2 + || view.getUint32(8, true) !== bytes.byteLength + || jsonChunkType !== GLB_JSON_CHUNK_TYPE + || jsonLength === 0 + || jsonEnd > bytes.byteLength + ) { + throw new Error('Lowlands Rabbit compact runtime container changed.'); + } + let json: Readonly>; + try { + const parsed: unknown = JSON.parse( + new TextDecoder('utf-8', { fatal: true }) + .decode(new Uint8Array(bytes, 20, jsonLength)) + .trim() + ); + if (!isObjectRecord(parsed)) { + throw new TypeError('Expected a glTF JSON object.'); + } + json = parsed; + } catch { + throw new Error('Lowlands Rabbit compact runtime JSON changed.'); + } + const buffers: readonly unknown[] = Array.isArray(json.buffers) ? json.buffers : []; + const buffer = buffers[0]; + if ( + buffers.length !== 1 + || !isObjectRecord(buffer) + || buffer.byteLength !== asset.embeddedBufferBytes + || Object.prototype.hasOwnProperty.call(buffer, 'uri') + || json.images !== undefined + || json.textures !== undefined + || json.samplers !== undefined + ) { + throw new Error('Lowlands Rabbit compact runtime must remain self-contained.'); + } +} + +async function requestVerifiedRealmRabbitBytes( + assetUrl: string, + options: LoadRealmRabbitAssetOptions +) { + const asset = REALM_RABBIT_RUNTIME_ASSET; + const timeoutMs = normalizedRequestTimeout(options.requestTimeoutMs); + const abortController = new AbortController(); + let timeoutHandle: ReturnType | undefined; + let externalAbortListener: (() => void) | undefined; + const externalAbort = options.signal + ? new Promise((_resolve, reject) => { + externalAbortListener = () => { + abortController.abort(); + reject(abortError()); + }; + if (options.signal?.aborted) externalAbortListener(); + else options.signal?.addEventListener('abort', externalAbortListener, { once: true }); + }) + : undefined; + const fetchRequest = Promise.resolve() + .then(() => fetch(assetUrl, { + credentials: 'same-origin', + redirect: 'error', + signal: abortController.signal + })) + .then(async (response) => { + if (!response.ok) { + throw new Error('Lowlands Rabbit request failed with ' + response.status + '.'); + } + const bytes = await readExactRealmModelResponseBody( + response, + asset.bytes, + 'Lowlands Rabbit compact runtime asset' + ); + if (await sha256Hex(bytes) !== asset.sha256) { + throw new Error('Lowlands Rabbit compact runtime asset failed its integrity check.'); + } + return bytes; + }); + const timeout = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`Lowlands Rabbit request timed out after ${timeoutMs}ms.`)); + abortController.abort(); + }, timeoutMs); + }); + try { + return await Promise.race( + externalAbort ? [fetchRequest, timeout, externalAbort] : [fetchRequest, timeout] + ); + } catch (error) { + // Retire a response body on status, streaming, length, integrity, timeout, + // or caller-abort failure. This also prevents unread error bodies lingering. + abortController.abort(); + throw error; + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + if (externalAbortListener) { + options.signal?.removeEventListener('abort', externalAbortListener); + } + } +} + +/** + * Load the exact compact rabbit from the same-origin public runtime. The + * model remains visual-only: its geometry is never used for collision, + * picking, terrain, navigation, or Realm authority. + */ +export async function loadRealmRabbitAsset( + options: LoadRealmRabbitAssetOptions +): Promise { + const asset = REALM_RABBIT_RUNTIME_ASSET; + const assetUrl = resolveIntegrityPinnedRealmAssetUrl( + options.baseUrl, + asset.path, + asset.sha256 + ); + const bytes = await requestVerifiedRealmRabbitBytes(assetUrl, options); + if (options.signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + assertEmbeddedRealmRabbitRuntime(bytes); + const { GLTFLoader } = await import('three/addons/loaders/GLTFLoader.js'); + const loader = new GLTFLoader(); + const loaded = await loader.parseAsync( + bytes.slice(0), + assetUrl.slice(0, assetUrl.lastIndexOf('/') + 1) + ); + if (options.signal?.aborted) { + disposeRealmObject(loaded.scene); + throw new DOMException('Aborted', 'AbortError'); + } + const meshes: THREE.Mesh[] = []; + loaded.scene.traverse((object) => { + if (object instanceof THREE.Mesh) meshes.push(object); + }); + const mesh = meshes[0]; + const material = mesh && !Array.isArray(mesh.material) + ? mesh.material + : undefined; + const positionCount = mesh?.geometry.getAttribute('position')?.count ?? 0; + const triangleCount = (mesh?.geometry.getIndex()?.count ?? 0) / 3; + if ( + meshes.length !== 1 + || !mesh + || !material + || positionCount !== asset.uploadedVertices + || triangleCount !== asset.triangles + ) { + disposeRealmObject(loaded.scene); + throw new Error('Lowlands Rabbit compact runtime structure changed.'); + } + let released = false; + return Object.freeze({ + assetUrl, + geometry: mesh.geometry, + material, + sourceRoot: loaded.scene, + release: () => { + if (released) return; + released = true; + disposeRealmObject(loaded.scene); + } + }); +} diff --git a/src/components/realm/realmForestLayer.ts b/src/components/realm/realmForestLayer.ts index 42262a54..e1b8865e 100644 --- a/src/components/realm/realmForestLayer.ts +++ b/src/components/realm/realmForestLayer.ts @@ -44,6 +44,11 @@ import { type RealmForestFallbackType, type RealmForestGroundingMode } from './createRealmProceduralForestFallback'; +import { + applyRealmForestWindMaterial, + REALM_FOREST_LIVING_CANOPY_MOTION_STATE, + type RealmForestWindMaterialController +} from './createRealmForestWindMaterial'; const HEX_SIZE = 1; const TREE_TERRAIN_LIFT = 0.002; @@ -60,7 +65,7 @@ export type RealmForestLayerPresentationTelemetry = Readonly<{ fallbackType: RealmForestFallbackType; contactShadowCount: number; groundingMode: RealmForestGroundingMode; - canopyMotionState: typeof REALM_FOREST_CANOPY_MOTION_STATE; + canopyMotionState: 'static' | typeof REALM_FOREST_LIVING_CANOPY_MOTION_STATE; structureCellCounts: RealmForestStructureCounts; silhouetteCoverageRatio: number; /** Canonical selected-LOD total, independent of temporary fallback state. */ @@ -70,11 +75,15 @@ export type RealmForestLayerPresentationTelemetry = Readonly<{ /** Bounded aggregate only; no per-tree climate data leaves this layer. */ snowTintedTreeCount: number; dryTintedTreeCount: number; + windAttributeBytes: number; + shaderFallbackCount: number; }>; export type RealmForestLayer = Readonly<{ group: THREE.Group; getPresentationTelemetry: () => RealmForestLayerPresentationTelemetry; + updateWind: (seconds: number) => boolean; + isAnimationActive: () => boolean; dispose: () => void; }>; @@ -103,6 +112,7 @@ export type CreateRealmForestLayerOptions = Readonly<{ northernSnow?: RealmNorthernSnowField; /** Immutable renderer-only climate sampled only during static batch builds. */ southernDesert?: RealmSouthernDesertField; + reducedMotion?: boolean; }>; type MutableTreeGeometry = { @@ -110,6 +120,8 @@ type MutableTreeGeometry = { normals: number[]; colors: number[]; indices: number[]; + windWeights: number[]; + windPhases: number[]; hasCompleteNormals: boolean; }; @@ -144,7 +156,8 @@ function appendPrimitive( instanceMatrix: THREE.Matrix4, habitat: RealmForestTreePoint['habitat'], snowCoverage: number, - sandCoverage: number + sandCoverage: number, + motionEnabled: boolean ) { const position = primitive.geometry.getAttribute('position'); if (!position || position.count === 0) return 0; @@ -169,6 +182,19 @@ function appendPrimitive( .set(component(position, index, 0), component(position, index, 1), component(position, index, 2)) .applyMatrix4(transform); output.positions.push(positionVector.x, positionVector.y, positionVector.z); + if (motionEnabled) { + const relativeHeight = Math.max(0, positionVector.y - groundY); + const windWeight = THREE.MathUtils.smoothstep( + relativeHeight, + HEGEMONY_TREE_TARGET_VISUAL_HEIGHT * 0.16, + HEGEMONY_TREE_TARGET_VISUAL_HEIGHT * 0.9 + ); + output.windWeights.push(Math.round(windWeight * 255)); + const windPhase = Math.sin( + positionVector.x * 17.13 + positionVector.z * 29.71 + relativeHeight * 7.19 + ) * 0.5 + 0.5; + output.windPhases.push(Math.round(THREE.MathUtils.clamp(windPhase, 0, 1) * 255)); + } if (normalAttribute) { normalVector @@ -275,13 +301,16 @@ function createMergedTreeMesh( map: RealmTerrainMap, terrainPlacements: readonly TerrainStructurePlacement[], northernSnow: RealmNorthernSnowField | undefined, - southernDesert: RealmSouthernDesertField | undefined + southernDesert: RealmSouthernDesertField | undefined, + motionEnabled: boolean ) { const source: MutableTreeGeometry = { positions: [], normals: [], colors: [], indices: [], + windWeights: [], + windPhases: [], hasCompleteNormals: true }; let snowTintedTreeCount = 0; @@ -306,7 +335,8 @@ function createMergedTreeMesh( matrix, point.habitat, snowCoverage, - sandCoverage + sandCoverage, + motionEnabled ); }); if ((treeTintFlags & FOREST_TINT_SNOW) !== 0) snowTintedTreeCount += 1; @@ -323,10 +353,21 @@ function createMergedTreeMesh( metalness: 0, side: THREE.DoubleSide }); + const wind = applyRealmForestWindMaterial(material, motionEnabled); try { geometry.setAttribute('position', new THREE.Float32BufferAttribute(source.positions, 3)); geometry.setAttribute('normal', new THREE.Float32BufferAttribute(source.normals, 3)); geometry.setAttribute('color', new THREE.Float32BufferAttribute(source.colors, 3)); + if (motionEnabled) { + geometry.setAttribute( + 'realmForestWindWeight', + new THREE.Uint8BufferAttribute(source.windWeights, 1, true) + ); + geometry.setAttribute( + 'realmForestWindPhase', + new THREE.Uint8BufferAttribute(source.windPhases, 1, true) + ); + } geometry.setIndex(new THREE.Uint32BufferAttribute(source.indices, 1)); if (!source.hasCompleteNormals) geometry.computeVertexNormals(); geometry.computeBoundingBox(); @@ -340,7 +381,11 @@ function createMergedTreeMesh( mesh, triangleCount: source.indices.length / 3, snowTintedTreeCount, - dryTintedTreeCount + dryTintedTreeCount, + wind, + windAttributeBytes: motionEnabled + ? source.windWeights.length + source.windPhases.length + : 0 }); } catch (error) { geometry.dispose(); @@ -354,13 +399,16 @@ function createFallbackForestMesh( map: RealmTerrainMap, terrainPlacements: readonly TerrainStructurePlacement[], northernSnow: RealmNorthernSnowField | undefined, - southernDesert: RealmSouthernDesertField | undefined + southernDesert: RealmSouthernDesertField | undefined, + motionEnabled: boolean ) { const fallback = createRealmProceduralForestFallbackGeometry( - HEGEMONY_TREE_TARGET_VISUAL_HEIGHT + HEGEMONY_TREE_TARGET_VISUAL_HEIGHT, + motionEnabled ); const { geometry } = fallback; const material = createRealmProceduralForestFallbackMaterial(); + const wind = applyRealmForestWindMaterial(material, motionEnabled); let mesh: THREE.InstancedMesh; try { mesh = new THREE.InstancedMesh(geometry, material, points.length); @@ -406,15 +454,37 @@ function createFallbackForestMesh( mesh, triangleCount: fallback.triangleCount * points.length, snowTintedTreeCount, - dryTintedTreeCount + dryTintedTreeCount, + wind, + windAttributeBytes: motionEnabled + ? geometry.getAttribute('realmForestWindWeight').count + + geometry.getAttribute('realmForestWindPhase').count + : 0 }); } function disposeMesh(mesh: THREE.Mesh | THREE.InstancedMesh) { mesh.removeFromParent(); - mesh.geometry.dispose(); + if (mesh instanceof THREE.InstancedMesh) { + try { + mesh.dispose(); + } catch { + // Continue releasing the shared geometry and materials below. + } + } + try { + mesh.geometry.dispose(); + } catch { + // A renderer cleanup failure must not strand the material resources. + } const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; - materials.forEach((material) => material.dispose()); + materials.forEach((material) => { + try { + material.dispose(); + } catch { + // Dispose every remaining material independently. + } + }); } async function acquireTreePrefabsStaged( @@ -486,14 +556,18 @@ export function createRealmForestLayer( fallbackType: 'none', contactShadowCount: 0, groundingMode: 'none', - canopyMotionState: REALM_FOREST_CANOPY_MOTION_STATE, + canopyMotionState: 'static', structureCellCounts, silhouetteCoverageRatio: 0, canonicalTriangleCount, triangleCount: 0, snowTintedTreeCount: 0, - dryTintedTreeCount: 0 + dryTintedTreeCount: 0, + windAttributeBytes: 0, + shaderFallbackCount: 0 }), + updateWind: () => false, + isAnimationActive: () => false, dispose: () => { if (disposed) return; disposed = true; @@ -506,13 +580,16 @@ export function createRealmForestLayer( options.map, options.terrainPlacements, options.northernSnow, - options.southernDesert + options.southernDesert, + options.reducedMotion !== true && options.quality.id !== 'reduced' ); group.add(fallback.mesh); let activeMesh: THREE.Mesh | THREE.InstancedMesh = fallback.mesh; let activeTriangleCount = fallback.triangleCount; let activeSnowTintedTreeCount = fallback.snowTintedTreeCount; let activeDryTintedTreeCount = fallback.dryTintedTreeCount; + let activeWind: RealmForestWindMaterialController = fallback.wind; + let activeWindAttributeBytes = fallback.windAttributeBytes; let usingFallback = true; let disposed = false; const abortController = new AbortController(); @@ -555,7 +632,8 @@ export function createRealmForestLayer( options.map, options.terrainPlacements, options.northernSnow, - options.southernDesert + options.southernDesert, + options.reducedMotion !== true && options.quality.id !== 'reduced' ); if (disposed) { disposeMesh(next.mesh); @@ -567,6 +645,8 @@ export function createRealmForestLayer( activeTriangleCount = next.triangleCount; activeSnowTintedTreeCount = next.snowTintedTreeCount; activeDryTintedTreeCount = next.dryTintedTreeCount; + activeWind = next.wind; + activeWindAttributeBytes = next.windAttributeBytes; usingFallback = false; disposeMesh(previousMesh); options.onModelReady?.(); @@ -595,14 +675,20 @@ export function createRealmForestLayer( : usingFallback ? 'terrain-canopy-procedural-root-contact' : 'terrain-canopy-baked-base', - canopyMotionState: REALM_FOREST_CANOPY_MOTION_STATE, + canopyMotionState: !disposed && activeWind.isActive() + ? REALM_FOREST_LIVING_CANOPY_MOTION_STATE + : 'static', structureCellCounts, silhouetteCoverageRatio: disposed ? 0 : silhouetteCoverageRatio, canonicalTriangleCount, triangleCount: disposed ? 0 : activeTriangleCount, snowTintedTreeCount: disposed ? 0 : activeSnowTintedTreeCount, - dryTintedTreeCount: disposed ? 0 : activeDryTintedTreeCount + dryTintedTreeCount: disposed ? 0 : activeDryTintedTreeCount, + windAttributeBytes: disposed ? 0 : activeWindAttributeBytes, + shaderFallbackCount: activeWind.getTelemetry().fallbackCount }), + updateWind: (seconds) => !disposed && activeWind.setTime(seconds), + isAnimationActive: () => !disposed && activeWind.isActive(), dispose: () => { if (disposed) return; disposed = true; diff --git a/src/components/realm/realmLivingEnvironment.ts b/src/components/realm/realmLivingEnvironment.ts new file mode 100644 index 00000000..562c978f --- /dev/null +++ b/src/components/realm/realmLivingEnvironment.ts @@ -0,0 +1,63 @@ +import { REALM_PREVAILING_WIND } from '../../game/map/realmPrevailingWind'; + +export const REALM_LIVING_ENVIRONMENT_REVISION = 'living-realm-v1'; + +export type RealmLivingEnvironmentSample = { + timeSeconds: number; + windX: number; + windZ: number; + gust: number; +}; + +function finiteSeconds(value: number) { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +/** + * Renderer-neutral counterpart to the GLSL gust function below. It is useful + * for deterministic planning and tests; renderers still advance it only from + * the existing Realm ambient scheduler. + */ +export function sampleRealmLivingEnvironment( + seconds: number, + worldX: number, + worldZ: number, + target: RealmLivingEnvironmentSample +) { + const safeSeconds = finiteSeconds(seconds); + const safeX = Number.isFinite(worldX) ? worldX : 0; + const safeZ = Number.isFinite(worldZ) ? worldZ : 0; + const alongWind = safeX * REALM_PREVAILING_WIND.x + + safeZ * REALM_PREVAILING_WIND.z; + const acrossWind = safeX * -REALM_PREVAILING_WIND.z + + safeZ * REALM_PREVAILING_WIND.x; + const front = Math.sin(alongWind * 0.21 - safeSeconds * 0.34); + const secondary = Math.sin(acrossWind * 0.087 + safeSeconds * 0.19 + 1.7); + const shapedFront = Math.max(0, Math.min(1, (front + 0.64) / 1.46)); + target.timeSeconds = safeSeconds; + target.windX = REALM_PREVAILING_WIND.x; + target.windZ = REALM_PREVAILING_WIND.z; + target.gust = Math.max(0, Math.min(1, shapedFront * 0.82 + (secondary * 0.5 + 0.5) * 0.18)); + return target; +} + +const finiteGlslFloatLiteral = (value: number) => { + const literal = Number.isFinite(value) ? value.toFixed(9) : '0.0'; + return literal.includes('.') ? literal : `${literal}.0`; +}; + +export const REALM_LIVING_WIND_GLSL = `vec2(${finiteGlslFloatLiteral( + REALM_PREVAILING_WIND.x +)}, ${finiteGlslFloatLiteral(REALM_PREVAILING_WIND.z)})`; + +/** Shared bounded gust field injected into existing subsystem materials. */ +export const REALM_LIVING_GUST_GLSL = ` +float realmLivingGust(vec2 worldXZ, float livingTime) { + vec2 livingWind = ${REALM_LIVING_WIND_GLSL}; + vec2 livingCross = vec2(-livingWind.y, livingWind.x); + float livingFront = sin(dot(worldXZ, livingWind) * 0.21 - livingTime * 0.34); + float livingSecondary = sin(dot(worldXZ, livingCross) * 0.087 + livingTime * 0.19 + 1.7); + float livingShapedFront = clamp((livingFront + 0.64) / 1.46, 0.0, 1.0); + return clamp(livingShapedFront * 0.82 + (livingSecondary * 0.5 + 0.5) * 0.18, 0.0, 1.0); +} +`; diff --git a/src/components/realm/realmQuality.ts b/src/components/realm/realmQuality.ts index 04210708..8ddc6558 100644 --- a/src/components/realm/realmQuality.ts +++ b/src/components/realm/realmQuality.ts @@ -4,6 +4,72 @@ import type { RealmGrassRenderPlan } from './realmGrassActiveWindow'; export type RealmQuality = 'high' | 'balanced' | 'reduced'; +export type RealmLivingRealmBudget = Readonly<{ + grassDisturbanceSlots: 0 | 4 | 8; + waterRippleSlots: 0 | 2 | 4; + forestGustEnabled: boolean; + birdInstances: 0 | 6 | 12; + rabbitInstances: 0 | 6 | 10; + moteCount: 0 | 18 | 36; + transientParticleCount: 0 | 48 | 96; + plannerHz: 0 | 7 | 10; + addedDrawCalls: 0 | 3; + addedTriangles: 0 | 888 | 1484; +}>; + +/** + * Hard, reviewable ceilings for optional presentation-only ecology. These + * limits are independent of world cardinality and resolve to zero before any + * moving ambience is allocated for Reduced quality or reduced motion. + */ +export const REALM_LIVING_REALM_BUDGETS = Object.freeze({ + high: Object.freeze({ + grassDisturbanceSlots: 8, + waterRippleSlots: 4, + forestGustEnabled: true, + birdInstances: 12, + rabbitInstances: 10, + moteCount: 36, + transientParticleCount: 96, + plannerHz: 10, + addedDrawCalls: 3, + addedTriangles: 1484 + }), + balanced: Object.freeze({ + grassDisturbanceSlots: 4, + waterRippleSlots: 2, + forestGustEnabled: true, + birdInstances: 6, + rabbitInstances: 6, + moteCount: 18, + transientParticleCount: 48, + plannerHz: 7, + addedDrawCalls: 3, + addedTriangles: 888 + }), + reduced: Object.freeze({ + grassDisturbanceSlots: 0, + waterRippleSlots: 0, + forestGustEnabled: false, + birdInstances: 0, + rabbitInstances: 0, + moteCount: 0, + transientParticleCount: 0, + plannerHz: 0, + addedDrawCalls: 0, + addedTriangles: 0 + }) +} satisfies Readonly>); + +export function resolveRealmLivingRealmBudget( + quality: RealmQuality, + reducedMotion: boolean +): RealmLivingRealmBudget { + return reducedMotion + ? REALM_LIVING_REALM_BUDGETS.reduced + : REALM_LIVING_REALM_BUDGETS[quality]; +} + export type RealmLightingSpec = Readonly<{ toneMappingExposure: number; sunIntensity: number; @@ -193,7 +259,7 @@ export const REALM_GRASS_RENDER_PLANS: Readonly; + +export type RealmSurfaceDisturbanceSnapshot = Readonly<{ + count: number; + /** Packed x/z centers. Storage is stable for the lifetime of the field. */ + centers: Float32Array; + /** Packed radius/current-strength/normalized-age/lifetime values. */ + params: Float32Array; +}>; + +export type RealmSurfaceDisturbanceTelemetry = Readonly<{ + capacity: number; + activeGrassCount: number; + activeWaterCount: number; + insertedCount: number; + evictedCount: number; + droppedCount: number; +}>; + +export type RealmSurfaceDisturbanceField = Readonly<{ + push: (input: RealmSurfaceDisturbanceInput) => boolean; + snapshot: ( + kind: RealmSurfaceDisturbanceKind, + seconds: number, + maximumSlots: number + ) => RealmSurfaceDisturbanceSnapshot; + getTelemetry: (seconds: number) => RealmSurfaceDisturbanceTelemetry; + clear: () => void; + dispose: () => void; +}>; + +const MAX_FIELD_CAPACITY = 16; +const MAX_SNAPSHOT_SLOTS = 8; + +function finite(value: number, fallback = 0) { + return Number.isFinite(value) ? value : fallback; +} + +export function createRealmSurfaceDisturbanceField( + requestedCapacity: number | Readonly<{ + grassCapacity: number; + waterCapacity: number; + }> +): RealmSurfaceDisturbanceField { + const sharedCapacity = typeof requestedCapacity === 'number'; + const grassCapacity = Math.min( + MAX_FIELD_CAPACITY, + Math.max(0, Math.trunc(finite( + sharedCapacity ? requestedCapacity : requestedCapacity.grassCapacity + ))) + ); + const waterCapacity = Math.min( + MAX_FIELD_CAPACITY, + Math.max(0, Math.trunc(finite( + sharedCapacity ? requestedCapacity : requestedCapacity.waterCapacity + ))) + ); + const capacity = sharedCapacity + ? grassCapacity + : Math.min(MAX_FIELD_CAPACITY, grassCapacity + waterCapacity); + const kinds = new Uint8Array(capacity); + const x = new Float32Array(capacity); + const z = new Float32Array(capacity); + const radius = new Float32Array(capacity); + const strength = new Float32Array(capacity); + const createdAt = new Float64Array(capacity); + const lifetime = new Float32Array(capacity); + const occupied = new Uint8Array(capacity); + const scratchIndices = new Int16Array(capacity); + const grassCenters = new Float32Array(MAX_SNAPSHOT_SLOTS * 2); + const grassParams = new Float32Array(MAX_SNAPSHOT_SLOTS * 4); + const waterCenters = new Float32Array(MAX_SNAPSHOT_SLOTS * 2); + const waterParams = new Float32Array(MAX_SNAPSHOT_SLOTS * 4); + let insertedCount = 0; + let evictedCount = 0; + let droppedCount = 0; + let disposed = false; + + const kindCode = (kind: RealmSurfaceDisturbanceKind) => kind === 'water' ? 2 : 1; + const isAlive = (index: number, seconds: number) => occupied[index] === 1 + && seconds >= createdAt[index]! + && seconds - createdAt[index]! < lifetime[index]!; + + const clearExpired = (seconds: number) => { + for (let index = 0; index < capacity; index += 1) { + if (occupied[index] === 1 && !isAlive(index, seconds)) occupied[index] = 0; + } + }; + + return Object.freeze({ + push: (input) => { + if (disposed || capacity === 0) return false; + const inputKind = kindCode(input.kind); + const inputX = finite(input.x); + const inputZ = finite(input.z); + const inputRadius = Math.max(0.05, Math.min(4, finite(input.radius, 0.5))); + const inputStrength = Math.max(0, Math.min(1, finite(input.strength))); + const inputCreatedAt = Math.max(0, finite(input.createdAtSeconds)); + const inputLifetime = Math.max(0.05, Math.min(8, finite(input.lifetimeSeconds, 1))); + if (inputStrength <= 0) return false; + clearExpired(inputCreatedAt); + const kindCapacity = inputKind === 2 ? waterCapacity : grassCapacity; + if (kindCapacity === 0) return false; + let target = -1; + let oldestTime = Number.POSITIVE_INFINITY; + let activeKindCount = 0; + if (!sharedCapacity) { + let emptySlot = -1; + for (let index = 0; index < capacity; index += 1) { + if (occupied[index] === 0 && emptySlot < 0) { + emptySlot = index; + } + if (occupied[index] === 1 && kinds[index] === inputKind) { + activeKindCount += 1; + if (createdAt[index]! < oldestTime) { + oldestTime = createdAt[index]!; + target = index; + } + } + } + if (activeKindCount < kindCapacity) { + target = emptySlot; + } else if (target >= 0) { + evictedCount += 1; + } + } else { + for (let index = 0; index < capacity; index += 1) { + if (occupied[index] === 0) { + target = index; + break; + } + if (createdAt[index]! < oldestTime) { + oldestTime = createdAt[index]!; + target = index; + } + } + } + if (target < 0) { + droppedCount += 1; + return false; + } + if (sharedCapacity && occupied[target] === 1) { + evictedCount += 1; + } + kinds[target] = inputKind; + x[target] = inputX; + z[target] = inputZ; + radius[target] = inputRadius; + strength[target] = inputStrength; + createdAt[target] = inputCreatedAt; + lifetime[target] = inputLifetime; + occupied[target] = 1; + insertedCount += 1; + return true; + }, + snapshot: (kind, seconds, maximumSlots) => { + const safeSeconds = Math.max(0, finite(seconds)); + const slots = Math.min( + MAX_SNAPSHOT_SLOTS, + Math.max(0, Math.trunc(finite(maximumSlots))) + ); + const centers = kind === 'water' ? waterCenters : grassCenters; + const params = kind === 'water' ? waterParams : grassParams; + centers.fill(0); + params.fill(0); + if (disposed || slots === 0) return Object.freeze({ count: 0, centers, params }); + clearExpired(safeSeconds); + const code = kindCode(kind); + let candidateCount = 0; + for (let index = 0; index < capacity; index += 1) { + if (isAlive(index, safeSeconds) && kinds[index] === code) { + scratchIndices[candidateCount] = index; + candidateCount += 1; + } + } + // Small fixed pool: stable insertion sort keeps newest disturbances. + for (let index = 1; index < candidateCount; index += 1) { + const candidate = scratchIndices[index]!; + let cursor = index - 1; + while (cursor >= 0 && createdAt[scratchIndices[cursor]!]! < createdAt[candidate]!) { + scratchIndices[cursor + 1] = scratchIndices[cursor]!; + cursor -= 1; + } + scratchIndices[cursor + 1] = candidate; + } + const count = Math.min(slots, candidateCount); + for (let slot = 0; slot < count; slot += 1) { + const source = scratchIndices[slot]!; + const age = Math.max(0, safeSeconds - createdAt[source]!); + const normalizedAge = Math.min(1, age / lifetime[source]!); + centers[slot * 2] = x[source]!; + centers[slot * 2 + 1] = z[source]!; + params[slot * 4] = radius[source]!; + params[slot * 4 + 1] = strength[source]! * (1 - normalizedAge); + params[slot * 4 + 2] = normalizedAge; + params[slot * 4 + 3] = lifetime[source]!; + } + return Object.freeze({ count, centers, params }); + }, + getTelemetry: (seconds) => { + const safeSeconds = Math.max(0, finite(seconds)); + if (!disposed) clearExpired(safeSeconds); + let activeGrassCount = 0; + let activeWaterCount = 0; + for (let index = 0; index < capacity; index += 1) { + if (!isAlive(index, safeSeconds)) continue; + if (kinds[index] === 1) activeGrassCount += 1; + if (kinds[index] === 2) activeWaterCount += 1; + } + return Object.freeze({ + capacity, + activeGrassCount, + activeWaterCount, + insertedCount, + evictedCount, + droppedCount + }); + }, + clear: () => { + occupied.fill(0); + grassCenters.fill(0); + grassParams.fill(0); + waterCenters.fill(0); + waterParams.fill(0); + }, + dispose: () => { + disposed = true; + occupied.fill(0); + } + }); +} diff --git a/src/components/realm/realmWaterLayer.ts b/src/components/realm/realmWaterLayer.ts index 6dad05a1..9d00757f 100644 --- a/src/components/realm/realmWaterLayer.ts +++ b/src/components/realm/realmWaterLayer.ts @@ -16,7 +16,10 @@ import { type GenesisWaterBodyV1, type GenesisWaterCellV1 } from '../../../spacetimedb/src/waterWorld'; -import type { RealmQualitySpec } from './realmQuality'; +import { + resolveRealmLivingRealmBudget, + type RealmQualitySpec +} from './realmQuality'; import { pointyHexCorners } from './createTerrainGeometry'; import { GENESIS_WATER_REVISION_ENABLED_CELLS_V1, @@ -37,6 +40,7 @@ import { type RealmRiverBankPresentation, type RealmRiverBoundaryEdge } from '../../game/map/realmRiverBankPresentation'; +import type { RealmSurfaceDisturbanceSnapshot } from './realmSurfaceDisturbanceField'; const WATER_Y_LIFT = 0.035; const RIVER_BANK_BLEND = 0.28; @@ -110,6 +114,8 @@ export type RealmWaterLayerTelemetry = Readonly<{ riverIncompleteCellCount: number; riverOverlappingPhysicalTriangleCount: number; shaderFallbackCount: number; + rippleSlotCount: number; + activeRippleCount: number; riverFallbackReasons: readonly Readonly<{ bodyId: string; reason: string; @@ -130,7 +136,10 @@ export type RealmWaterLayer = Readonly<{ getCellPresentation: (cellKey: string) => GenesisWaterCellV1 | undefined; setSelectedCellKey: (cellKey: string | null) => void; setHoveredCellKey: (cellKey: string | null) => void; - updateEnvironment: (elapsedSeconds: number) => boolean; + updateEnvironment: ( + elapsedSeconds: number, + disturbances?: RealmSurfaceDisturbanceSnapshot | null + ) => boolean; isAnimationActive: () => boolean; getTelemetry: () => RealmWaterLayerTelemetry; dispose: () => void; @@ -188,11 +197,11 @@ function flowForCell( } function regimeColor(cell: GenesisWaterCellV1): THREE.Color { - if (cell.regime === 'river') return new THREE.Color('#315e64'); - if (cell.regime === 'lake') return new THREE.Color('#548eac'); + if (cell.regime === 'river') return new THREE.Color('#38676d'); + if (cell.regime === 'lake') return new THREE.Color('#4c8198'); const depth = GENESIS_OCEAN_DEPTH_BY_KEY.get(cell.cellKey) ?? cell.depthCells; - return depth >= 5 ? new THREE.Color('#315b78') : depth >= 3 - ? new THREE.Color('#3c7691') : new THREE.Color('#4f91ab'); + return depth >= 5 ? new THREE.Color('#294e69') : depth >= 3 + ? new THREE.Color('#356b86') : new THREE.Color('#4b879f'); } function waterPointKey(point: HexWorldPosition) { @@ -970,6 +979,7 @@ function createWaterMaterial( quality: RealmQualitySpec, reducedMotion: boolean, river: boolean, + rippleSlotCount: number, onShaderFallback: () => void ) { const material = new THREE.MeshStandardMaterial({ @@ -977,8 +987,8 @@ function createWaterMaterial( // Keep the material base neutral so the authoritative per-regime vertex // palette is not multiplied back toward the pale Lowlands ground tint. color: '#ffffff', - roughness: river ? 0.34 : 0.27, - metalness: 0.04, + roughness: river ? 0.29 : 0.19, + metalness: 0.02, transparent: false, depthWrite: true, fog: true @@ -992,7 +1002,21 @@ function createWaterMaterial( ? 1 : 0 : REALM_WATER_RENDER_BUDGETS[quality.id].waveComponents; - const uniforms = { uWaterTime: { value: 0 } }; + const safeRippleSlotCount = Math.max(0, Math.min(4, Math.trunc(rippleSlotCount))); + const rippleCenters = Array.from( + { length: safeRippleSlotCount }, + () => new THREE.Vector2() + ); + const rippleParams = Array.from( + { length: safeRippleSlotCount }, + () => new THREE.Vector4() + ); + const uniforms = { + uWaterTime: { value: 0 }, + uWaterRippleCount: { value: 0 }, + uWaterRippleCenters: { value: rippleCenters }, + uWaterRippleParams: { value: rippleParams } + }; const oceanWaveTerms = Array.from({ length: activeWaveComponents }, (_, index) => { const ordinal = index + 1; const directionX = (0.54 + ((ordinal * 17) % 31) / 100).toFixed(3); @@ -1016,18 +1040,57 @@ function createWaterMaterial( ].slice(0, activeWaveComponents); const effectiveWaveTerms = river ? riverWaveTerms : oceanWaveTerms; const timeUniform = activeWaveComponents > 0 ? 'uniform float uWaterTime;\n' : ''; - const heightFunction = activeWaveComponents === 0 - ? 'float warpkeepWaterHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow, float waterFlowAccumulation, float waterFeaturePhase) { return 0.0; }' - : `float warpkeepWaterHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow, float waterFlowAccumulation, float waterFeaturePhase) { + const baseHeightFunction = activeWaveComponents === 0 + ? 'float warpkeepWaterBaseHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow, float waterFlowAccumulation, float waterFeaturePhase) { return 0.0; }' + : `float warpkeepWaterBaseHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow, float waterFlowAccumulation, float waterFeaturePhase) { return ${effectiveWaveTerms.join(' + ')}; }`; + const rippleUniforms = safeRippleSlotCount > 0 ? ` +uniform int uWaterRippleCount; +uniform vec2 uWaterRippleCenters[${safeRippleSlotCount}]; +uniform vec4 uWaterRippleParams[${safeRippleSlotCount}]; +` : ''; + const rippleTerms = safeRippleSlotCount > 0 + ? Array.from({ length: safeRippleSlotCount }, (_, slot) => ` + if (uWaterRippleCount > ${slot}) { + vec2 waterRippleDelta${slot} = waterWorldXZ - uWaterRippleCenters[${slot}]; + float waterRippleDistance${slot} = length(waterRippleDelta${slot}); + float waterRippleRadius${slot} = max(0.08, uWaterRippleParams[${slot}].x); + float waterRippleAge${slot} = clamp(uWaterRippleParams[${slot}].z, 0.0, 1.0); + float waterRippleRing${slot} = waterRippleRadius${slot} * mix(0.12, 1.72, waterRippleAge${slot}); + float waterRippleWidth${slot} = max(0.07, waterRippleRadius${slot} * 0.19); + float waterRipplePhase${slot} = (waterRippleDistance${slot} - waterRippleRing${slot}) / waterRippleWidth${slot}; + float waterRippleEnvelope${slot} = exp(-4.0 * waterRipplePhase${slot} * waterRipplePhase${slot}); + float waterRippleAmplitude${slot} = uWaterRippleParams[${slot}].y * 0.032; + rippleHeight += waterRippleEnvelope${slot} * waterRippleAmplitude${slot}; + float waterRippleDerivative${slot} = waterRippleEnvelope${slot} + * waterRippleAmplitude${slot} * (-8.0 * waterRipplePhase${slot}) + / waterRippleWidth${slot}; + rippleGradient += (waterRippleDistance${slot} > 0.0001 + ? waterRippleDelta${slot} / waterRippleDistance${slot} + : vec2(0.0)) * waterRippleDerivative${slot}; + } +`).join('') + : ''; + const rippleFunction = ` +void warpkeepWaterRipple(vec2 waterWorldXZ, out float rippleHeight, out vec2 rippleGradient) { + rippleHeight = 0.0; + rippleGradient = vec2(0.0); +${rippleTerms}} +float warpkeepWaterHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow, float waterFlowAccumulation, float waterFeaturePhase) { + float rippleHeight; + vec2 rippleGradient; + warpkeepWaterRipple(waterWorldXZ, rippleHeight, rippleGradient); + return warpkeepWaterBaseHeight(waterWorldXZ, waterRegime, waterFlow, waterFlowAccumulation, waterFeaturePhase) + rippleHeight; +} +`; const foamQualityScale = quality.id === 'high' ? 1 : quality.id === 'balanced' ? 0.62 : 0; const waterTimeExpression = activeWaveComponents > 0 ? 'uWaterTime' : '0.0'; - const shaderContract = `warpkeep-water-world-space-r185-${river ? 'river' : 'ocean'}-v6`; + const shaderContract = `warpkeep-water-world-space-r185-${river ? 'river' : 'ocean'}-v8-reflection-ripples-${safeRippleSlotCount}`; let shaderFallback = false; material.onBeforeCompile = (shader) => { if ( @@ -1039,6 +1102,7 @@ function createWaterMaterial( if (!shaderFallback) { shaderFallback = true; material.userData.waterWaveComponents = 0; + material.userData.waterRippleSlots = 0; material.userData.waterShaderFallbackReason = 'shader-contract-changed'; material.userData.waterShaderFallbackPresentation = 'full-mesh-fog-color'; onShaderFallback(); @@ -1064,7 +1128,12 @@ function createWaterMaterial( return; } if (activeWaveComponents > 0) shader.uniforms.uWaterTime = uniforms.uWaterTime; - shader.vertexShader = `${timeUniform} + if (safeRippleSlotCount > 0) { + shader.uniforms.uWaterRippleCount = uniforms.uWaterRippleCount; + shader.uniforms.uWaterRippleCenters = uniforms.uWaterRippleCenters; + shader.uniforms.uWaterRippleParams = uniforms.uWaterRippleParams; + } + shader.vertexShader = `${timeUniform}${rippleUniforms} attribute float waterDepth; attribute float waterBankBlend; attribute float waterFogMix; @@ -1088,7 +1157,8 @@ varying float vWarpkeepWaterSourceMix; varying float vWarpkeepWaterMouthMix; varying vec2 vWarpkeepWaterWorldXZ; varying vec2 vWarpkeepWaterFlow; -${heightFunction} +${baseHeightFunction} +${rippleFunction} ${shader.vertexShader}` .replace('#include ', `#include vWarpkeepWaterDepth = waterDepth; @@ -1118,9 +1188,14 @@ ${shader.vertexShader}` float warpkeepWaterEpsilon = 0.045; float warpkeepWaterWaveVisibility = 1.0 - clamp(waterFogMix, 0.0, 1.0); vec2 warpkeepWaterNormalWorldXZ = (modelMatrix * vec4(position, 1.0)).xz; - float warpkeepWaterNormalHeight = warpkeepWaterHeight(warpkeepWaterNormalWorldXZ, waterRegime, vec2(waterFlowX, waterFlowZ), waterFlowAccumulation, waterFeaturePhase); - float warpkeepWaterDx = ((warpkeepWaterHeight(warpkeepWaterNormalWorldXZ + vec2(warpkeepWaterEpsilon, 0.0), waterRegime, vec2(waterFlowX, waterFlowZ), waterFlowAccumulation, waterFeaturePhase) - warpkeepWaterNormalHeight) / warpkeepWaterEpsilon) * warpkeepWaterWaveVisibility; - float warpkeepWaterDz = ((warpkeepWaterHeight(warpkeepWaterNormalWorldXZ + vec2(0.0, warpkeepWaterEpsilon), waterRegime, vec2(waterFlowX, waterFlowZ), waterFlowAccumulation, waterFeaturePhase) - warpkeepWaterNormalHeight) / warpkeepWaterEpsilon) * warpkeepWaterWaveVisibility; + float warpkeepWaterNormalHeight = warpkeepWaterBaseHeight(warpkeepWaterNormalWorldXZ, waterRegime, vec2(waterFlowX, waterFlowZ), waterFlowAccumulation, waterFeaturePhase); + float warpkeepWaterDx = ((warpkeepWaterBaseHeight(warpkeepWaterNormalWorldXZ + vec2(warpkeepWaterEpsilon, 0.0), waterRegime, vec2(waterFlowX, waterFlowZ), waterFlowAccumulation, waterFeaturePhase) - warpkeepWaterNormalHeight) / warpkeepWaterEpsilon) * warpkeepWaterWaveVisibility; + float warpkeepWaterDz = ((warpkeepWaterBaseHeight(warpkeepWaterNormalWorldXZ + vec2(0.0, warpkeepWaterEpsilon), waterRegime, vec2(waterFlowX, waterFlowZ), waterFlowAccumulation, waterFeaturePhase) - warpkeepWaterNormalHeight) / warpkeepWaterEpsilon) * warpkeepWaterWaveVisibility; + float warpkeepWaterRippleHeight; + vec2 warpkeepWaterRippleGradient; + warpkeepWaterRipple(warpkeepWaterNormalWorldXZ, warpkeepWaterRippleHeight, warpkeepWaterRippleGradient); + warpkeepWaterDx += warpkeepWaterRippleGradient.x * warpkeepWaterWaveVisibility; + warpkeepWaterDz += warpkeepWaterRippleGradient.y * warpkeepWaterWaveVisibility; objectNormal = normalize(vec3(-warpkeepWaterDx, 1.0, -warpkeepWaterDz));`); shader.fragmentShader = `${timeUniform}varying float vWarpkeepWaterDepth; varying float vWarpkeepWaterBankBlend; @@ -1136,15 +1211,23 @@ varying vec2 vWarpkeepWaterWorldXZ; varying vec2 vWarpkeepWaterFlow; ${shader.fragmentShader}` .replace('#include ', ` - float waterViewFacing = max(dot(normalize(vNormal), normalize(-vViewPosition)), 0.0); - float waterFresnel = pow(1.0 - waterViewFacing, 3.0) * (vWarpkeepWaterRegime > 0.5 ? 0.045 : 0.095); - vec3 oceanDeepColor = vec3(0.055, 0.22, 0.34); - vec3 oceanShallowColor = vec3(0.16, 0.48, 0.58); - vec3 riverDeepColor = vec3(0.055, 0.19, 0.21); - vec3 riverShallowColor = vec3(0.16, 0.35, 0.36); + // Three r185 stores the fragment-to-camera direction in vViewPosition. + float waterViewFacing = max(dot(normalize(vNormal), normalize(vViewPosition)), 0.0); + float waterFresnel = pow(1.0 - waterViewFacing, 2.4); + float waterReflectionStrength = waterFresnel + * (vWarpkeepWaterRegime > 0.5 ? 0.085 : 0.18); + vec3 oceanDeepColor = vec3(0.04, 0.15, 0.24); + vec3 oceanShallowColor = vec3(0.12, 0.36, 0.46); + vec3 riverDeepColor = vec3(0.05, 0.17, 0.18); + vec3 riverShallowColor = vec3(0.14, 0.32, 0.33); vec3 waterDeepColor = mix(oceanDeepColor, riverDeepColor, step(0.5, vWarpkeepWaterRegime)); vec3 waterShallowColor = mix(oceanShallowColor, riverShallowColor, step(0.5, vWarpkeepWaterRegime)); vec3 waterBodyColor = mix(waterShallowColor, waterDeepColor, clamp(vWarpkeepWaterDepth, 0.0, 1.0) * 0.78); + vec3 waterReflectionColor = mix( + vec3(0.20, 0.38, 0.48), + vec3(0.48, 0.66, 0.78), + smoothstep(0.0, 0.82, waterViewFacing) + ); vec2 waterFlowDirection = normalize(vWarpkeepWaterFlow + vec2(0.0001)); vec2 waterCrossFlow = vec2(-waterFlowDirection.y, waterFlowDirection.x); float waterDirectionalCurrent = 0.5 + 0.5 * sin( @@ -1179,13 +1262,18 @@ ${shader.fragmentShader}` max(vWarpkeepWaterSourceMix * 0.34, vWarpkeepWaterMouthMix * 0.48) ); float waterFoam = waterHydrologyFoam - * (0.035 + waterCrest * 0.2) + * (0.055 + waterCrest * 0.24) * waterFoamPattern; waterFoam *= ${foamQualityScale.toFixed(2)}; float waterBankEdge = clamp(vWarpkeepWaterBankBlend, 0.0, 1.0); float bankSoftness = 1.0 - waterBankEdge * 0.2; outgoingLight = mix(outgoingLight, outgoingLight * waterBodyColor * 1.65, 0.42); - outgoingLight += (waterBodyColor * waterFresnel + vec3(waterGlimmer)) * bankSoftness; + outgoingLight += vec3(waterGlimmer * 0.72) * bankSoftness; + outgoingLight = mix( + outgoingLight, + waterReflectionColor, + waterReflectionStrength * bankSoftness + ); outgoingLight = mix(outgoingLight, vec3(0.10, 0.20, 0.18), waterBankEdge * 0.12 * step(0.5, vWarpkeepWaterRegime)); float waterTransmission = step(0.5, vWarpkeepWaterRegime) * (vWarpkeepWaterSourceMix * 0.012 + vWarpkeepWaterMouthMix * 0.008); @@ -1203,11 +1291,43 @@ ${shader.fragmentShader}` ); material.userData.waterUniforms = uniforms; material.userData.waterWaveComponents = activeWaveComponents; + material.userData.waterRippleSlots = safeRippleSlotCount; material.userData.waterFoamQualityScale = foamQualityScale; material.userData.waterPhysicalRiverDisplacement = 0; material.userData.waterShaderContract = shaderContract; material.userData.waterShaderFallbackReason = null; material.userData.waterShaderFallbackPresentation = null; + material.userData.setWaterRipples = (snapshot: RealmSurfaceDisturbanceSnapshot | null) => { + if (shaderFallback || safeRippleSlotCount === 0) return false; + const nextCount = Math.min( + safeRippleSlotCount, + Math.max(0, Math.trunc(snapshot?.count ?? 0)) + ); + let changed = uniforms.uWaterRippleCount.value !== nextCount; + uniforms.uWaterRippleCount.value = nextCount; + for (let slot = 0; slot < safeRippleSlotCount; slot += 1) { + const centerOffset = slot * 2; + const paramOffset = slot * 4; + const center = rippleCenters[slot]!; + const params = rippleParams[slot]!; + const nextCenterX = slot < nextCount ? snapshot?.centers[centerOffset] ?? 0 : 0; + const nextCenterZ = slot < nextCount ? snapshot?.centers[centerOffset + 1] ?? 0 : 0; + const nextRadius = slot < nextCount ? snapshot?.params[paramOffset] ?? 0 : 0; + const nextStrength = slot < nextCount ? snapshot?.params[paramOffset + 1] ?? 0 : 0; + const nextAge = slot < nextCount ? snapshot?.params[paramOffset + 2] ?? 0 : 0; + const nextLifetime = slot < nextCount ? snapshot?.params[paramOffset + 3] ?? 0 : 0; + changed = changed + || center.x !== nextCenterX + || center.y !== nextCenterZ + || params.x !== nextRadius + || params.y !== nextStrength + || params.z !== nextAge + || params.w !== nextLifetime; + center.set(nextCenterX, nextCenterZ); + params.set(nextRadius, nextStrength, nextAge, nextLifetime); + } + return changed; + }; return material; } @@ -1253,6 +1373,10 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay const riverBankPresentation = options.riverBankPresentation ?? createRealmRiverBankPresentation(options.cells, options.hexSize); const budget = REALM_WATER_RENDER_BUDGETS[options.quality.id]; + const livingBudget = resolveRealmLivingRealmBudget( + options.quality.id, + options.reducedMotion + ); const group = new THREE.Group(); group.name = 'genesis-canonical-water'; let oceanGeometry: THREE.BufferGeometry | undefined; @@ -1297,18 +1421,21 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay options.quality, options.reducedMotion, false, + livingBudget.waterRippleSlots, recordShaderFallback ); lakeMaterial = createWaterMaterial( options.quality, options.reducedMotion, false, + livingBudget.waterRippleSlots, recordShaderFallback ); riverMaterial = createWaterMaterial( options.quality, options.reducedMotion, true, + livingBudget.waterRippleSlots, recordShaderFallback ); riverMaterial.emissive.set('#143d41'); @@ -1573,10 +1700,17 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay } const animatedMaterials = [waterMaterial, lakeMaterial, riverMaterial].map((material) => ({ material, - uniforms: material.userData.waterUniforms as { uWaterTime: { value: number } } + uniforms: material.userData.waterUniforms as { + uWaterTime: { value: number }; + uWaterRippleCount: { value: number }; + }, + setRipples: material.userData.setWaterRipples as ( + snapshot: RealmSurfaceDisturbanceSnapshot | null + ) => boolean })); const animationActive = () => animatedMaterials.some(({ material }) => ( (material.userData.waterWaveComponents as number) > 0 + || (material.userData.waterRippleSlots as number) > 0 )); const environment = waterLayerRecord(options.environment); const environmentEpoch = typeof environment?.environmentEpoch === 'bigint' @@ -1624,15 +1758,18 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay let cachedTelemetry: RealmWaterLayerTelemetry | undefined; let cachedAnimated = false; let cachedShaderFallbackCount = -1; + let cachedActiveRippleCount = -1; const getTelemetry = (): RealmWaterLayerTelemetry => { const animated = animationActive(); if ( cachedTelemetry && cachedAnimated === animated && cachedShaderFallbackCount === shaderFallbackCount + && cachedActiveRippleCount === animatedMaterials[0]!.uniforms.uWaterRippleCount.value ) return cachedTelemetry; cachedAnimated = animated; cachedShaderFallbackCount = shaderFallbackCount; + cachedActiveRippleCount = animatedMaterials[0]!.uniforms.uWaterRippleCount.value; cachedTelemetry = Object.freeze({ layoutVersion: options.cells === GENESIS_WATER_REVISION_ENABLED_CELLS_V1 ? GENESIS_WATER_REVISION_VERSION @@ -1666,19 +1803,28 @@ export function createRealmWaterLayer(options: WaterLayerOptions): RealmWaterLay ), riverOverlappingPhysicalTriangleCount: 0, shaderFallbackCount, + rippleSlotCount: Math.max( + 0, + ...animatedMaterials.map(({ material }) => material.userData.waterRippleSlots as number) + ), + activeRippleCount: cachedActiveRippleCount, riverFallbackReasons: riverSurfaceData.fallbackReasons }); return cachedTelemetry; }; return { group, - updateEnvironment: (elapsedSeconds) => { + updateEnvironment: (elapsedSeconds, disturbances = null) => { + const ripplesChanged = !disposed && animatedMaterials.reduce( + (changed, material) => material.setRipples(disturbances) || changed, + false + ); if ( disposed || !animationActive() || !Number.isFinite(elapsedSeconds) || elapsedSeconds === lastElapsedSeconds - ) return false; + ) return ripplesChanged; lastElapsedSeconds = elapsedSeconds; let synchronizedServerTimeMicros: bigint | undefined; if (options.nowMicros) { diff --git a/src/dev/RenderedWebglQaHarness.tsx b/src/dev/RenderedWebglQaHarness.tsx index 9633e92f..d64247d9 100644 --- a/src/dev/RenderedWebglQaHarness.tsx +++ b/src/dev/RenderedWebglQaHarness.tsx @@ -389,6 +389,7 @@ export function RenderedWebglQaHarness({ onAudioMutedChange={setAudioMuted} onGraphicsPreferenceChange={setGraphicsPreference} onRequestReturn={() => setPhase({ kind: 'closed' })} + localQaLivingVisualTimeSeconds={8.25} localQaWorkerProjectionTelemetry={ fixtureVariant === 'worker-locomotion' || fixtureVariant === 'worker-locomotion-northern' diff --git a/src/game/map/hegemonyLowlandsSpec.ts b/src/game/map/hegemonyLowlandsSpec.ts index 87e6134d..5881feb8 100644 --- a/src/game/map/hegemonyLowlandsSpec.ts +++ b/src/game/map/hegemonyLowlandsSpec.ts @@ -10,8 +10,8 @@ export const hegemonyLowlandsSpec = { palette: { // Scene-linear values: WebGL writes them directly to vertex colours and // the SVG fallback encodes them once for display-sRGB. - grassBase: { r: 0.39, g: 0.56, b: 0.25 }, - grassCool: { r: 0.27, g: 0.45, b: 0.23 }, + grassBase: { r: 0.42, g: 0.62, b: 0.24 }, + grassCool: { r: 0.30, g: 0.52, b: 0.22 }, soil: { r: 0.52, g: 0.42, b: 0.22 }, dryGrass: { r: 0.62, g: 0.56, b: 0.27 }, stone: { r: 0.45, g: 0.45, b: 0.38 } diff --git a/src/game/map/realmGrass.ts b/src/game/map/realmGrass.ts index 9c02cbf7..c31c4264 100644 --- a/src/game/map/realmGrass.ts +++ b/src/game/map/realmGrass.ts @@ -51,37 +51,37 @@ export const REALM_GRASS_BIOME_PROFILES: Readonly< Record > = Object.freeze({ meadow: Object.freeze({ - kind: 'meadow', highCandidateCount: 34, completelyBareThreshold: 0.25, retention: 0.94, + kind: 'meadow', highCandidateCount: 42, completelyBareThreshold: 0.21, retention: 0.96, height: Object.freeze([0.11, 0.19]), width: Object.freeze([0.34, 0.52]), - palette: palette(['#82985B', '#8CA062', '#91A46C', '#788E53']), - slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.07 + palette: palette(['#8CAA58', '#93B261', '#98B568', '#7FA04E']), + slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.06 }), lowland: Object.freeze({ - kind: 'lowland', highCandidateCount: 30, completelyBareThreshold: 0.29, retention: 0.88, + kind: 'lowland', highCandidateCount: 37, completelyBareThreshold: 0.25, retention: 0.92, height: Object.freeze([0.10, 0.18]), width: Object.freeze([0.32, 0.50]), - palette: palette(['#768B51', '#82965A', '#8B9C63', '#6C814B']), - slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.075 + palette: palette(['#7F9F4E', '#88A957', '#90B162', '#739348']), + slopeSoftLimit: 0.42, slopeHardLimit: 0.78, minimumSeparation: 0.064 }), forest: Object.freeze({ - kind: 'forest', highCandidateCount: 24, completelyBareThreshold: 0.33, retention: 0.82, + kind: 'forest', highCandidateCount: 30, completelyBareThreshold: 0.29, retention: 0.86, height: Object.freeze([0.10, 0.17]), width: Object.freeze([0.30, 0.46]), - palette: palette(['#627B4E', '#6B8454', '#748C5C', '#587345']), - slopeSoftLimit: 0.40, slopeHardLimit: 0.74, minimumSeparation: 0.08 + palette: palette(['#688D49', '#729950', '#7AA158', '#5E8341']), + slopeSoftLimit: 0.40, slopeHardLimit: 0.74, minimumSeparation: 0.068 }), heath: Object.freeze({ - kind: 'heath', highCandidateCount: 22, completelyBareThreshold: 0.39, retention: 0.80, + kind: 'heath', highCandidateCount: 27, completelyBareThreshold: 0.35, retention: 0.84, height: Object.freeze([0.09, 0.16]), width: Object.freeze([0.28, 0.43]), - palette: palette(['#7B8054', '#85895B', '#8A8E64']), - slopeSoftLimit: 0.34, slopeHardLimit: 0.67, minimumSeparation: 0.085 + palette: palette(['#83965A', '#8CA061', '#94A869']), + slopeSoftLimit: 0.34, slopeHardLimit: 0.67, minimumSeparation: 0.074 }), ridge: Object.freeze({ - kind: 'ridge', highCandidateCount: 6, completelyBareThreshold: 0.72, retention: 0.62, + kind: 'ridge', highCandidateCount: 7, completelyBareThreshold: 0.72, retention: 0.62, height: Object.freeze([0.08, 0.13]), width: Object.freeze([0.24, 0.34]), palette: palette(['#85815A', '#777A50']), slopeSoftLimit: 0.22, slopeHardLimit: 0.44, minimumSeparation: 0.10 }), 'ancient-stone': Object.freeze({ - kind: 'ancient-stone', highCandidateCount: 4, completelyBareThreshold: 0.86, retention: 0.54, + kind: 'ancient-stone', highCandidateCount: 5, completelyBareThreshold: 0.86, retention: 0.54, height: Object.freeze([0.07, 0.11]), width: Object.freeze([0.22, 0.30]), palette: palette(['#7A7D60', '#6E7458']), slopeSoftLimit: 0.18, slopeHardLimit: 0.34, minimumSeparation: 0.12 @@ -92,15 +92,15 @@ export const REALM_GRASS_BIOME_PROFILES: Readonly< slopeSoftLimit: 0, slopeHardLimit: 0, minimumSeparation: 0 }), apron: Object.freeze({ - kind: 'apron', highCandidateCount: 6, completelyBareThreshold: 0.52, retention: 0.56, + kind: 'apron', highCandidateCount: 8, completelyBareThreshold: 0.52, retention: 0.56, height: Object.freeze([0.08, 0.13]), width: Object.freeze([0.24, 0.36]), - palette: palette(['#6D8450', '#788E58']), + palette: palette(['#739448', '#7E9F50']), slopeSoftLimit: 0.30, slopeHardLimit: 0.58, minimumSeparation: 0.11 }) }); export const REALM_GRASS_QUALITY_MULTIPLIERS: Readonly> = - Object.freeze({ high: 1, balanced: 0.62, reduced: 0.25 }); + Object.freeze({ high: 1, balanced: 0.72, reduced: 0.25 }); export type RealmGrassExclusion = Readonly<{ id: string; @@ -726,7 +726,7 @@ export function generateRealmGrassCells(input: RealmGrassGenerationInput): Realm // only a restrained local response. tint: mixColor( mixColor( - mixColor(groundTint, authoredTint, 0.86), + mixColor(groundTint, authoredTint, 0.90), { r: 0.49, g: 0.52, b: 0.39 }, smoothstep(0.10, 0.86, snowCoverage) * 0.66 ), diff --git a/src/game/map/terrainColor.ts b/src/game/map/terrainColor.ts index 9c3c9138..c95d23a9 100644 --- a/src/game/map/terrainColor.ts +++ b/src/game/map/terrainColor.ts @@ -52,11 +52,11 @@ export const REALM_TERRAIN_KIND_PALETTE: Readonly>> = Object.freeze({ - lowland: Object.freeze({ color: { r: 0.29, g: 0.42, b: 0.23 }, strength: 0.12 }), - meadow: Object.freeze({ color: { r: 0.40, g: 0.51, b: 0.26 }, strength: 0.24 }), + lowland: Object.freeze({ color: { r: 0.33, g: 0.48, b: 0.23 }, strength: 0.16 }), + meadow: Object.freeze({ color: { r: 0.37, g: 0.532, b: 0.283 }, strength: 0.28 }), // Forest depth comes from canopy/contact cues rather than near-black paint. - forest: Object.freeze({ color: { r: 0.20, g: 0.35, b: 0.22 }, strength: 0.36 }), - heath: Object.freeze({ color: { r: 0.32, g: 0.40, b: 0.26 }, strength: 0.22 }), + forest: Object.freeze({ color: { r: 0.23, g: 0.40, b: 0.21 }, strength: 0.36 }), + heath: Object.freeze({ color: { r: 0.35, g: 0.46, b: 0.25 }, strength: 0.24 }), ridge: Object.freeze({ color: { r: 0.38, g: 0.38, b: 0.34 }, strength: 0.46 }), lake: Object.freeze({ color: { r: 0.18, g: 0.31, b: 0.36 }, strength: 0.62 }), 'ancient-stone': Object.freeze({ color: { r: 0.39, g: 0.40, b: 0.37 }, strength: 0.45 }) @@ -180,8 +180,8 @@ export function sampleLowlandsColor( if (vegetationDensity > 0) { color = mixColor( color, - { r: 0.32, g: 0.46, b: 0.24 }, - vegetationDensity * (context.semanticColor ? 1 : cellInfluence) * 0.08 + { r: 0.36, g: 0.56, b: 0.22 }, + vegetationDensity * (context.semanticColor ? 1 : cellInfluence) * 0.12 ); } @@ -191,8 +191,8 @@ export function sampleLowlandsColor( // modified by a canopy tint. if (forestCanopy > 0) { const underCanopy = visualTerrainKind === 'forest' - ? { r: 0.25, g: 0.51, b: 0.24 } - : { r: 0.36, g: 0.57, b: 0.25 }; + ? { r: 0.27, g: 0.55, b: 0.22 } + : { r: 0.40, g: 0.63, b: 0.24 }; color = mixColor( color, underCanopy, @@ -236,5 +236,5 @@ export function sampleLowlandsColor( context.playableRadius, context.renderRadius ); - return mixColor(color, { r: 0.47, g: 0.51, b: 0.38 }, apronBlend * 0.32); + return mixColor(color, { r: 0.49, g: 0.55, b: 0.37 }, apronBlend * 0.32); } diff --git a/tests/realmAmbientEcologyLayer.test.ts b/tests/realmAmbientEcologyLayer.test.ts new file mode 100644 index 00000000..3a83cbc6 --- /dev/null +++ b/tests/realmAmbientEcologyLayer.test.ts @@ -0,0 +1,84 @@ +import * as THREE from 'three'; +import { describe, expect, it, vi } from 'vitest'; + +import { createRealmAmbientEcologyLayer } from '../src/components/realm/createRealmAmbientEcologyLayer'; +import { REALM_LIVING_REALM_BUDGETS } from '../src/components/realm/realmQuality'; + +describe('Living Realm ambient ecology layer', () => { + it('uses exactly two non-pickable bounded draws near the camera', () => { + const layer = createRealmAmbientEcologyLayer({ + budget: REALM_LIVING_REALM_BUDGETS.high + }); + const centers = new Float32Array([1, 2, 0, 0, 0, 0, 0, 0]); + const params = new Float32Array([0.8, 0.7, 0.3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + + expect(layer.update(1, { x: 3, y: 0.2, z: -2 }, 'approach', { + count: 1, + centers, + params + })).toBe(true); + expect(layer.getTelemetry()).toMatchObject({ + enabled: true, + animated: true, + birdCount: 12, + moteCount: 36, + transientParticleCount: 12, + transientParticleCapacity: 96, + drawCalls: 2, + triangleCount: 24, + plannerHz: 10, + plannerTickCount: 1 + }); + expect(layer.getTelemetry().drawCalls) + .toBeLessThanOrEqual(REALM_LIVING_REALM_BUDGETS.high.addedDrawCalls); + expect(layer.getTelemetry().triangleCount) + .toBeLessThanOrEqual(REALM_LIVING_REALM_BUDGETS.high.addedTriangles); + expect(layer.group.children.every((child) => child.raycast !== undefined)).toBe(true); + const birds = layer.group.getObjectByName('realm-living-birds'); + const dispose = vi.spyOn(birds as THREE.InstancedMesh, 'dispose'); + layer.dispose(); + layer.dispose(); + expect(dispose).toHaveBeenCalledOnce(); + }); + + it('uses a frozen visual clock for deterministic rendered QA', () => { + const first = createRealmAmbientEcologyLayer({ + budget: REALM_LIVING_REALM_BUDGETS.balanced, + frozenVisualTimeSeconds: 8.25 + }); + const second = createRealmAmbientEcologyLayer({ + budget: REALM_LIVING_REALM_BUDGETS.balanced, + frozenVisualTimeSeconds: 8.25 + }); + first.update(1, { x: 0, y: 0, z: 0 }, 'keep'); + second.update(99, { x: 0, y: 0, z: 0 }, 'keep'); + const firstBirds = first.group.getObjectByName('realm-living-birds'); + const secondBirds = second.group.getObjectByName('realm-living-birds'); + expect((firstBirds as unknown as { instanceMatrix: { array: ArrayLike } }).instanceMatrix.array) + .toEqual((secondBirds as unknown as { instanceMatrix: { array: ArrayLike } }).instanceMatrix.array); + first.dispose(); + second.dispose(); + }); + + it('hides in overview and allocates no draws for Reduced', () => { + const balanced = createRealmAmbientEcologyLayer({ + budget: REALM_LIVING_REALM_BUDGETS.balanced + }); + balanced.update(1, { x: 0, z: 0 }, 'realm'); + expect(balanced.group.visible).toBe(false); + expect(balanced.getTelemetry()).toMatchObject({ + animated: false, + overviewHidden: true, + drawCalls: 0 + }); + balanced.dispose(); + + const reduced = createRealmAmbientEcologyLayer({ + budget: REALM_LIVING_REALM_BUDGETS.reduced + }); + expect(reduced.group.children).toHaveLength(0); + expect(reduced.update(1, { x: 0, z: 0 }, 'approach')).toBe(false); + expect(reduced.getTelemetry()).toMatchObject({ enabled: false, drawCalls: 0 }); + reduced.dispose(); + }); +}); diff --git a/tests/realmForestLayer.test.ts b/tests/realmForestLayer.test.ts index d663a83a..d4ce27e8 100644 --- a/tests/realmForestLayer.test.ts +++ b/tests/realmForestLayer.test.ts @@ -282,7 +282,7 @@ describe('static forest presentation layer', () => { fallbackType: 'procedural-trunk-multi-canopy-v1', contactShadowCount: 0, groundingMode: 'terrain-canopy-procedural-root-contact', - canopyMotionState: 'static', + canopyMotionState: 'shared-gust', structureCellCounts: { core: 1, body: 0, @@ -316,8 +316,13 @@ describe('static forest presentation layer', () => { return fakeLease(asset, release); }); const layer = createLayer(points, acquirePrefab, onModelReady); + const fallbackMesh = layer.group.getObjectByName( + 'realm-hegemony-tree-static-fallback' + ) as THREE.InstancedMesh; + const fallbackDispose = vi.spyOn(fallbackMesh, 'dispose'); await vi.waitFor(() => expect(layer.getPresentationTelemetry().usingFallback).toBe(false)); + expect(fallbackDispose).toHaveBeenCalledOnce(); expect(maximumActiveLoads).toBeLessThanOrEqual(HEGEMONY_TREE_PREFAB_LOAD_CONCURRENCY); expect(acquirePrefab).toHaveBeenCalledTimes(assets.length); expect(release).toHaveBeenCalledTimes(assets.length); @@ -328,15 +333,62 @@ describe('static forest presentation layer', () => { fallbackType: 'none', contactShadowCount: 0, groundingMode: 'terrain-canopy-baked-base', - canopyMotionState: 'static', + canopyMotionState: 'shared-gust', triangleCount: assets.length * 12 }); expect(layer.group.getObjectByName('realm-hegemony-tree-static-fallback')).toBeUndefined(); - expect(layer.group.getObjectByName('realm-hegemony-tree-static-batch')).toBeTruthy(); + const authoredBatch = layer.group.getObjectByName( + 'realm-hegemony-tree-static-batch' + ) as THREE.Mesh; + expect(authoredBatch).toBeTruthy(); + expect(authoredBatch.geometry.getAttribute('realmForestWindWeight').array) + .toBeInstanceOf(Uint8Array); + expect(authoredBatch.geometry.getAttribute('realmForestWindWeight').normalized) + .toBe(true); + expect(authoredBatch.geometry.getAttribute('realmForestWindPhase').array) + .toBeInstanceOf(Uint8Array); + expect(layer.getPresentationTelemetry().windAttributeBytes) + .toBe(authoredBatch.geometry.getAttribute('position').count * 2); + expect(layer.isAnimationActive()).toBe(true); + expect(layer.updateWind(1)).toBe(true); + expect(layer.updateWind(1)).toBe(false); expect(onModelReady).toHaveBeenCalledOnce(); layer.dispose(); }); + it('keeps forest materials static under reduced motion', async () => { + const asset = HEGEMONY_TREE_RUNTIME_ASSETS[0]!; + const layer = createRealmForestLayer({ + data: biomeData([pointForAsset(asset)]), + map: surface.renderMap, + terrainPlacements: [], + quality: REALM_QUALITY_SPECS.high, + baseUrl: '/', + reducedMotion: true, + acquirePrefab: async () => fakeLease(asset) + }); + + expect(layer.isAnimationActive()).toBe(false); + expect(layer.updateWind(1)).toBe(false); + expect(layer.getPresentationTelemetry().canopyMotionState).toBe('static'); + const fallback = layer.group.getObjectByName( + 'realm-hegemony-tree-static-fallback' + ) as THREE.InstancedMesh; + expect(fallback.geometry.getAttribute('realmForestWindWeight')).toBeUndefined(); + expect(layer.getPresentationTelemetry().windAttributeBytes).toBe(0); + await vi.waitFor(() => expect(layer.getPresentationTelemetry().usingFallback).toBe(false)); + expect(layer.isAnimationActive()).toBe(false); + expect(layer.getPresentationTelemetry().canopyMotionState).toBe('static'); + const authoredBatch = layer.group.getObjectByName( + 'realm-hegemony-tree-static-batch' + ) as THREE.Mesh; + expect(authoredBatch.geometry.getAttribute('realmForestWindWeight')).toBeUndefined(); + expect(authoredBatch.geometry.getAttribute('realmForestWindPhase')).toBeUndefined(); + expect(layer.getPresentationTelemetry().windAttributeBytes).toBe(0); + layer.dispose(); + expect(layer.getPresentationTelemetry().canopyMotionState).toBe('static'); + }); + it('dusts only top-facing authored vertices without changing their static topology', async () => { const asset = HEGEMONY_TREE_RUNTIME_ASSETS[0]!; const point = pointForAsset(asset); diff --git a/tests/realmForestWindMaterial.test.ts b/tests/realmForestWindMaterial.test.ts new file mode 100644 index 00000000..fe15fcdc --- /dev/null +++ b/tests/realmForestWindMaterial.test.ts @@ -0,0 +1,55 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; + +import { + applyRealmForestWindMaterial, + injectRealmForestWindVertexShader, + REALM_FOREST_WIND_SHADER_CONTRACT +} from '../src/components/realm/createRealmForestWindMaterial'; + +describe('Living Realm forest wind material', () => { + it('injects one root-weighted world gust into the existing material', () => { + const injected = injectRealmForestWindVertexShader( + 'void main() {\n#include \n}' + ); + expect(injected).toContain('attribute float realmForestWindWeight'); + expect(injected).toContain('float realmLivingGust'); + expect(injected).toContain('realmForestRootedWeight'); + expect(injected).toContain('modelMatrix * instanceMatrix'); + expect(injected).toContain('transformed.xz +='); + expect(() => injectRealmForestWindVertexShader('void main() {}')) + .toThrow('REALM_FOREST_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED'); + }); + + it('advances a uniform only and fails closed to the standard material', () => { + const material = new THREE.MeshStandardMaterial(); + const controller = applyRealmForestWindMaterial(material, true); + expect(material.customProgramCacheKey()).toBe(REALM_FOREST_WIND_SHADER_CONTRACT); + expect(controller.setTime(2)).toBe(true); + expect(controller.setTime(2)).toBe(false); + + const shader = { vertexShader: 'void main() {}', fragmentShader: '', uniforms: {} }; + expect(() => material.onBeforeCompile( + shader as Parameters[0], + {} as THREE.WebGLRenderer + )).not.toThrow(); + expect(controller.isActive()).toBe(false); + expect(controller.getTelemetry()).toMatchObject({ + enabled: true, + fallbackActive: true, + fallbackCount: 1, + fallbackReason: 'REALM_FOREST_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED' + }); + expect(material).toBeInstanceOf(THREE.MeshStandardMaterial); + material.dispose(); + }); + + it('does not install moving presentation when disabled', () => { + const material = new THREE.MeshStandardMaterial(); + const controller = applyRealmForestWindMaterial(material, false); + expect(controller.isActive()).toBe(false); + expect(controller.setTime(1)).toBe(false); + expect(material.userData.realmForestWindEnabled).toBe(false); + material.dispose(); + }); +}); diff --git a/tests/realmGrassActiveWindow.test.ts b/tests/realmGrassActiveWindow.test.ts index 98803155..3bcd23e7 100644 --- a/tests/realmGrassActiveWindow.test.ts +++ b/tests/realmGrassActiveWindow.test.ts @@ -24,13 +24,13 @@ describe('procedural grass active window', () => { high: { activeRadius: 12, maximumActiveInstances: 7_000, - maximumActiveTriangles: 189_000, + maximumActiveTriangles: 252_000, animationFrameCap: 24 }, balanced: { activeRadius: 9, maximumActiveInstances: 4_000, - maximumActiveTriangles: 84_000, + maximumActiveTriangles: 108_000, animationFrameCap: 16 }, reduced: { diff --git a/tests/realmGrassGenesisBounds.test.ts b/tests/realmGrassGenesisBounds.test.ts index 38cb3752..b7a8f9ab 100644 --- a/tests/realmGrassGenesisBounds.test.ts +++ b/tests/realmGrassGenesisBounds.test.ts @@ -132,7 +132,7 @@ describe('canonical Genesis 001 grass bounds', () => { expect(first.drawCalls).toBeLessThanOrEqual(3); expect(first.variantCounts).toHaveLength(3); expect(digestPackedGrass(layer)).toBe( - '561a371101ae10c6b2a8bf28b89285254913e40e87177c341babb683aeeebec5' + '578fae82c257ce0bc9f80dee7d0901c70c6663e64748a4ef08a510a836d9b04d' ); layer.updateView(axialToWorld({ q: 30, r: -10 }, 1), 'keep'); diff --git a/tests/realmGrassGeometry.test.ts b/tests/realmGrassGeometry.test.ts index 68b96c1e..723f9709 100644 --- a/tests/realmGrassGeometry.test.ts +++ b/tests/realmGrassGeometry.test.ts @@ -69,7 +69,7 @@ describe('low-poly grass geometry', () => { REALM_GRASS_RIBBONS[profile] * REALM_GRASS_TRIANGLES_PER_RIBBON ])); - expect(trianglesByQuality).toEqual({ high: 27, balanced: 21, reduced: 15 }); + expect(trianglesByQuality).toEqual({ high: 36, balanced: 27, reduced: 15 }); }); it('uses a small deterministic family of genuinely different patch silhouettes', () => { diff --git a/tests/realmGrassLayer.test.ts b/tests/realmGrassLayer.test.ts index a30c95cc..f060ea79 100644 --- a/tests/realmGrassLayer.test.ts +++ b/tests/realmGrassLayer.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import { createRealmGrassLayer } from '../src/components/realm/createRealmGrassLayer'; import type { RealmGrassRenderPlan } from '../src/components/realm/realmGrassActiveWindow'; -import { REALM_GRASS_RENDER_PLANS } from '../src/components/realm/realmQuality'; +import { + REALM_GRASS_RENDER_PLANS, + REALM_LIVING_REALM_BUDGETS +} from '../src/components/realm/realmQuality'; import { axialToWorld, hexKey } from '../src/game/map/hexCoordinates'; import { sampleRealmGrassSurfaceFrame } from '../src/game/map/realmGrass'; import { REALM_GRASS_COLOR_BOUNDS } from '../src/game/map/realmGrassPalette'; @@ -18,7 +21,7 @@ function plan(): RealmGrassRenderPlan { hysteresisRadius: 2, cacheLimit: 8, maximumActiveInstances: 96, - maximumActiveTriangles: 2_016 + maximumActiveTriangles: 2_592 }); } @@ -34,7 +37,8 @@ describe('camera-local procedural grass layer', () => { castleSlotKeys: new Set(), placements: [], plan: plan(), - reducedMotion: false + reducedMotion: false, + livingBudget: REALM_LIVING_REALM_BUDGETS.balanced }); expect(layer.updateView({ x: 0, z: 0 }, 'realm')).toBe(true); @@ -50,7 +54,7 @@ describe('camera-local procedural grass layer', () => { expect(telemetry.overviewHidden).toBe(false); expect(telemetry.instanceCount).toBeGreaterThan(0); expect(telemetry.instanceCount).toBeLessThanOrEqual(96); - expect(telemetry.triangleCount).toBeLessThanOrEqual(2_016); + expect(telemetry.triangleCount).toBeLessThanOrEqual(2_592); expect(telemetry.drawCalls).toBeLessThanOrEqual(2); expect(telemetry.cacheEntries).toBeLessThanOrEqual(8); expect(telemetry.cacheLimit).toBe(8); @@ -124,6 +128,15 @@ describe('camera-local procedural grass layer', () => { const matrixWrites = layer.meshes.map((currentMesh) => vi.spyOn(currentMesh, 'setMatrixAt')); const matrixVersions = layer.meshes.map((currentMesh) => currentMesh.instanceMatrix.version); expect(layer.updateWind(0.5)).toBe(true); + expect(layer.updateWind(0.75, { + count: 1, + centers: new Float32Array([1, 2, 0, 0, 0, 0, 0, 0]), + params: new Float32Array([0.7, 0.8, 0.25, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + })).toBe(true); + expect(layer.getTelemetry()).toMatchObject({ + disturbanceSlotCount: 4, + activeDisturbanceCount: 1 + }); layer.setInteraction({ q: 0, r: 0 }, { q: 1, r: 0 }); matrixWrites.forEach((spy) => expect(spy).not.toHaveBeenCalled()); layer.meshes.forEach((currentMesh, index) => expect(currentMesh.instanceMatrix.version) diff --git a/tests/realmGrassMaterial.test.ts b/tests/realmGrassMaterial.test.ts index a031f6bc..708da3eb 100644 --- a/tests/realmGrassMaterial.test.ts +++ b/tests/realmGrassMaterial.test.ts @@ -44,7 +44,7 @@ function projectLocalDirectionIntoWorldXZ( describe('procedural grass material contract', () => { it('injects world-space wind only at the pinned Three.js shader hook', () => { - const source = 'void main() {\n#include \n}'; + const source = 'void main() {\n#include \n#include \n}'; const injected = injectRealmGrassVertexShader(source); expect(injected).toContain('attribute vec4 grassBladeData;'); @@ -61,9 +61,10 @@ describe('procedural grass material contract', () => { expect(injected).toContain('transformed.xz += grassLocalDirection'); expect(injected).toContain('transformed.xz += grassLocalCrossDirection'); expect(injected).toContain('dot(grassWorldPosition.xz, grassWorldDirection)'); - expect(injected).toContain('float grassGustFront = sin('); - expect(injected).toContain('float grassGustBand = smoothstep('); - expect(injected).toContain('float grassGust = mix(0.66, 1.0, grassGustBand);'); + expect(injected).toContain('float realmLivingGust'); + expect(injected).toContain('float grassNormalLean'); + expect(injected).toContain('objectNormal.xz -= grassNormalLocalDirection'); + expect(injected).toContain('float grassGust = mix(0.66, 1.0, realmLivingGust('); expect(injected).toContain('grassPhase * 0.18'); expect(injected).not.toContain('transformed.xz += grassWorldDirection'); expect(injected).toContain('float grassFlex = grassBladeData.y;'); @@ -86,8 +87,8 @@ describe('procedural grass material contract', () => { expect((layer.material as THREE.MeshStandardMaterial & { alphaHash?: boolean }).alphaHash).toBe(true); expect((layer.material as THREE.MeshStandardMaterial & { alphaToCoverage?: boolean }).alphaToCoverage).toBe(false); expect(layer.material.customProgramCacheKey()).toBe(REALM_GRASS_SHADER_CACHE_KEY); - expect(REALM_GRASS_SHADER_CACHE_KEY).toContain('procedural-grass-v2'); - expect(REALM_GRASS_SHADER_CACHE_KEY).toContain('bounded-tips'); + expect(REALM_GRASS_SHADER_CACHE_KEY).toContain('procedural-grass-v3'); + expect(REALM_GRASS_SHADER_CACHE_KEY).toContain('bent-normals'); expect(REALM_GRASS_SHADER_CACHE_KEY).toContain(REALM_GRASS_THREE_SHADER_CONTRACT); expect(layer.uniforms.uGrassWindStrength.value).toBeCloseTo(0.78); expect(layer.uniforms.uGrassWindDirection.value.toArray()).toEqual([ @@ -97,7 +98,9 @@ describe('procedural grass material contract', () => { expect(layer.getShaderTelemetry()).toEqual({ fallbackActive: false, fallbackCount: 0, - fallbackReason: null + fallbackReason: null, + disturbanceSlotCount: 0, + activeDisturbanceCount: 0 }); expect(layer.setTime(1.25)).toBe(true); expect(layer.setTime(1.25)).toBe(false); @@ -135,7 +138,9 @@ describe('procedural grass material contract', () => { expect(layer.getShaderTelemetry()).toEqual({ fallbackActive: true, fallbackCount: 1, - fallbackReason: 'REALM_GRASS_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED' + fallbackReason: 'REALM_GRASS_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED', + disturbanceSlotCount: 0, + activeDisturbanceCount: 0 }); expect(layer.material.userData.realmGrassShaderFallbackActive).toBe(true); expect(layer.material.customProgramCacheKey()).toContain('static-fallback'); @@ -151,6 +156,25 @@ describe('procedural grass material contract', () => { .toBeCloseTo(REALM_GRASS_MAX_WIND_SWAY, 12); }); + it('unrolls only the configured disturbance slots and updates uniforms in place', () => { + const layer = createRealmGrassMaterial(1, true, false, 4); + const source = 'void main() {\n#include \n#include \n}'; + const injected = injectRealmGrassVertexShader(source, 4); + const centers = new Float32Array([1, 2, 3, 4, 0, 0, 0, 0]); + const params = new Float32Array([0.7, 0.8, 0.25, 2, 1, 0.5, 0.5, 3, 0, 0, 0, 0, 0, 0, 0, 0]); + + expect(injected).toContain('uGrassDisturbanceCenters[4]'); + expect(injected).toContain('uGrassDisturbanceCount > 3'); + expect(injected).not.toContain('uGrassDisturbanceCount > 4'); + expect(layer.material.customProgramCacheKey()).toContain('disturbances-4'); + expect(layer.setDisturbances({ count: 2, centers, params })).toBe(true); + expect(layer.uniforms.uGrassDisturbanceCount.value).toBe(2); + expect(layer.uniforms.uGrassDisturbanceCenters.value[0]!.toArray()).toEqual([1, 2]); + expect(layer.uniforms.uGrassDisturbanceParams.value[1]!.toArray()).toEqual([1, 0.5, 0.5, 3]); + expect(layer.getShaderTelemetry().activeDisturbanceCount).toBe(2); + layer.dispose(); + }); + it('projects one world wind direction through yawed and scaled instance bases', () => { const worldDirection = new THREE.Vector2( REALM_PREVAILING_WIND.x, diff --git a/tests/realmGrassVisualContract.test.ts b/tests/realmGrassVisualContract.test.ts index 92e29dcf..9a697a7e 100644 --- a/tests/realmGrassVisualContract.test.ts +++ b/tests/realmGrassVisualContract.test.ts @@ -41,7 +41,7 @@ describe('natural broad grass visual contract', () => { it('stores restrained authored palettes in linear space within display-sRGB bounds', () => { const meadow = REALM_GRASS_BIOME_PROFILES.meadow.palette; expect(meadow).toHaveLength(4); - expect(meadow[0]!.r).toBeCloseTo(0.2232, 3); + expect(meadow[0]!.r).toBeCloseTo(0.2623, 3); meadow.forEach((colour) => expect(colour.g).toBeGreaterThan(colour.r)); const heath = REALM_GRASS_BIOME_PROFILES.heath; expect(heath.palette).toHaveLength(3); @@ -49,9 +49,9 @@ describe('natural broad grass visual contract', () => { expect(colour.g).toBeGreaterThan(colour.r); expect(colour.g).toBeGreaterThan(colour.b); }); - expect(heath.highCandidateCount).toBe(22); - expect(heath.completelyBareThreshold).toBe(0.39); - expect(heath.retention).toBe(0.8); + expect(heath.highCandidateCount).toBe(27); + expect(heath.completelyBareThreshold).toBe(0.35); + expect(heath.retention).toBe(0.84); expect(REALM_GRASS_BIOME_PROFILES.lake.highCandidateCount).toBe(0); expect(REALM_GRASS_BIOME_PROFILES.lake.retention).toBe(0); @@ -79,13 +79,17 @@ describe('natural broad grass visual contract', () => { '#include ' ].join('\n')); expect(fragment).toContain('realmGrassCoverage()'); - expect(fragment).toContain('diffuseColor.rgb *= mix(0.94, 1.015, grassVerticalLift);'); + expect(fragment).toContain('diffuseColor.rgb *= mix(0.96, 1.04, grassVerticalLift);'); expect(fragment).not.toContain('diffuseColor.rgb +='); + expect(fragment).toContain('vGrassSunTransmission'); expect(fragment).toContain('diffuseColor.a *= realmGrassCoverage();'); const material = createRealmGrassMaterial(1, true, false).material; expect(material.transparent).toBe(false); expect(material.depthWrite).toBe(true); expect(material.depthTest).toBe(true); + expect(material.emissive.g).toBeGreaterThan(material.emissive.r); + expect(material.emissive.g).toBeGreaterThan(material.emissive.b); + expect(material.emissiveIntensity).toBe(0.14); expect((material as typeof material & { alphaHash?: boolean }).alphaHash).toBe(true); expect((material as typeof material & { alphaToCoverage?: boolean }).alphaToCoverage).toBe(false); material.dispose(); diff --git a/tests/realmLivingEnvironment.test.ts b/tests/realmLivingEnvironment.test.ts new file mode 100644 index 00000000..58452822 --- /dev/null +++ b/tests/realmLivingEnvironment.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + REALM_LIVING_GUST_GLSL, + REALM_LIVING_WIND_GLSL, + sampleRealmLivingEnvironment +} from '../src/components/realm/realmLivingEnvironment'; +import { REALM_PREVAILING_WIND } from '../src/game/map/realmPrevailingWind'; + +describe('Living Realm environment contract', () => { + it('samples one deterministic, normalized, bounded world-space gust', () => { + const sample = { timeSeconds: -1, windX: 0, windZ: 0, gust: -1 }; + const first = { ...sampleRealmLivingEnvironment(12.5, 4, -7, sample) }; + const second = sampleRealmLivingEnvironment(12.5, 4, -7, sample); + + expect(second).toEqual(first); + expect(second.windX).toBe(REALM_PREVAILING_WIND.x); + expect(second.windZ).toBe(REALM_PREVAILING_WIND.z); + expect(Math.hypot(second.windX, second.windZ)).toBeCloseTo(1, 12); + expect(second.gust).toBeGreaterThanOrEqual(0); + expect(second.gust).toBeLessThanOrEqual(1); + }); + + it('fails malformed time and positions to a finite deterministic sample', () => { + const sample = { timeSeconds: -1, windX: 0, windZ: 0, gust: -1 }; + expect(sampleRealmLivingEnvironment(Number.NaN, Infinity, -Infinity, sample)) + .toEqual(sampleRealmLivingEnvironment(0, 0, 0, sample)); + }); + + it('exports the same fixed wind and bounded function for shader consumers', () => { + expect(REALM_LIVING_WIND_GLSL).toContain(REALM_PREVAILING_WIND.x.toFixed(9)); + expect(REALM_LIVING_GUST_GLSL).toContain('float realmLivingGust'); + expect(REALM_LIVING_GUST_GLSL).toContain('clamp('); + }); +}); diff --git a/tests/realmLivingQuality.test.ts b/tests/realmLivingQuality.test.ts new file mode 100644 index 00000000..a5bf809a --- /dev/null +++ b/tests/realmLivingQuality.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + REALM_LIVING_REALM_BUDGETS, + resolveRealmLivingRealmBudget +} from '../src/components/realm/realmQuality'; +import { REALM_RABBIT_RUNTIME_ASSET } from '../src/components/realm/realmRabbitRuntimeAsset'; + +describe('Living Realm quality budgets', () => { + it('keeps High and Balanced within the hard V1 limits', () => { + expect(REALM_LIVING_REALM_BUDGETS.high).toMatchObject({ + grassDisturbanceSlots: 8, + waterRippleSlots: 4, + birdInstances: 12, + rabbitInstances: 10, + moteCount: 36, + transientParticleCount: 96, + plannerHz: 10, + addedDrawCalls: 3, + addedTriangles: 1_484 + }); + expect(REALM_LIVING_REALM_BUDGETS.balanced).toMatchObject({ + grassDisturbanceSlots: 4, + waterRippleSlots: 2, + birdInstances: 6, + rabbitInstances: 6, + moteCount: 18, + transientParticleCount: 48, + plannerHz: 7, + addedDrawCalls: 3, + addedTriangles: 888 + }); + }); + + it('collapses all optional moving ecology for Reduced or reduced motion', () => { + expect(Object.values(REALM_LIVING_REALM_BUDGETS.reduced).filter(Boolean)) + .toEqual([]); + expect(resolveRealmLivingRealmBudget('high', true)) + .toBe(REALM_LIVING_REALM_BUDGETS.reduced); + expect(resolveRealmLivingRealmBudget('balanced', false)) + .toBe(REALM_LIVING_REALM_BUDGETS.balanced); + }); + + it.each(['high', 'balanced'] as const)( + 'derives the declared %s ecology draw and triangle ceilings from concrete layers', + (quality) => { + const budget = REALM_LIVING_REALM_BUDGETS[quality]; + const birdTriangles = budget.birdInstances * 2; + const rabbitTriangles = budget.rabbitInstances + * REALM_RABBIT_RUNTIME_ASSET.triangles; + expect(budget.addedDrawCalls).toBe( + Number(budget.birdInstances > 0) + + Number(budget.moteCount + budget.transientParticleCount > 0) + + Number(budget.rabbitInstances > 0) + ); + expect(budget.addedTriangles).toBe(birdTriangles + rabbitTriangles); + } + ); +}); diff --git a/tests/realmProceduralForestFallback.test.ts b/tests/realmProceduralForestFallback.test.ts index bb3ee1c1..1aa82283 100644 --- a/tests/realmProceduralForestFallback.test.ts +++ b/tests/realmProceduralForestFallback.test.ts @@ -26,11 +26,14 @@ import type { RealmSouthernDesertField } from '../src/game/map/realmSouthernDese describe('local procedural forest fallback', () => { it('builds a grounded trunk and asymmetric multi-canopy silhouette', () => { const fallback = createRealmProceduralForestFallbackGeometry( - HEGEMONY_TREE_TARGET_VISUAL_HEIGHT + HEGEMONY_TREE_TARGET_VISUAL_HEIGHT, + true ); const position = fallback.geometry.getAttribute('position'); const color = fallback.geometry.getAttribute('color'); const normal = fallback.geometry.getAttribute('normal'); + const windWeight = fallback.geometry.getAttribute('realmForestWindWeight'); + const windPhase = fallback.geometry.getAttribute('realmForestWindPhase'); const index = fallback.geometry.getIndex(); const bounds = fallback.geometry.boundingBox!; @@ -41,6 +44,14 @@ describe('local procedural forest fallback', () => { expect(position.count).toBeGreaterThan(40); expect(color.count).toBe(position.count); expect(normal.count).toBe(position.count); + expect(windWeight.count).toBe(position.count); + expect(windPhase.count).toBe(position.count); + expect(windWeight.array).toBeInstanceOf(Uint8Array); + expect(windPhase.array).toBeInstanceOf(Uint8Array); + expect(windWeight.normalized).toBe(true); + expect(windPhase.normalized).toBe(true); + expect(Array.from(windWeight.array).some((value) => value === 0)).toBe(true); + expect(Array.from(windWeight.array).some((value) => value > 0)).toBe(true); expect(index?.array).toBeInstanceOf(Uint16Array); expect(bounds.min.y).toBeCloseTo(0, 6); expect(bounds.max.y).toBeCloseTo(HEGEMONY_TREE_TARGET_VISUAL_HEIGHT, 5); @@ -53,6 +64,8 @@ describe('local procedural forest fallback', () => { const fallback = createRealmProceduralForestFallbackGeometry( HEGEMONY_TREE_TARGET_VISUAL_HEIGHT ); + expect(fallback.geometry.getAttribute('realmForestWindWeight')).toBeUndefined(); + expect(fallback.geometry.getAttribute('realmForestWindPhase')).toBeUndefined(); Object.values(REALM_DECORATIVE_FOREST_RENDER_BUDGETS).forEach((budget) => { expect(fallback.triangleCount * budget.instances) .toBeLessThanOrEqual(budget.triangles); @@ -66,11 +79,17 @@ describe('local procedural forest fallback', () => { realmForestFallbackInstanceColor('forest'), realmForestFallbackInstanceColor('fringe') ]).size).toBe(3); - expect(new Set([ + const modelTints = [ realmForestModelInstanceTint('grove'), realmForestModelInstanceTint('forest'), realmForestModelInstanceTint('fringe') - ]).size).toBe(3); + ]; + expect(new Set(modelTints).size).toBe(3); + modelTints.forEach((tint) => { + const color = new THREE.Color(tint); + expect(color.g).toBeGreaterThan(color.r); + expect(color.g).toBeGreaterThan(color.b); + }); const material = createRealmProceduralForestFallbackMaterial(); expect(material.vertexColors).toBe(true); expect(material.roughness).toBeGreaterThanOrEqual(0.9); diff --git a/tests/realmQuality.test.ts b/tests/realmQuality.test.ts index 18bbfc68..ce7ec775 100644 --- a/tests/realmQuality.test.ts +++ b/tests/realmQuality.test.ts @@ -205,8 +205,8 @@ describe('realm quality profiles', () => { budget.terrainTriangles ))).toEqual([204_000, 140_000, 94_000]); expect(REALM_GRASS_RENDER_PLANS).toMatchObject({ - high: { maximumActiveInstances: 7_000, maximumActiveTriangles: 189_000, animationFrameCap: 24 }, - balanced: { maximumActiveInstances: 4_000, maximumActiveTriangles: 84_000, animationFrameCap: 16 }, + high: { maximumActiveInstances: 7_000, maximumActiveTriangles: 252_000, animationFrameCap: 24 }, + balanced: { maximumActiveInstances: 4_000, maximumActiveTriangles: 108_000, animationFrameCap: 16 }, reduced: { maximumActiveInstances: 1_200, maximumActiveTriangles: 18_000, animationFrameCap: 0 } }); }); diff --git a/tests/realmRabbitLayer.test.ts b/tests/realmRabbitLayer.test.ts new file mode 100644 index 00000000..ae7148bb --- /dev/null +++ b/tests/realmRabbitLayer.test.ts @@ -0,0 +1,291 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import * as THREE from 'three'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const parserState = vi.hoisted(() => ({ calls: 0 })); + +vi.mock('three/addons/loaders/GLTFLoader.js', async () => { + const THREE = await vi.importActual('three'); + return { + GLTFLoader: class { + async parseAsync() { + parserState.calls += 1; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(384 * 3), 3) + ); + geometry.setIndex(new THREE.Uint16BufferAttribute(new Uint16Array(438), 1)); + const scene = new THREE.Group(); + scene.add(new THREE.Mesh(geometry, new THREE.MeshStandardMaterial())); + return { scene }; + } + } + }; +}); + +import { createRealmRabbitLayer } from '../src/components/realm/createRealmRabbitLayer'; +import { assertEmbeddedRealmRabbitRuntime } from '../src/components/realm/loadRealmRabbitAsset'; +import { REALM_RABBIT_RUNTIME_ASSET } from '../src/components/realm/realmRabbitRuntimeAsset'; + +const ROOT = resolve(import.meta.dirname, '..'); +const SOURCE = readFileSync(resolve(ROOT, 'public', REALM_RABBIT_RUNTIME_ASSET.path)); +const SOURCE_BYTES = SOURCE.buffer.slice( + SOURCE.byteOffset, + SOURCE.byteOffset + SOURCE.byteLength +) as ArrayBuffer; + +function exactResponse(bytes = SOURCE_BYTES) { + return new Response(bytes.slice(0), { + status: 200, + headers: { 'content-length': String(bytes.byteLength) } + }); +} + +beforeEach(() => { + parserState.calls = 0; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('Living Realm compact Rabbit layer', () => { + it('loads the exact digest-pinned model into one bounded, non-pickable draw', async () => { + const fetchMock = vi.fn(async ( + _input: RequestInfo | URL, + _init?: RequestInit + ) => exactResponse()); + vi.stubGlobal('fetch', fetchMock); + const layer = createRealmRabbitLayer({ + instanceCount: 10, + baseUrl: '/', + heightAtWorld: () => 0.18, + isHabitat: () => true, + frozenVisualTimeSeconds: 4.5 + }); + + expect(layer.update(1, { x: 2, z: -3 }, 'approach')).toBe(true); + await vi.waitFor(() => expect(layer.getTelemetry().assetReady).toBe(true)); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0]?.[0]).toBe('/' + REALM_RABBIT_RUNTIME_ASSET.path); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + credentials: 'same-origin', + redirect: 'error' + }); + expect(parserState.calls).toBe(1); + expect(layer.group.visible).toBe(true); + expect(layer.getTelemetry()).toMatchObject({ + enabled: true, + assetReady: true, + overviewHidden: false, + instanceCapacity: 10, + instanceCount: 10, + drawCalls: 1, + triangleCount: 1_460, + loadFallbackCount: 0 + }); + const instances = layer.group.children[0]; + expect(instances?.name).toBe('realm-lowlands-rabbit-compact-instances'); + expect(instances?.raycast?.({} as never, [] as never)).toBeUndefined(); + const dispose = vi.spyOn(instances as THREE.InstancedMesh, 'dispose'); + const frozenMatrices = Array.from( + (instances as unknown as { instanceMatrix: { array: ArrayLike } }) + .instanceMatrix.array + ); + expect(layer.update(99, { x: 2, z: -3 }, 'approach')).toBe(false); + expect(Array.from( + (instances as unknown as { instanceMatrix: { array: ArrayLike } }) + .instanceMatrix.array + )).toEqual(frozenMatrices); + + layer.update(2, { x: 2, z: -3 }, 'realm'); + expect(layer.group.visible).toBe(false); + expect(layer.getTelemetry()).toMatchObject({ + overviewHidden: true, + instanceCount: 0, + drawCalls: 0, + triangleCount: 0 + }); + layer.dispose(); + layer.dispose(); + expect(dispose).toHaveBeenCalledOnce(); + expect(layer.getTelemetry()).toMatchObject({ enabled: false, instanceCapacity: 0 }); + }); + + it('allocates no model request or draw for Reduced and reduced motion', () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const layer = createRealmRabbitLayer({ + instanceCount: 0, + baseUrl: '/', + heightAtWorld: () => 0 + }); + + expect(layer.update(1, { x: 0, z: 0 }, 'approach')).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect(layer.group.children).toHaveLength(0); + expect(layer.getTelemetry()).toMatchObject({ + enabled: false, + assetReady: false, + instanceCapacity: 0, + instanceCount: 0, + drawCalls: 0, + triangleCount: 0 + }); + layer.dispose(); + }); + + it('fails closed when the supplied model bytes do not match', async () => { + const corrupt = SOURCE_BYTES.slice(0); + new Uint8Array(corrupt)[corrupt.byteLength - 1] ^= 0xff; + vi.stubGlobal('fetch', vi.fn(async () => exactResponse(corrupt))); + const ready = vi.fn(); + const layer = createRealmRabbitLayer({ + instanceCount: 6, + baseUrl: '/', + heightAtWorld: () => 0, + onModelReady: ready + }); + + layer.update(1, { x: 0, z: 0 }, 'keep'); + await vi.waitFor(() => expect(layer.getTelemetry().loadFallbackCount).toBe(1)); + expect(parserState.calls).toBe(0); + expect(ready).toHaveBeenCalledOnce(); + expect(layer.group.visible).toBe(false); + expect(layer.getTelemetry()).toMatchObject({ + assetReady: false, + instanceCount: 0, + drawCalls: 0, + triangleCount: 0 + }); + layer.dispose(); + }); + + it('times out and actively retires a stalled transport', async () => { + let transportSignal: AbortSignal | undefined; + vi.stubGlobal('fetch', vi.fn(( + _input: RequestInfo | URL, + init?: RequestInit + ) => { + transportSignal = init?.signal ?? undefined; + return new Promise(() => {}); + })); + const ready = vi.fn(); + const layer = createRealmRabbitLayer({ + instanceCount: 6, + baseUrl: '/', + heightAtWorld: () => 0, + requestTimeoutMs: 5, + onModelReady: ready + }); + + await vi.waitFor(() => expect(layer.getTelemetry().loadFallbackCount).toBe(1)); + expect(transportSignal?.aborted).toBe(true); + expect(ready).toHaveBeenCalledOnce(); + expect(layer.getTelemetry().assetReady).toBe(false); + layer.dispose(); + }); + + it('times out when an exact-length response body never closes', async () => { + let transportSignal: AbortSignal | undefined; + vi.stubGlobal('fetch', vi.fn(( + _input: RequestInfo | URL, + init?: RequestInit + ) => { + transportSignal = init?.signal ?? undefined; + return Promise.resolve(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0x67, 0x6c, 0x54, 0x46])); + } + }), { + status: 200, + headers: { 'content-length': String(REALM_RABBIT_RUNTIME_ASSET.bytes) } + })); + })); + const layer = createRealmRabbitLayer({ + instanceCount: 6, + baseUrl: '/', + heightAtWorld: () => 0, + requestTimeoutMs: 5 + }); + + await vi.waitFor(() => expect(layer.getTelemetry().loadFallbackCount).toBe(1)); + expect(transportSignal?.aborted).toBe(true); + expect(parserState.calls).toBe(0); + layer.dispose(); + }); + + it('aborts a pending transport on disposal without recording a false fallback', async () => { + let transportSignal: AbortSignal | undefined; + const fetchMock = vi.fn(( + _input: RequestInfo | URL, + init?: RequestInit + ) => { + transportSignal = init?.signal ?? undefined; + return new Promise(() => {}); + }); + vi.stubGlobal('fetch', fetchMock); + const ready = vi.fn(); + const layer = createRealmRabbitLayer({ + instanceCount: 6, + baseUrl: '/', + heightAtWorld: () => 0, + onModelReady: ready + }); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + layer.dispose(); + await Promise.resolve(); + expect(transportSignal?.aborted).toBe(true); + expect(layer.getTelemetry().loadFallbackCount).toBe(0); + expect(ready).not.toHaveBeenCalled(); + }); + + it('rolls back every adopted resource when initial habitat resolution fails', async () => { + vi.stubGlobal('fetch', vi.fn(async () => exactResponse())); + const ready = vi.fn(); + const layer = createRealmRabbitLayer({ + instanceCount: 6, + baseUrl: '/', + heightAtWorld: () => { + throw new Error('synthetic habitat failure'); + }, + isHabitat: () => true, + onModelReady: ready + }); + + layer.update(1, { x: 0, z: 0 }, 'keep'); + await vi.waitFor(() => expect(layer.getTelemetry().loadFallbackCount).toBe(1)); + expect(parserState.calls).toBe(1); + expect(layer.group.children).toHaveLength(0); + expect(layer.group.visible).toBe(false); + expect(layer.getTelemetry()).toMatchObject({ + assetReady: false, + instanceCount: 0, + drawCalls: 0 + }); + expect(ready).toHaveBeenCalledOnce(); + layer.dispose(); + }); + + it('rejects dependent GLB URLs before invoking the parser', () => { + const dependent = SOURCE_BYTES.slice(0); + const view = new DataView(dependent); + const jsonLength = view.getUint32(12, true); + const jsonBytes = new Uint8Array(dependent, 20, jsonLength); + const json = JSON.stringify({ + buffers: [{ byteLength: REALM_RABBIT_RUNTIME_ASSET.embeddedBufferBytes, uri: 'rabbit.bin' }] + }); + jsonBytes.fill(0x20); + jsonBytes.set(new TextEncoder().encode(json)); + + expect(() => assertEmbeddedRealmRabbitRuntime(dependent)) + .toThrow('must remain self-contained'); + expect(parserState.calls).toBe(0); + }); +}); diff --git a/tests/realmSceneCleanup.test.ts b/tests/realmSceneCleanup.test.ts index c2712fa9..13fa1bc4 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -167,6 +167,9 @@ import { createRealmTerrainSurface } from '../src/game/map/realmTerrainSurface'; import { DEFAULT_REALM_CAMERA_SPEC } from '../src/components/realm/realmCameraController'; +import { + DEFAULT_REALM_RABBIT_REQUEST_TIMEOUT_MS +} from '../src/components/realm/loadRealmRabbitAsset'; import { REALM_QUALITY_SPECS } from '../src/components/realm/realmQuality'; import { CANONICAL_GENESIS_FOREST_INSTANCES_V1, @@ -461,7 +464,9 @@ describe('realm scene setup cleanup', () => { quality: REALM_QUALITY_SPECS.balanced, reducedMotion: false })); - expect(setTimeoutSpy).not.toHaveBeenCalled(); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy.mock.calls[0]?.[1]) + .toBe(DEFAULT_REALM_RABBIT_REQUEST_TIMEOUT_MS); animated.dispose(); }); diff --git a/tests/realmSurfaceDisturbanceField.test.ts b/tests/realmSurfaceDisturbanceField.test.ts new file mode 100644 index 00000000..2280d74b --- /dev/null +++ b/tests/realmSurfaceDisturbanceField.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { createRealmSurfaceDisturbanceField } from '../src/components/realm/realmSurfaceDisturbanceField'; + +describe('Living Realm surface disturbance field', () => { + it('keeps fixed storage, newest-first slots, decay, and aggregate-only telemetry', () => { + const field = createRealmSurfaceDisturbanceField(3); + field.push({ kind: 'grass', x: 1, z: 2, radius: 0.7, strength: 1, createdAtSeconds: 1, lifetimeSeconds: 2 }); + field.push({ kind: 'water', x: 5, z: 6, radius: 1.1, strength: 0.8, createdAtSeconds: 1.2, lifetimeSeconds: 3 }); + field.push({ kind: 'grass', x: 3, z: 4, radius: 0.5, strength: 0.6, createdAtSeconds: 1.4, lifetimeSeconds: 2 }); + + const first = field.snapshot('grass', 1.5, 8); + expect(first.count).toBe(2); + expect(Array.from(first.centers.slice(0, 4))).toEqual([3, 4, 1, 2]); + expect(first.params[1]).toBeLessThan(0.6); + expect(first.params[1]).toBeGreaterThan(0); + expect(field.snapshot('grass', 1.6, 8).centers).toBe(first.centers); + expect(field.getTelemetry(1.6)).toEqual({ + capacity: 3, + activeGrassCount: 2, + activeWaterCount: 1, + insertedCount: 3, + evictedCount: 0, + droppedCount: 0 + }); + expect(Object.keys(field.getTelemetry(1.6))).not.toContain('positions'); + }); + + it('evicts deterministically and expires without growing the pool', () => { + const field = createRealmSurfaceDisturbanceField(2); + for (let index = 0; index < 3; index += 1) { + field.push({ + kind: 'grass', + x: index, + z: 0, + radius: 1, + strength: 1, + createdAtSeconds: index, + lifetimeSeconds: 4 + }); + } + expect(field.getTelemetry(2)).toMatchObject({ + capacity: 2, + activeGrassCount: 2, + insertedCount: 3, + evictedCount: 1, + droppedCount: 0 + }); + expect(Array.from(field.snapshot('grass', 2, 8).centers.slice(0, 4))) + .toEqual([2, 0, 1, 0]); + expect(field.snapshot('grass', 8, 8).count).toBe(0); + }); + + it('allocates no active storage for a zero budget and clears on disposal', () => { + const field = createRealmSurfaceDisturbanceField(0); + expect(field.push({ kind: 'water', x: 1, z: 2, radius: 1, strength: 1, createdAtSeconds: 0, lifetimeSeconds: 1 })).toBe(false); + expect(field.snapshot('water', 0, 4).count).toBe(0); + field.dispose(); + expect(field.getTelemetry(0).capacity).toBe(0); + }); + + it('enforces independent per-material capacities', () => { + const field = createRealmSurfaceDisturbanceField({ + grassCapacity: 2, + waterCapacity: 1 + }); + for (let index = 0; index < 4; index += 1) { + field.push({ + kind: 'grass', + x: index, + z: 0, + radius: 1, + strength: 1, + createdAtSeconds: index, + lifetimeSeconds: 8 + }); + } + for (let index = 0; index < 2; index += 1) { + field.push({ + kind: 'water', + x: index, + z: 1, + radius: 1, + strength: 1, + createdAtSeconds: index + 4, + lifetimeSeconds: 8 + }); + } + expect(field.getTelemetry(5)).toMatchObject({ + capacity: 3, + activeGrassCount: 2, + activeWaterCount: 1, + insertedCount: 6, + evictedCount: 3, + droppedCount: 0 + }); + expect(field.snapshot('grass', 5, 8).count).toBe(2); + expect(field.snapshot('water', 5, 8).count).toBe(1); + }); +}); diff --git a/tests/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index 36c43ccc..38c5eb7a 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -822,7 +822,15 @@ describe('Realm canonical water layer', () => { expect(shader.vertexShader).toContain('* warpkeepWaterWaveVisibility'); expect(shader.vertexShader).not.toContain('vViewPosition.xz'); expect(shader.fragmentShader).toContain('outgoingLight +='); - expect(ocean.material.userData.waterShaderContract).toContain('-v6'); + expect(shader.fragmentShader).toContain('float waterFresnel = pow('); + expect(shader.fragmentShader) + .toContain('dot(normalize(vNormal), normalize(vViewPosition))'); + expect(shader.fragmentShader) + .not.toContain('dot(normalize(vNormal), normalize(-vViewPosition))'); + expect(shader.fragmentShader).toContain('vec3 waterReflectionColor = mix('); + expect(shader.fragmentShader).toContain('waterReflectionStrength * bankSoftness'); + expect(ocean.material.userData.waterShaderContract) + .toContain('-v8-reflection-ripples-4'); expect(shader.uniforms).toHaveProperty('uWaterTime'); expect(layer.updateEnvironment(1)).toBe(true); expect(layer.updateEnvironment(1)).toBe(false); @@ -831,6 +839,34 @@ describe('Realm canonical water layer', () => { layer.dispose(); }); + it('injects only the quality-budgeted analytic ripple slots without new geometry or draws', () => { + const layer = createLayer('balanced'); + const ocean = layer.group.getObjectByName( + 'canonical-ocean-surface' + ) as THREE.Mesh; + const shader = compileMaterial(ocean.material); + const telemetryBefore = layer.getTelemetry(); + const centers = new Float32Array([1, 2, 3, 4]); + const params = new Float32Array([1.2, 0.8, 0.25, 2, 0.8, 0.5, 0.5, 1.5]); + + expect(ocean.material.userData.waterRippleSlots).toBe(2); + expect(shader.vertexShader).toContain('uWaterRippleCenters[2]'); + expect(shader.vertexShader).toContain('uWaterRippleCount > 1'); + expect(shader.vertexShader).not.toContain('uWaterRippleCount > 2'); + expect(shader.vertexShader).toContain('warpkeepWaterRippleGradient'); + expect(shader.vertexShader).toContain('exp(-4.0 * waterRipplePhase0'); + expect(shader.uniforms).toHaveProperty('uWaterRippleCount'); + expect(layer.updateEnvironment(2, { count: 2, centers, params })).toBe(true); + expect(ocean.material.userData.waterUniforms.uWaterRippleCount.value).toBe(2); + expect(layer.getTelemetry()).toMatchObject({ + rippleSlotCount: 2, + activeRippleCount: 2, + triangleCount: telemetryBefore.triangleCount, + drawCalls: telemetryBefore.drawCalls + }); + layer.dispose(); + }); + it.each([ ['high', 2], ['balanced', 1], @@ -846,7 +882,8 @@ describe('Realm canonical water layer', () => { expect(rivers.material.userData.waterWaveComponents).toBe(expectedWaveCount); expect(shader.vertexShader.match(/sin\(/g) ?? []).toHaveLength(expectedWaveCount); - expect(rivers.material.userData.waterShaderContract).toContain('-v6'); + expect(rivers.material.userData.waterShaderContract) + .toContain('-v8-reflection-ripples-'); layer.dispose(); } diff --git a/tests/terrainGeometry.test.ts b/tests/terrainGeometry.test.ts index 1f77405e..b115c059 100644 --- a/tests/terrainGeometry.test.ts +++ b/tests/terrainGeometry.test.ts @@ -438,9 +438,9 @@ describe('combined lowlands terrain geometry', () => { it('matches the pinned former radius-twenty-two topology at every runtime profile', () => { const map = generateRealmTerrainMap(HEGEMONY_GENESIS_001, 22); const expectations = [ - [4, 145_824, 73_453, '9442fb9be122e9bef2f17804bab8d06a36728e0dcd3278d05dd09af18de14681'], - [3, 82_026, 41_419, '47c93d1820c655215b429047dc5202328337b88526cc810969b9db31d4f77df1'], - [2, 36_456, 18_499, '5c9333cb001c7d806c694903f79eef5c96cbb3fc61a77dc65261462ddb67c23c'] + [4, 145_824, 73_453, '9447109d42d328e108304eb0c1f2bea23daa566bff855b06c514a2f7a2776c43'], + [3, 82_026, 41_419, '1da2d8f834a07c102a225cefabb8fe722b319651254e981ec60b704e2374f62a'], + [2, 36_456, 18_499, 'fda53511282e075ba04fd5c448e735bd87545e3af5ba2ec32ab1d0a74c257a79'] ] as const; expectations.forEach(([subdivisions, triangleCount, vertexCount, digest]) => {