From 29e1b96494c683531905dfa02c3b7198a3a3e7a6 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 13:20:15 +0200 Subject: [PATCH 1/7] docs: record Living Realm baseline and boundaries --- docs/README.md | 2 + docs/design/living-realm-v1.md | 85 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 docs/design/living-realm-v1.md 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..74c15527 --- /dev/null +++ b/docs/design/living-realm-v1.md @@ -0,0 +1,85 @@ +# 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 and one points draw in High or Balanced, and + adds neither 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. No TUMBLE source, bundle, +shader, artwork, preset, or binary was downloaded, inspected, copied, +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. No subsystem owns a second animation frame +loop or interval. A failed optional material or ecology layer leaves terrain, +water topology, forest placement, Workers, interaction, and the Realm intact. From 2ee935b2a4eae2cbddc07dc832b33514f8d94312 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 13:25:54 +0200 Subject: [PATCH 2/7] feat: define bounded Living Realm contracts --- .../realm/realmLivingEnvironment.ts | 63 ++++++ src/components/realm/realmQuality.ts | 62 ++++++ .../realm/realmSurfaceDisturbanceField.ts | 197 ++++++++++++++++++ tests/realmLivingEnvironment.test.ts | 35 ++++ tests/realmLivingQuality.test.ts | 38 ++++ tests/realmSurfaceDisturbanceField.test.ts | 59 ++++++ 6 files changed, 454 insertions(+) create mode 100644 src/components/realm/realmLivingEnvironment.ts create mode 100644 src/components/realm/realmSurfaceDisturbanceField.ts create mode 100644 tests/realmLivingEnvironment.test.ts create mode 100644 tests/realmLivingQuality.test.ts create mode 100644 tests/realmSurfaceDisturbanceField.test.ts 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..ac190c5b 100644 --- a/src/components/realm/realmQuality.ts +++ b/src/components/realm/realmQuality.ts @@ -4,6 +4,68 @@ 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; + moteCount: 0 | 18 | 36; + transientParticleCount: 0 | 48 | 96; + plannerHz: 0 | 7 | 10; + addedDrawCalls: 0 | 2; + addedTriangles: 0 | 480 | 960; +}>; + +/** + * 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, + moteCount: 36, + transientParticleCount: 96, + plannerHz: 10, + addedDrawCalls: 2, + addedTriangles: 960 + }), + balanced: Object.freeze({ + grassDisturbanceSlots: 4, + waterRippleSlots: 2, + forestGustEnabled: true, + birdInstances: 6, + moteCount: 18, + transientParticleCount: 48, + plannerHz: 7, + addedDrawCalls: 2, + addedTriangles: 480 + }), + reduced: Object.freeze({ + grassDisturbanceSlots: 0, + waterRippleSlots: 0, + forestGustEnabled: false, + birdInstances: 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; diff --git a/src/components/realm/realmSurfaceDisturbanceField.ts b/src/components/realm/realmSurfaceDisturbanceField.ts new file mode 100644 index 00000000..2e308dc5 --- /dev/null +++ b/src/components/realm/realmSurfaceDisturbanceField.ts @@ -0,0 +1,197 @@ +export type RealmSurfaceDisturbanceKind = 'grass' | 'water'; + +export type RealmSurfaceDisturbanceInput = Readonly<{ + kind: RealmSurfaceDisturbanceKind; + x: number; + z: number; + radius: number; + strength: number; + createdAtSeconds: number; + lifetimeSeconds: number; +}>; + +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; + 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 +): RealmSurfaceDisturbanceField { + const capacity = Math.min( + MAX_FIELD_CAPACITY, + Math.max(0, Math.trunc(finite(requestedCapacity))) + ); + 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 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); + let target = -1; + let oldestTime = Number.POSITIVE_INFINITY; + 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 (occupied[target] === 1) droppedCount += 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, + 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/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..1c85e0ca --- /dev/null +++ b/tests/realmLivingQuality.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { + REALM_LIVING_REALM_BUDGETS, + resolveRealmLivingRealmBudget +} from '../src/components/realm/realmQuality'; + +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, + moteCount: 36, + transientParticleCount: 96, + plannerHz: 10, + addedDrawCalls: 2 + }); + expect(REALM_LIVING_REALM_BUDGETS.balanced).toMatchObject({ + grassDisturbanceSlots: 4, + waterRippleSlots: 2, + birdInstances: 6, + moteCount: 18, + transientParticleCount: 48, + plannerHz: 7, + addedDrawCalls: 2 + }); + }); + + 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); + }); +}); diff --git a/tests/realmSurfaceDisturbanceField.test.ts b/tests/realmSurfaceDisturbanceField.test.ts new file mode 100644 index 00000000..d039b316 --- /dev/null +++ b/tests/realmSurfaceDisturbanceField.test.ts @@ -0,0 +1,59 @@ +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, + 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, + droppedCount: 1 + }); + 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); + }); +}); From 63bb01e5adda2a2bdfed1d7e263659545e76fe07 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 13:35:35 +0200 Subject: [PATCH 3/7] feat: add coherent grass water and forest response --- .../realm/createRealmForestWindMaterial.ts | 118 +++++++++++++ src/components/realm/createRealmGrassLayer.ts | 27 ++- .../realm/createRealmGrassMaterial.ts | 161 +++++++++++++++-- .../createRealmProceduralForestFallback.ts | 23 +++ src/components/realm/createRealmScene.ts | 8 +- src/components/realm/realmForestLayer.ts | 81 +++++++-- src/components/realm/realmWaterLayer.ts | 163 ++++++++++++++++-- tests/realmForestLayer.test.ts | 41 ++++- tests/realmForestWindMaterial.test.ts | 55 ++++++ tests/realmGrassMaterial.test.ts | 40 ++++- tests/realmGrassVisualContract.test.ts | 1 + tests/realmProceduralForestFallback.test.ts | 10 ++ tests/realmWaterLayer.test.ts | 32 +++- 13 files changed, 697 insertions(+), 63 deletions(-) create mode 100644 src/components/realm/createRealmForestWindMaterial.ts create mode 100644 tests/realmForestWindMaterial.test.ts 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..587153b3 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; @@ -584,7 +595,9 @@ export function createRealmGrassLayer(options: CreateRealmGrassLayerOptions): Re 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..d8ba94f1 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) { @@ -160,6 +229,7 @@ export function injectRealmGrassFragmentShader(fragmentShader: string) { ${colorMarker} float grassVerticalLift = smoothstep(0.0, 1.0, vGrassBladeVertical); diffuseColor.rgb *= mix(0.94, 1.015, grassVerticalLift); +diffuseColor.rgb *= vec3(1.0) + vec3(0.105, 0.072, 0.026) * vGrassSunTransmission * 0.34; 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,7 +278,17 @@ 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', @@ -244,7 +337,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 +354,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 +411,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 +449,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..164ed520 100644 --- a/src/components/realm/createRealmProceduralForestFallback.ts +++ b/src/components/realm/createRealmProceduralForestFallback.ts @@ -352,6 +352,29 @@ export function createRealmProceduralForestFallbackGeometry( 'color', new THREE.Float32BufferAttribute(output.colors, 3) ); + 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(); diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 1517214f..9711d272 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -66,6 +66,7 @@ import type { RealmForestFallbackType, RealmForestGroundingMode } from './createRealmProceduralForestFallback'; +import type { REALM_FOREST_LIVING_CANOPY_MOTION_STATE } from './createRealmForestWindMaterial'; import { estimateRealmForestViewportRadiusCells } from './realmForestActiveWindow'; import { createTerrainGeometryData, @@ -704,7 +705,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; @@ -1973,6 +1974,7 @@ function initializeRealmScene( map: presentationSurface.renderMap, terrainPlacements, quality: runtimeQuality, + reducedMotion: options.reducedMotion, baseUrl: options.baseUrl, northernSnow, southernDesert, @@ -2160,7 +2162,9 @@ function initializeRealmScene( retainedInDryTransition: 0, activeSandCellCount: 0, averageSandCoverageOfActiveCells: 0, - overviewHidden: true + overviewHidden: true, + disturbanceSlotCount: 0, + activeDisturbanceCount: 0 }); const terrainPresentationTelemetry = () => { terrainTelemetryAggregationCount += 1; diff --git a/src/components/realm/realmForestLayer.ts b/src/components/realm/realmForestLayer.ts index 42262a54..9bdf8dd0 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; }; @@ -169,6 +181,17 @@ 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); + 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 +298,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; @@ -323,10 +349,19 @@ 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)); + 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 +375,9 @@ function createMergedTreeMesh( mesh, triangleCount: source.indices.length / 3, snowTintedTreeCount, - dryTintedTreeCount + dryTintedTreeCount, + wind, + windAttributeBytes: source.windWeights.length + source.windPhases.length }); } catch (error) { geometry.dispose(); @@ -354,13 +391,15 @@ 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 ); 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,7 +445,11 @@ function createFallbackForestMesh( mesh, triangleCount: fallback.triangleCount * points.length, snowTintedTreeCount, - dryTintedTreeCount + dryTintedTreeCount, + wind, + windAttributeBytes: + geometry.getAttribute('realmForestWindWeight').count + + geometry.getAttribute('realmForestWindPhase').count }); } @@ -486,14 +529,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 +553,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 +605,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 +618,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 +648,20 @@ export function createRealmForestLayer( : usingFallback ? 'terrain-canopy-procedural-root-contact' : 'terrain-canopy-baked-base', - canopyMotionState: REALM_FOREST_CANOPY_MOTION_STATE, + canopyMotionState: 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/realmWaterLayer.ts b/src/components/realm/realmWaterLayer.ts index 6dad05a1..dea6528c 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; @@ -970,6 +979,7 @@ function createWaterMaterial( quality: RealmQualitySpec, reducedMotion: boolean, river: boolean, + rippleSlotCount: number, onShaderFallback: () => void ) { const material = new THREE.MeshStandardMaterial({ @@ -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'}-v7-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; @@ -1203,11 +1278,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 +1360,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 +1408,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 +1687,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 +1745,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 +1790,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/tests/realmForestLayer.test.ts b/tests/realmForestLayer.test.ts index d663a83a..57d38896 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, @@ -328,15 +328,50 @@ 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'); + await vi.waitFor(() => expect(layer.getPresentationTelemetry().usingFallback).toBe(false)); + expect(layer.isAnimationActive()).toBe(false); + expect(layer.getPresentationTelemetry().canopyMotionState).toBe('static'); + layer.dispose(); + }); + 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/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..f01d8fe9 100644 --- a/tests/realmGrassVisualContract.test.ts +++ b/tests/realmGrassVisualContract.test.ts @@ -81,6 +81,7 @@ describe('natural broad grass visual contract', () => { expect(fragment).toContain('realmGrassCoverage()'); expect(fragment).toContain('diffuseColor.rgb *= mix(0.94, 1.015, 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); diff --git a/tests/realmProceduralForestFallback.test.ts b/tests/realmProceduralForestFallback.test.ts index bb3ee1c1..7d8943aa 100644 --- a/tests/realmProceduralForestFallback.test.ts +++ b/tests/realmProceduralForestFallback.test.ts @@ -31,6 +31,8 @@ describe('local procedural forest fallback', () => { 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 +43,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); diff --git a/tests/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index 36c43ccc..0d32b994 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -822,7 +822,7 @@ 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(ocean.material.userData.waterShaderContract).toContain('-v7-ripples-4'); expect(shader.uniforms).toHaveProperty('uWaterTime'); expect(layer.updateEnvironment(1)).toBe(true); expect(layer.updateEnvironment(1)).toBe(false); @@ -831,6 +831,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 +874,7 @@ 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('-v7-ripples-'); layer.dispose(); } From 2bda07e03cbda0f0b927738c2b0a9823581bc70b Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 13:43:31 +0200 Subject: [PATCH 4/7] feat: add camera-local ambient ecology --- src/components/realm/RealmMapScreen.tsx | 6 + .../realm/createRealmAmbientEcologyLayer.ts | 263 ++++++++++++++++++ src/components/realm/createRealmScene.ts | 157 ++++++++++- src/dev/RenderedWebglQaHarness.tsx | 1 + tests/realmAmbientEcologyLayer.test.ts | 79 ++++++ 5 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 src/components/realm/createRealmAmbientEcologyLayer.ts create mode 100644 tests/realmAmbientEcologyLayer.test.ts 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/createRealmAmbientEcologyLayer.ts b/src/components/realm/createRealmAmbientEcologyLayer.ts new file mode 100644 index 00000000..72f44075 --- /dev/null +++ b/src/components/realm/createRealmAmbientEcologyLayer.ts @@ -0,0 +1,263 @@ +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(); + birds?.dispose(); + birdMaterial?.dispose(); + points?.dispose(); + pointMaterial?.dispose(); + } + }); +} diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 9711d272..6d9d9bae 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -55,6 +55,11 @@ import { } from '../../game/map/terrainPlacements'; import { createTerrainDecorationLayers } from './createTerrainDecorations'; import { createRealmGrassLayer, type RealmGrassLayer, type RealmGrassTelemetry } from './createRealmGrassLayer'; +import { + createRealmAmbientEcologyLayer, + type RealmAmbientEcologyLayer +} from './createRealmAmbientEcologyLayer'; +import { createRealmSurfaceDisturbanceField } from './realmSurfaceDisturbanceField'; import { createRealmTerrainFeatureLayers } from './createRealmTerrainFeatures'; import { createRealmForestLayer, type RealmForestLayer } from './realmForestLayer'; import { @@ -196,6 +201,7 @@ import { } from './realmPickArbitration'; import { REALM_LIGHTING_SPECS, + resolveRealmLivingRealmBudget, resolveRealmPixelRatio, resolveRealmRenderPlan, type RealmQualitySpec @@ -934,6 +940,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; @@ -1431,6 +1439,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, @@ -1880,6 +1892,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 ?? [] ); @@ -2090,6 +2108,7 @@ function initializeRealmScene( ]), plan: renderPlan.grass, reducedMotion: options.reducedMotion, + livingBudget, hexSize: HEX_SIZE, alphaToCoverage: grassAlphaToCoverage, vegetationField, @@ -2107,6 +2126,29 @@ function initializeRealmScene( // Decorative failure must not take the terrain, input, or castle layer down. options.canvas.dataset.grassPresentation = 'unavailable'; } + const surfaceDisturbances = createRealmSurfaceDisturbanceField( + livingBudget.grassDisturbanceSlots + 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; + } const emptyGrassTelemetry: RealmGrassTelemetry = Object.freeze({ candidateCellCount: 0, activeCellCount: 0, @@ -2780,6 +2822,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; @@ -2861,6 +2909,8 @@ function initializeRealmScene( ) > 0 && ( grassLayer?.isAnimationActive() === true + || forestLayer?.isAnimationActive() === true + || ambientEcologyLayer?.isAnimationActive() === true || decorations.animated || goldNodeLayer?.hasMovingWagons() === true || foodNodeLayer?.hasMovingWagons() === true @@ -3080,6 +3130,69 @@ 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 + }; + workerWakeSamples.set(worker.workerId, next); + if (!previous || 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 || 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 forest = forestLayer?.getPresentationTelemetry(); + const values = { + realmLivingGrassDisturbanceSlots: livingBudget.grassDisturbanceSlots, + realmLivingWaterRippleSlots: livingBudget.waterRippleSlots, + realmLivingActiveGrassDisturbances: disturbances.activeGrassCount, + realmLivingActiveWaterRipples: disturbances.activeWaterCount, + realmLivingDisturbanceInsertions: disturbances.insertedCount, + realmLivingDisturbanceDrops: disturbances.droppedCount, + realmLivingForestMotion: forest?.canopyMotionState ?? 'static', + realmLivingForestWindAttributeBytes: forest?.windAttributeBytes ?? 0, + realmLivingEcologyDrawCalls: ecology?.drawCalls ?? 0, + realmLivingEcologyTriangles: ecology?.triangleCount ?? 0, + realmLivingBirdCount: ecology?.birdCount ?? 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)); + } + }; const render = () => { if (cleanup.isDisposed()) return; if (contextLost) return; @@ -3164,6 +3277,19 @@ 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 + ); + syncLivingRealmTelemetry(); const workerTelemetry = workerLayer?.getPresentationTelemetry(); if ( workerTelemetry @@ -3688,6 +3814,8 @@ function initializeRealmScene( }); const handleRenderVisibility = () => { if (document.hidden) { + workerWakeSamples.clear(); + surfaceDisturbances.clear(); workerMovementWakeSuspended = ( presentationActive && options.canvas.dataset.realmCanvasActive !== 'false' @@ -3740,16 +3868,36 @@ 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; if ( grassChanged + || forestChanged || terrainChanged || wagonsMoving || foodWagonsMoving @@ -3757,6 +3905,7 @@ function initializeRealmScene( || stoneWagonsMoving || workersMoving || waterChanged + || ecologyChanged ) render(); } }); @@ -4379,6 +4528,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', @@ -5143,6 +5294,8 @@ function initializeRealmScene( workerMovementWakeSuspended = false; renderPendingWhileHidden = false; cancelWorkerMovementWake(); + workerWakeSamples.clear(); + surfaceDisturbances.clear(); } ambientScheduler?.setActive(active && ambientIsNeeded()); if (active) render(); 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/tests/realmAmbientEcologyLayer.test.ts b/tests/realmAmbientEcologyLayer.test.ts new file mode 100644 index 00000000..bbc70dee --- /dev/null +++ b/tests/realmAmbientEcologyLayer.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } 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); + layer.dispose(); + }); + + 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(); + }); +}); From e1ecfefec5785e6e8a4dd450e683412cfd90c0c0 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 14:01:16 +0200 Subject: [PATCH 5/7] fix: bound Living Realm wake telemetry --- docs/design/living-realm-v1.md | 55 ++++++++++++++++ src/components/realm/createRealmScene.ts | 21 ++++-- .../realm/realmSurfaceDisturbanceField.ts | 66 +++++++++++++++---- tests/realmSurfaceDisturbanceField.test.ts | 43 +++++++++++- 4 files changed, 167 insertions(+), 18 deletions(-) diff --git a/docs/design/living-realm-v1.md b/docs/design/living-realm-v1.md index 74c15527..113bd7bc 100644 --- a/docs/design/living-realm-v1.md +++ b/docs/design/living-realm-v1.md @@ -83,3 +83,58 @@ inactive presentation, context loss, shader-contract drift, and disposal all disable optional moving ambience. No subsystem owns a second animation frame loop or interval. A failed optional material 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. 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; rivers receive light/normal + response without breaking their 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. +- 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`: 271 Vitest files and 2,979 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, or forest shader fallback. Existing subsystem topology and draw counts +remain unchanged; the only new visible draws are the two bounded ecology +draws. + +| Case | Existing grass / water / forest draws | New draws / triangles | Living slots | Ambient cap | +| --- | ---: | ---: | ---: | ---: | +| High 1920×1080 | 3 / 3 / 1 | 2 / 24 | grass 8, water 4 | 30 Hz | +| Balanced 1280×720 | 2 / 3 / 1 | 2 / 12 | grass 4, water 2 | 22 Hz | +| Balanced tablet 1024×768 | 2 / 3 / 1 | 2 / 12 | grass 4, water 2 | 22 Hz | +| Balanced portrait 390×844 | 2 / 3 / 1 | 2 / 12 | grass 4, water 2 | 22 Hz | +| Balanced short landscape 667×375 | 2 / 3 / 1 | 2 / 12 | 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. Reduced held zero moving ecology, zero ripple/disturbance slots, +zero new draws, and no ambient scheduler demand. diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index 6d9d9bae..421e10f3 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -2126,9 +2126,10 @@ function initializeRealmScene( // Decorative failure must not take the terrain, input, or castle layer down. options.canvas.dataset.grassPresentation = 'unavailable'; } - const surfaceDisturbances = createRealmSurfaceDisturbanceField( - livingBudget.grassDisturbanceSlots + livingBudget.waterRippleSlots - ); + const surfaceDisturbances = createRealmSurfaceDisturbanceField({ + grassCapacity: livingBudget.grassDisturbanceSlots, + waterCapacity: livingBudget.waterRippleSlots + }); cleanup.add(surfaceDisturbances.dispose); let ambientEcologyLayer: RealmAmbientEcologyLayer | null = null; try { @@ -3147,14 +3148,19 @@ function initializeRealmScene( z: worker.world.z, sampledAtSeconds: seconds }; - workerWakeSamples.set(worker.workerId, next); - if (!previous || seconds - previous.sampledAtSeconds < 0.16) continue; + 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 || distance > 1.5) continue; + 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', @@ -3177,9 +3183,12 @@ function initializeRealmScene( 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, realmLivingEcologyTriangles: ecology?.triangleCount ?? 0, realmLivingBirdCount: ecology?.birdCount ?? 0, diff --git a/src/components/realm/realmSurfaceDisturbanceField.ts b/src/components/realm/realmSurfaceDisturbanceField.ts index 2e308dc5..e865cffc 100644 --- a/src/components/realm/realmSurfaceDisturbanceField.ts +++ b/src/components/realm/realmSurfaceDisturbanceField.ts @@ -23,6 +23,7 @@ export type RealmSurfaceDisturbanceTelemetry = Readonly<{ activeGrassCount: number; activeWaterCount: number; insertedCount: number; + evictedCount: number; droppedCount: number; }>; @@ -46,12 +47,27 @@ function finite(value: number, fallback = 0) { } export function createRealmSurfaceDisturbanceField( - requestedCapacity: number + requestedCapacity: number | Readonly<{ + grassCapacity: number; + waterCapacity: number; + }> ): RealmSurfaceDisturbanceField { - const capacity = Math.min( + const sharedCapacity = typeof requestedCapacity === 'number'; + const grassCapacity = Math.min( MAX_FIELD_CAPACITY, - Math.max(0, Math.trunc(finite(requestedCapacity))) + 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); @@ -66,6 +82,7 @@ export function createRealmSurfaceDisturbanceField( 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; @@ -92,23 +109,49 @@ export function createRealmSurfaceDisturbanceField( 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; - for (let index = 0; index < capacity; index += 1) { - if (occupied[index] === 0) { - target = index; - break; + 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 (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 (occupied[target] === 1) droppedCount += 1; + if (sharedCapacity && occupied[target] === 1) { + evictedCount += 1; + } kinds[target] = inputKind; x[target] = inputX; z[target] = inputZ; @@ -179,6 +222,7 @@ export function createRealmSurfaceDisturbanceField( activeGrassCount, activeWaterCount, insertedCount, + evictedCount, droppedCount }); }, diff --git a/tests/realmSurfaceDisturbanceField.test.ts b/tests/realmSurfaceDisturbanceField.test.ts index d039b316..2280d74b 100644 --- a/tests/realmSurfaceDisturbanceField.test.ts +++ b/tests/realmSurfaceDisturbanceField.test.ts @@ -20,6 +20,7 @@ describe('Living Realm surface disturbance field', () => { activeGrassCount: 2, activeWaterCount: 1, insertedCount: 3, + evictedCount: 0, droppedCount: 0 }); expect(Object.keys(field.getTelemetry(1.6))).not.toContain('positions'); @@ -42,7 +43,8 @@ describe('Living Realm surface disturbance field', () => { capacity: 2, activeGrassCount: 2, insertedCount: 3, - droppedCount: 1 + evictedCount: 1, + droppedCount: 0 }); expect(Array.from(field.snapshot('grass', 2, 8).centers.slice(0, 4))) .toEqual([2, 0, 1, 0]); @@ -56,4 +58,43 @@ describe('Living Realm surface disturbance field', () => { 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); + }); }); From 7f24ad847587bb0d6be116d50f194fc893d6ce63 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 14:58:57 +0200 Subject: [PATCH 6/7] feat(realm): deepen meadow ecology --- docs/design/living-realm-v1.md | 71 +++-- .../2026-08-03-lowlands-rabbit/README.md | 30 +++ .../source-manifest.json | 36 +++ ...wlands-rabbit-compact-2ecc7b1adf4c1d79.glb | Bin 0 -> 14808 bytes scripts/install-lowlands-rabbit-runtime.mjs | 81 ++++++ scripts/verify-runtime-assets.mjs | 64 ++++- .../realm/createLowPolyGrassGeometry.ts | 13 +- .../realm/createRealmGrassMaterial.ts | 8 +- .../createRealmProceduralForestFallback.ts | 14 +- .../realm/createRealmRabbitLayer.ts | 246 ++++++++++++++++++ src/components/realm/createRealmScene.ts | 61 ++++- .../realm/createRealmTerrainMaterial.ts | 6 +- src/components/realm/loadRealmRabbitAsset.ts | 103 ++++++++ src/components/realm/realmQuality.ts | 22 +- .../realm/realmRabbitRuntimeAsset.ts | 9 + src/components/realm/realmWaterLayer.ts | 40 ++- src/game/map/hegemonyLowlandsSpec.ts | 4 +- src/game/map/realmGrass.ts | 36 +-- src/game/map/terrainColor.ts | 18 +- tests/realmGrassActiveWindow.test.ts | 4 +- tests/realmGrassGenesisBounds.test.ts | 2 +- tests/realmGrassGeometry.test.ts | 2 +- tests/realmGrassLayer.test.ts | 4 +- tests/realmGrassVisualContract.test.ts | 13 +- tests/realmLivingQuality.test.ts | 8 +- tests/realmProceduralForestFallback.test.ts | 10 +- tests/realmQuality.test.ts | 4 +- tests/realmRabbitLayer.test.ts | 163 ++++++++++++ tests/realmWaterLayer.test.ts | 9 +- tests/terrainGeometry.test.ts | 6 +- 30 files changed, 971 insertions(+), 116 deletions(-) create mode 100644 docs/reference/assets/2026-08-03-lowlands-rabbit/README.md create mode 100644 docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json create mode 100644 public/models/hegemony/environment/wildlife/rabbit/hegemony-lowlands-rabbit-compact-2ecc7b1adf4c1d79.glb create mode 100644 scripts/install-lowlands-rabbit-runtime.mjs create mode 100644 src/components/realm/createRealmRabbitLayer.ts create mode 100644 src/components/realm/loadRealmRabbitAsset.ts create mode 100644 src/components/realm/realmRabbitRuntimeAsset.ts create mode 100644 tests/realmRabbitLayer.test.ts diff --git a/docs/design/living-realm-v1.md b/docs/design/living-realm-v1.md index 113bd7bc..f9a2ce23 100644 --- a/docs/design/living-realm-v1.md +++ b/docs/design/living-realm-v1.md @@ -44,8 +44,8 @@ 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 and one points draw in High or Balanced, and - adds neither in Reduced or reduced motion. + 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 @@ -59,9 +59,11 @@ implementation scale of a close first-person meadow: - 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. No TUMBLE source, bundle, -shader, artwork, preset, or binary was downloaded, inspected, copied, -decompiled, or made a dependency. +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 @@ -80,23 +82,28 @@ existing fog remain the lighting and depth contract. Reduced quality, reduced motion, strategic overview, hidden documents, inactive presentation, context loss, shader-contract drift, and disposal all -disable optional moving ambience. No subsystem owns a second animation frame -loop or interval. A failed optional material or ecology layer leaves terrain, -water topology, forest placement, Workers, interaction, and the Realm intact. +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. 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. +- 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; rivers receive light/normal - response without breaking their physical edge weld. + 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. @@ -104,6 +111,11 @@ water topology, forest placement, Workers, interaction, and the Realm intact. 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 @@ -112,29 +124,38 @@ water topology, forest placement, Workers, interaction, and the Realm intact. ## Final verification -The completed branch passed `npm run check`: 271 Vitest files and 2,979 tests, +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, or forest shader fallback. Existing subsystem topology and draw counts -remain unchanged; the only new visible draws are the two bounded ecology -draws. +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 | 2 / 24 | grass 8, water 4 | 30 Hz | -| Balanced 1280×720 | 2 / 3 / 1 | 2 / 12 | grass 4, water 2 | 22 Hz | -| Balanced tablet 1024×768 | 2 / 3 / 1 | 2 / 12 | grass 4, water 2 | 22 Hz | -| Balanced portrait 390×844 | 2 / 3 / 1 | 2 / 12 | grass 4, water 2 | 22 Hz | -| Balanced short landscape 667×375 | 2 / 3 / 1 | 2 / 12 | grass 4, water 2 | 22 Hz | +| 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. Reduced held zero moving ecology, zero ripple/disturbance slots, -zero new draws, and no ambient scheduler demand. +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..ec3a3198 --- /dev/null +++ b/docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json @@ -0,0 +1,36 @@ +{ + "assetId": "warpkeep.environment.wildlife.rabbit", + "category": "Environment/Wildlife", + "designation": { + "gameplayAuthority": false, + "visualOnly": true + }, + "runtime": { + "bytes": 14808, + "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, + "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 0000000000000000000000000000000000000000..19115cdc9f1031e15e37a70f258d99b03da44502 GIT binary patch literal 14808 zcmcJTdt8*&*2mWd@dBcj;0@4WPys<;7-j~Bd7eiFNd)Akh*#t?AP6%!Gk8NpMN>PS zV`Zn4sp(P5I+^ClJ2Uf~JeHYOnwoa8H0@O@v()O8mpN-5*fM8=+`N7ApZ(oyt-bbI zzh}>W9xSUIpW*=kXA=OjIs=T&%^X)NFEpF=7P+=oUTmzMZz?aFi4%DeW!Xr5r7T)D zvCvdKORuj^lvSII75ZY!@WL9)OruF(A~O{h6_s0LrW%8#yh<-?P8TUJ(;M`rLW|La z+0tg3j0U4wR)z)0Mpfz!C3=%AH9Au^M->$trA&}V%ID}!=5nI}gJYv&3Bu9uP&8J#uD zM;S}zx2$efP_DI@YMMhzQ8r5fu5${8vBM`dN0yn4HHMOc1=w;_3~E)SH_w#X|5eYf zH5TMr3N7Wua%Z#mb(EwkG1lNFx#cBJp__MamEWmDJJO?vK86$&!TGFXqLk_w=RZZ9 zGDf9P#A#G@SmCb<%hQ`I`guvlN~5VDYg7*QRb6Dt&|3;CE6a;>j5TF54SKWm460g; z_jibtWBbki`?jcfw_WR`E>Rch3Ab??+EAt-!>J=yVk)mHx0KImSz2hZn97T4EY4}I zJS#Idb$n{(I7#nunK>CF)8$%4q&z7zJu{~uMy`#;cFPSV<;Bi;WvkSd|+#dx0ZU>Gdp=U3zLP$^>7k@(BRqk+ldex7iMjaEY z!aNqKM0wumBqjc0Yvr!3pdt@!<+s+ywzVGBbd|zjG*uN=mM>^MwXGTBepQjQ*j?P# zjB(2N`yJ&bjZ7bzBmGcXdvbkT(xWPxZ_%gg4P_Sm?Zv5MaJkcxSz0RHr+67z3Qc9s zt5q47pvF@{1!7p-oQ`$9GDd@Pxo(~itH5K!x;|cm!LG{{v9a+^HC*afI)6{iVzdb1 z(!D~dP{-lnmdRw5N`O(RANiY#E6`rx zE{)x7W8n=WpOl|l^)WZ>t*>n?TqlpSUCqD1DROq%SeQ#$YNPeD@J4gI?NWq;^UC~1 z$HMP6=4{zy@!(A_Oy*elyCGVxGSk5sPu<*PU#$vhdRBTJCOBC|5CT ztBr+k>i#Sb8z0CmTz}fe!qs-Q?Nn|U7x1q}8w>9k-C(2jv+&^)N?WfNin(=b`*SQD z^6&gDkN2I;DOWdeEZi_g!-c3zxFMQ>91A;tQ`M7}=*Fu4Wc{PK&*AZW^*aYRXa4^$ zKeINQTfIM?_jz69Sor0Ar**4O#_=P0bac^2mFCv~~c==h-yAD)Hle$M5VJP-WHWk)y`KI(f`7eD1^ZrexGITjx7 zcfvYt{mXgw_4xbU>KyrC0+GWj8%g}tmNbQfoL;vZYsi)Ufy*}D~d zg!{DeJ?R|&9*#aYn73Mb^Uj%yn@l-9Da4>zbeemn3i+;HNU)tNuEM^~PO zr%yX7#XtAfK%RxO_BGd&oH~?eVW&PRdv0=6tB&&aM}m3pGa-`B58@Xc;kueN{iJvn zesb8zU}yYJuRxxKqZ`AmG(QVBKG$QjGu~@e1kb`wt#qIFyx;GGIOXwTn`g9&@B7hv z91EX1rLuj|5Glp8@U6S!ZI^F{^LHEG&`SD9`G1R1 z6V64S2XbOhPus$C8h$~@X0AQnZ^*HIaC|&pc;)Y0d#pL%*|urhO5StrQ(Swzabv#K znP+s?vs`=p!^$qUPkL(j^H*1M?Xmevo-NjMJpcCg)m(dgW0H^U_~?~ zh1Hp7Tc1a{_82wcT=Z#^u@{|7Uu`mK!nx?vCZo2Ti_UE_YQnkb(ibPn{7cvJI41sTm=xc2+D7ga13UGJiTqwmwdx>qE;@IJKdpZS1CZWf8TFKD+_m*9hTygE2eQQ{MECIrFv3pHgYVCI&&`C zc8Hzt-_J9B`2(rl?H%P08 z?N(|JeC*KXbj>O$X3f|Q3AcJK64orfZTlk_jK#vOtylO>xyx-V{L`CJ!mzsId}MZj z$in*64Nbj&+RbkmV-#8V%#GpVfkDUl^-}{y7XHiEMPk~OulR2+*Vk)$4yPL3F<|(l78}&1UM+0uzCl<}+Sop4gq_7~y zN1W0KA`2&v39{zx?=Px%ri(0W${HqeU3|rU4iH)RdPJf4Ug`Jt)521Yg?%?gh~28r z+7|}~3M{-eB*D7iywx6ktx90w7dAx-lb4>cb2$M53l}`ppq=&bB2oDK7w(VXX-oEM z*L1BHa^Bo&`#r4nEfE^-UFH|P-_^##-@kg1_vw3xzmSqCvhZIzU$&?AvhzM~WQr_Y zcDGo}K75&PdS7W{Vb!OX?6(Jc3K?Rez`|c-U*=09J%zvC(h4m6#z!TBZ`VuqrX%Sb z3r|>oo?kY@Qw;b>C$jL_+>7=v4|WoJ^h^|4c+bmv@vX)S_P4X|=va7G=y|*D(!cG| zW3mJmULSUeKM#lO#ltcM7Eaikt$hmW#q7J zH~Rn!554}h^nV{czO{%foY*Bxbml)7>o2nK;^a&z{@W3K1Qz~eMRPsYyB2|kTl5iK zv|{E{drs`%j(QCYPfKKBdyBSSi#3TX+;{JZ{Z6d_7K<#5IVG)H@;{JWatbWm(xd3o z?{D(;og3|2v}WPsy(jjuu=jkka76h$AG-U*J{De>oZs})@Fxze$G%w@bBdVX-je@; z?2=Pp;d_VH^SQgsVg=UE!smyr=f62-7QFpDn^^ehJhQL~>(5!FX=34_P0uv-Tz5#T zLw#5nbBdUsZ^{2acF8HQ@ZW_^c4dfJJm1T+iG{rum~39}6o~iU`qADV&mNO6%1?&c z_ZN7G?QxZUt&O{rDg*`witX{e+*rZ;bf|sl@mR4vR;F&?0^Uy%AL}g>+T+jmD#RT_ zOKjDENw^ml~eUX z&6f?76B(kuhM~2vYbDs4bO@EPv_kEi$u8W#xResS$VByLa zYWPhsz*^EUPhjD`zV@c`_v*!nJ=xlh^4OrQO}+5lmb2%CwxeA0cD-n*&O3er3wKUm$6uJy&-(H; zv%tbP|M6;*2j0gQpF5=ODC0L-g7ce9u~?(+DDP?c_NZEy+{D7`mu`~2>r(rfMHaq# zuK7EyqS9Yv;Yqi~N#7?UcJvWgxX&{iqcb7ZAB&eOdvTaDv_ zP_1887l-4msM>z=ZmsKS{N*9DdWWH(#<}OGesSo>y)!V5){lZ9zY^)4HiWT750>4)#awv=3^ZuI?7p75k)i+8^zU`e|R(Py5Obd)03Z?teA> z3IDHgr0ZyZH12HZEWbMR(|GEqeNzA5hW*FCPgjS7+G(G@;obdr;d<()cG@TPPm1i^ zcLlDeb~-PM{-obnd``6Q`NLoD`##3G+iAbF9~wvdqJCO0?T7A9*U>uZ{xlEWuQ3h+ zpTp@(-xz(qUpn?d z<7piA`-Xb>O~yEypTZE>Jm%HD+ zzDL8}^jn8@_(V7MQ{p~!oqPQULudHS$2b~K>!bB2g!cCZrrGZXVr zKi!wwv*wg$-N5|RPV1oiQa`n~w?ED2zV1o#BE*5iae=%6@i$vA!s zvtSsGkC83^6X))~0aXxzaqjut?XQv@pu#x156$atZ!f=l{4Fp@`&N>*5Q}+(J32pe~;5re>CnhD1opx;%GkVr}M*PAE+^&=BIP_ywp$E z)BWA!dH<4bLpvzq23yy31SNgnzBem1{@_?%D13IHLSF*L~|<>@*Lx(>cv^ ze9*QYM=_qxX&!2)bGn||X&!gG(!Zcv2-bV0kIcIW$Mm2nzQvf|-5wZd?H=o5r}JHb zKE7!#b~>kd-1APCd-PIyweCmrP&=K|Jk(C-G%vMZtgW6KgwbT8ny>rD-X|@jeWmS*?CMR*1OhxGb2I%>-e1&Yf`oawZ_rBG#~Z9IXgYQ zSFpoD{d6B{kF1%V86WcN{pr5cPwS=i(fqVdn$LaRlIqa3QK)C|+>q=!gYi3e5y>AD z?r_{*a&z)z?0d(b_7*QyI2=dHuaDL{`=mH@S3SKZ2eDH4cg|5&YVxSi&zz-e*e|*pNgZ}sy2*ev! z20;)EkH8Q}fo?Du-vOx*4N))#-zG{(2MykqWAVmKgbciwdB}v3FapNGXh?=Myt#8A zAErSTOobVc4TX3cPXIl>OLAc*-t6P?tvCttzyy@S$G`&2J7I@upTzTYS;u@ z;0b7ir{PI>5uSm+!V9n#o`bEh0iK7KVKe+4UV^9K6?hGH!#`jb?1gQxAFS{y*kC7Y z2Rr-|UI!8Oz&>~bn&1HZ3wFT2;V`@l2jLjJ4~O6coP;;wG#r7q;6r!^j=~vu501lG z_yA79NANa$44=V8_yoR$%Ww{^!Zr95zJUwyIb4TJ@CAGe{|8s#JNOE|hVS7#`~bJ% z26Q8y1jvuzfLri0c#xmqE_jh&;3jk-cfgnQCf+2N1dx8D2kAq6$U|fR=}G)Z5P6vR zlfFbo29t0SMdV~SQIHT4OH^bCQIiBRlxWE?5=uA{Ng{}j#E@u`NR%Xw@I*u6i9o{0 zC^CkmlMy71WRfJ3MRG|p8Bg-bNHUQ;LPnFxWGqP~lSl>`N2ZW$l0&AEJTieyB`KtU z=t%{cPRdCYnL!MsniLTenL`Rm4JjqXWImZm%E(+Yi&T<%#7Jh7T4Et)vVfG3g=7(_ zCt>)$_r;SLiDy%R-xPj$GF5ms`$GbrO#w#X*$jpxJew)_h0_gBkpa)q6fomCnga{) z92G+yo}(qO9M4e$ejiLkRsVy!b|alp*B^lp2ddhMc#yl$m3Wbxa1V9$CB0DBU@{PO c?LqpZt`Ctw)YXp+LS6kychq$uslyuo3&d%0+W-In literal 0 HcmV?d00001 diff --git a/scripts/install-lowlands-rabbit-runtime.mjs b/scripts/install-lowlands-rabbit-runtime.mjs new file mode 100644 index 00000000..70573ca2 --- /dev/null +++ b/scripts/install-lowlands-rabbit-runtime.mjs @@ -0,0 +1,81 @@ +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]; +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.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..4296fe71 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,21 @@ 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 requiredCastleExtensions = Object.freeze([ 'EXT_meshopt_compression', 'EXT_texture_webp', @@ -389,7 +410,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 +435,47 @@ 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]; + 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.images !== undefined + || json.textures !== 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/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/createRealmGrassMaterial.ts b/src/components/realm/createRealmGrassMaterial.ts index d8ba94f1..71c7e9e7 100644 --- a/src/components/realm/createRealmGrassMaterial.ts +++ b/src/components/realm/createRealmGrassMaterial.ts @@ -228,8 +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 *= vec3(1.0) + vec3(0.105, 0.072, 0.026) * vGrassSunTransmission * 0.34; +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)}`; @@ -292,6 +292,10 @@ export function createRealmGrassMaterial( }); 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, diff --git a/src/components/realm/createRealmProceduralForestFallback.ts b/src/components/realm/createRealmProceduralForestFallback.ts index 164ed520..b7fe2472 100644 --- a/src/components/realm/createRealmProceduralForestFallback.ts +++ b/src/components/realm/createRealmProceduralForestFallback.ts @@ -401,16 +401,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..dadca9cf --- /dev/null +++ b/src/components/realm/createRealmRabbitLayer.ts @@ -0,0 +1,246 @@ +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; +}>; + +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 + }).then((loaded) => { + if (disposed) { + loaded.release(); + return; + } + prefab = loaded; + rabbitMaterial = loaded.material.clone(); + rabbitMaterial.name = 'realm-lowlands-rabbit-compact-material'; + rabbitMesh = new THREE.InstancedMesh( + loaded.geometry, + rabbitMaterial, + capacity + ); + rabbitMesh.name = 'realm-lowlands-rabbit-compact-instances'; + rabbitMesh.count = 0; + rabbitMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + rabbitMesh.frustumCulled = false; + rabbitMesh.castShadow = false; + rabbitMesh.receiveShadow = false; + rabbitMesh.raycast = () => {}; + group.add(rabbitMesh); + assetReady = true; + update(lastElapsedSeconds, { x: lastFocusX, z: lastFocusZ }, lastMode); + 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(); + rabbitMaterial?.dispose(); + prefab?.release(); + 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 421e10f3..b70e95b3 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -59,6 +59,10 @@ 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'; @@ -2150,6 +2154,49 @@ function initializeRealmScene( // 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, @@ -2912,6 +2959,7 @@ function initializeRealmScene( grassLayer?.isAnimationActive() === true || forestLayer?.isAnimationActive() === true || ambientEcologyLayer?.isAnimationActive() === true + || rabbitLayer?.isAnimationActive() === true || decorations.animated || goldNodeLayer?.hasMovingWagons() === true || foodNodeLayer?.hasMovingWagons() === true @@ -3176,6 +3224,7 @@ function initializeRealmScene( const syncLivingRealmTelemetry = () => { const disturbances = surfaceDisturbances.getTelemetry(livingElapsedSeconds); const ecology = ambientEcologyLayer?.getTelemetry(); + const rabbits = rabbitLayer?.getTelemetry(); const forest = forestLayer?.getPresentationTelemetry(); const values = { realmLivingGrassDisturbanceSlots: livingBudget.grassDisturbanceSlots, @@ -3189,9 +3238,14 @@ function initializeRealmScene( realmLivingForestDrawCalls: forest?.drawCalls ?? 0, realmLivingForestWindAttributeBytes: forest?.windAttributeBytes ?? 0, realmLivingForestShaderFallbackCount: forest?.shaderFallbackCount ?? 0, - realmLivingEcologyDrawCalls: ecology?.drawCalls ?? 0, - realmLivingEcologyTriangles: ecology?.triangleCount ?? 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, @@ -3298,6 +3352,7 @@ function initializeRealmScene( pose.mode, grassDisturbanceSnapshot ); + rabbitLayer?.update(livingElapsedSeconds, pose.focus, pose.mode); syncLivingRealmTelemetry(); const workerTelemetry = workerLayer?.getPresentationTelemetry(); if ( @@ -3904,6 +3959,7 @@ function initializeRealmScene( waterDisturbanceSnapshot ) === true; const ecologyChanged = ambientEcologyLayer?.isAnimationActive() === true; + const rabbitsChanged = rabbitLayer?.isAnimationActive() === true; if ( grassChanged || forestChanged @@ -3915,6 +3971,7 @@ function initializeRealmScene( || workersMoving || waterChanged || ecologyChanged + || rabbitsChanged ) 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..1d2aeb82 --- /dev/null +++ b/src/components/realm/loadRealmRabbitAsset.ts @@ -0,0 +1,103 @@ +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; +}>; + +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(''); +} + +/** + * 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 response = await fetch(assetUrl, { + credentials: 'same-origin', + redirect: 'error', + signal: options.signal + }); + 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.'); + } + if (options.signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + 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/realmQuality.ts b/src/components/realm/realmQuality.ts index ac190c5b..8ddc6558 100644 --- a/src/components/realm/realmQuality.ts +++ b/src/components/realm/realmQuality.ts @@ -9,11 +9,12 @@ export type RealmLivingRealmBudget = Readonly<{ 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 | 2; - addedTriangles: 0 | 480 | 960; + addedDrawCalls: 0 | 3; + addedTriangles: 0 | 888 | 1484; }>; /** @@ -27,28 +28,31 @@ export const REALM_LIVING_REALM_BUDGETS = Object.freeze({ waterRippleSlots: 4, forestGustEnabled: true, birdInstances: 12, + rabbitInstances: 10, moteCount: 36, transientParticleCount: 96, plannerHz: 10, - addedDrawCalls: 2, - addedTriangles: 960 + addedDrawCalls: 3, + addedTriangles: 1484 }), balanced: Object.freeze({ grassDisturbanceSlots: 4, waterRippleSlots: 2, forestGustEnabled: true, birdInstances: 6, + rabbitInstances: 6, moteCount: 18, transientParticleCount: 48, plannerHz: 7, - addedDrawCalls: 2, - addedTriangles: 480 + addedDrawCalls: 3, + addedTriangles: 888 }), reduced: Object.freeze({ grassDisturbanceSlots: 0, waterRippleSlots: 0, forestGustEnabled: false, birdInstances: 0, + rabbitInstances: 0, moteCount: 0, transientParticleCount: 0, plannerHz: 0, @@ -255,7 +259,7 @@ export const REALM_GRASS_RENDER_PLANS: Readonly= 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) { @@ -987,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 @@ -1090,7 +1090,7 @@ float warpkeepWaterHeight(vec2 waterWorldXZ, float waterRegime, vec2 waterFlow, ? 0.62 : 0; const waterTimeExpression = activeWaveComponents > 0 ? 'uWaterTime' : '0.0'; - const shaderContract = `warpkeep-water-world-space-r185-${river ? 'river' : 'ocean'}-v7-ripples-${safeRippleSlotCount}`; + const shaderContract = `warpkeep-water-world-space-r185-${river ? 'river' : 'ocean'}-v8-reflection-ripples-${safeRippleSlotCount}`; let shaderFallback = false; material.onBeforeCompile = (shader) => { if ( @@ -1212,14 +1212,21 @@ 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); + 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( @@ -1254,13 +1261,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); 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/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..5d94d378 100644 --- a/tests/realmGrassLayer.test.ts +++ b/tests/realmGrassLayer.test.ts @@ -18,7 +18,7 @@ function plan(): RealmGrassRenderPlan { hysteresisRadius: 2, cacheLimit: 8, maximumActiveInstances: 96, - maximumActiveTriangles: 2_016 + maximumActiveTriangles: 2_592 }); } @@ -50,7 +50,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); diff --git a/tests/realmGrassVisualContract.test.ts b/tests/realmGrassVisualContract.test.ts index f01d8fe9..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,7 +79,7 @@ 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();'); @@ -87,6 +87,9 @@ describe('natural broad grass visual contract', () => { 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/realmLivingQuality.test.ts b/tests/realmLivingQuality.test.ts index 1c85e0ca..b8f93ec8 100644 --- a/tests/realmLivingQuality.test.ts +++ b/tests/realmLivingQuality.test.ts @@ -11,19 +11,23 @@ describe('Living Realm quality budgets', () => { grassDisturbanceSlots: 8, waterRippleSlots: 4, birdInstances: 12, + rabbitInstances: 10, moteCount: 36, transientParticleCount: 96, plannerHz: 10, - addedDrawCalls: 2 + 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: 2 + addedDrawCalls: 3, + addedTriangles: 888 }); }); diff --git a/tests/realmProceduralForestFallback.test.ts b/tests/realmProceduralForestFallback.test.ts index 7d8943aa..6b40dc1b 100644 --- a/tests/realmProceduralForestFallback.test.ts +++ b/tests/realmProceduralForestFallback.test.ts @@ -76,11 +76,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..e9818159 --- /dev/null +++ b/tests/realmRabbitLayer.test.ts @@ -0,0 +1,163 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +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 { 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 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(); + 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(); + }); +}); diff --git a/tests/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index 0d32b994..6abd3ae6 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -822,7 +822,11 @@ 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('-v7-ripples-4'); + expect(shader.fragmentShader).toContain('float waterFresnel = pow('); + 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); @@ -874,7 +878,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('-v7-ripples-'); + 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]) => { From cb5316e50e5f997ad2a6a7e004a74022e5f5dda6 Mon Sep 17 00:00:00 2001 From: Ael Date: Mon, 3 Aug 2026 15:33:45 +0200 Subject: [PATCH 7/7] fix(realm): harden living ecology --- .../source-manifest.json | 2 + scripts/install-lowlands-rabbit-runtime.mjs | 7 + scripts/verify-runtime-assets.mjs | 36 ++++ .../realm/createRealmAmbientEcologyLayer.ts | 1 + src/components/realm/createRealmGrassLayer.ts | 2 + .../createRealmProceduralForestFallback.ts | 47 +++--- .../realm/createRealmRabbitLayer.ts | 88 +++++++--- src/components/realm/createRealmScene.ts | 25 ++- src/components/realm/loadRealmRabbitAsset.ts | 155 ++++++++++++++++-- src/components/realm/realmForestLayer.ts | 85 ++++++---- .../realm/realmRabbitRuntimeAsset.ts | 1 + src/components/realm/realmWaterLayer.ts | 3 +- tests/realmAmbientEcologyLayer.test.ts | 7 +- tests/realmForestLayer.test.ts | 17 ++ tests/realmGrassLayer.test.ts | 17 +- tests/realmLivingQuality.test.ts | 17 ++ tests/realmProceduralForestFallback.test.ts | 5 +- tests/realmRabbitLayer.test.ts | 128 +++++++++++++++ tests/realmSceneCleanup.test.ts | 7 +- tests/realmWaterLayer.test.ts | 4 + 20 files changed, 558 insertions(+), 96 deletions(-) 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 index ec3a3198..7879ba44 100644 --- a/docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json +++ b/docs/reference/assets/2026-08-03-lowlands-rabbit/source-manifest.json @@ -7,6 +7,7 @@ }, "runtime": { "bytes": 14808, + "embeddedBufferBytes": 13164, "file": "Warpkeep_Rabbit_LOD2_Compact_Static_Runtime.glb", "rigged": false, "sha256": "2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c", @@ -24,6 +25,7 @@ "collision": false, "embeddedTextures": 0, "externalDependencies": 0, + "buffers": 1, "frontFacing": "+Z", "materials": 1, "meshes": 1, diff --git a/scripts/install-lowlands-rabbit-runtime.mjs b/scripts/install-lowlands-rabbit-runtime.mjs index 70573ca2..e5c403a2 100644 --- a/scripts/install-lowlands-rabbit-runtime.mjs +++ b/scripts/install-lowlands-rabbit-runtime.mjs @@ -37,6 +37,7 @@ if (hash !== expectedHash) { 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 @@ -57,6 +58,12 @@ if ( || 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 ) { diff --git a/scripts/verify-runtime-assets.mjs b/scripts/verify-runtime-assets.mjs index 4296fe71..a138bb9c 100644 --- a/scripts/verify-runtime-assets.mjs +++ b/scripts/verify-runtime-assets.mjs @@ -366,6 +366,37 @@ if ( ); } +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', @@ -442,6 +473,7 @@ for (const [relativePath, expectedBytes, expectedHash, glb] of assets) { 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' @@ -468,8 +500,12 @@ for (const [relativePath, expectedBytes, expectedHash, glb] of assets) { || 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']) diff --git a/src/components/realm/createRealmAmbientEcologyLayer.ts b/src/components/realm/createRealmAmbientEcologyLayer.ts index 72f44075..25885ac6 100644 --- a/src/components/realm/createRealmAmbientEcologyLayer.ts +++ b/src/components/realm/createRealmAmbientEcologyLayer.ts @@ -254,6 +254,7 @@ export function createRealmAmbientEcologyLayer( if (disposed) return; disposed = true; group.clear(); + birdMesh?.dispose(); birds?.dispose(); birdMaterial?.dispose(); points?.dispose(); diff --git a/src/components/realm/createRealmGrassLayer.ts b/src/components/realm/createRealmGrassLayer.ts index 587153b3..547422f1 100644 --- a/src/components/realm/createRealmGrassLayer.ts +++ b/src/components/realm/createRealmGrassLayer.ts @@ -589,6 +589,8 @@ 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, diff --git a/src/components/realm/createRealmProceduralForestFallback.ts b/src/components/realm/createRealmProceduralForestFallback.ts index b7fe2472..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,29 +353,31 @@ export function createRealmProceduralForestFallbackGeometry( 'color', new THREE.Float32BufferAttribute(output.colors, 3) ); - 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 + 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) ); - 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(); diff --git a/src/components/realm/createRealmRabbitLayer.ts b/src/components/realm/createRealmRabbitLayer.ts index dadca9cf..bafc3396 100644 --- a/src/components/realm/createRealmRabbitLayer.ts +++ b/src/components/realm/createRealmRabbitLayer.ts @@ -33,6 +33,8 @@ export type CreateRealmRabbitLayerOptions = Readonly<{ 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; @@ -179,30 +181,61 @@ export function createRealmRabbitLayer( if (capacity > 0) { void loadRealmRabbitAsset({ baseUrl: options.baseUrl, - signal: abortController.signal + signal: abortController.signal, + requestTimeoutMs: options.requestTimeoutMs }).then((loaded) => { if (disposed) { loaded.release(); return; } - prefab = loaded; - rabbitMaterial = loaded.material.clone(); - rabbitMaterial.name = 'realm-lowlands-rabbit-compact-material'; - rabbitMesh = new THREE.InstancedMesh( - loaded.geometry, - rabbitMaterial, - capacity - ); - rabbitMesh.name = 'realm-lowlands-rabbit-compact-instances'; - rabbitMesh.count = 0; - rabbitMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); - rabbitMesh.frustumCulled = false; - rabbitMesh.castShadow = false; - rabbitMesh.receiveShadow = false; - rabbitMesh.raycast = () => {}; - group.add(rabbitMesh); - assetReady = true; - update(lastElapsedSeconds, { x: lastFocusX, z: lastFocusZ }, lastMode); + 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; @@ -234,8 +267,21 @@ export function createRealmRabbitLayer( disposed = true; abortController.abort(); group.clear(); - rabbitMaterial?.dispose(); - prefab?.release(); + 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; diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index b70e95b3..1d834843 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -3256,6 +3256,7 @@ function initializeRealmScene( setCanvasDatasetValue(key, String(value)); } }; + let lastPostCompileLivingShaderSignature = ''; const render = () => { if (cleanup.isDisposed()) return; if (contextLost) return; @@ -3582,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(); diff --git a/src/components/realm/loadRealmRabbitAsset.ts b/src/components/realm/loadRealmRabbitAsset.ts index 1d2aeb82..f371bcac 100644 --- a/src/components/realm/loadRealmRabbitAsset.ts +++ b/src/components/realm/loadRealmRabbitAsset.ts @@ -18,8 +18,14 @@ export type RealmRabbitPrefab = Readonly<{ 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)] @@ -27,6 +33,137 @@ async function sha256Hex(bytes: ArrayBuffer) { .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, @@ -41,23 +178,9 @@ export async function loadRealmRabbitAsset( asset.path, asset.sha256 ); - const response = await fetch(assetUrl, { - credentials: 'same-origin', - redirect: 'error', - signal: options.signal - }); - 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.'); - } + 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( diff --git a/src/components/realm/realmForestLayer.ts b/src/components/realm/realmForestLayer.ts index 9bdf8dd0..e1b8865e 100644 --- a/src/components/realm/realmForestLayer.ts +++ b/src/components/realm/realmForestLayer.ts @@ -156,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; @@ -181,17 +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); - 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 (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 @@ -332,7 +335,8 @@ function createMergedTreeMesh( matrix, point.habitat, snowCoverage, - sandCoverage + sandCoverage, + motionEnabled ); }); if ((treeTintFlags & FOREST_TINT_SNOW) !== 0) snowTintedTreeCount += 1; @@ -354,14 +358,16 @@ function createMergedTreeMesh( 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)); - geometry.setAttribute( - 'realmForestWindWeight', - new THREE.Uint8BufferAttribute(source.windWeights, 1, true) - ); - geometry.setAttribute( - 'realmForestWindPhase', - new THREE.Uint8BufferAttribute(source.windPhases, 1, true) - ); + 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(); @@ -377,7 +383,9 @@ function createMergedTreeMesh( snowTintedTreeCount, dryTintedTreeCount, wind, - windAttributeBytes: source.windWeights.length + source.windPhases.length + windAttributeBytes: motionEnabled + ? source.windWeights.length + source.windPhases.length + : 0 }); } catch (error) { geometry.dispose(); @@ -395,7 +403,8 @@ function createFallbackForestMesh( motionEnabled: boolean ) { const fallback = createRealmProceduralForestFallbackGeometry( - HEGEMONY_TREE_TARGET_VISUAL_HEIGHT + HEGEMONY_TREE_TARGET_VISUAL_HEIGHT, + motionEnabled ); const { geometry } = fallback; const material = createRealmProceduralForestFallbackMaterial(); @@ -447,17 +456,35 @@ function createFallbackForestMesh( snowTintedTreeCount, dryTintedTreeCount, wind, - windAttributeBytes: - geometry.getAttribute('realmForestWindWeight').count - + geometry.getAttribute('realmForestWindPhase').count + 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( @@ -648,7 +675,7 @@ export function createRealmForestLayer( : usingFallback ? 'terrain-canopy-procedural-root-contact' : 'terrain-canopy-baked-base', - canopyMotionState: activeWind.isActive() + canopyMotionState: !disposed && activeWind.isActive() ? REALM_FOREST_LIVING_CANOPY_MOTION_STATE : 'static', structureCellCounts, diff --git a/src/components/realm/realmRabbitRuntimeAsset.ts b/src/components/realm/realmRabbitRuntimeAsset.ts index ce0ff6ec..25a64047 100644 --- a/src/components/realm/realmRabbitRuntimeAsset.ts +++ b/src/components/realm/realmRabbitRuntimeAsset.ts @@ -5,5 +5,6 @@ export const REALM_RABBIT_RUNTIME_ASSET = Object.freeze({ sha256: '2ecc7b1adf4c1d79b7ca2d5ea9a6727ed3f6d9072047466082bb912d34ea930c', triangles: 146, uploadedVertices: 384, + embeddedBufferBytes: 13_164, visualHeight: 0.265_102 }); diff --git a/src/components/realm/realmWaterLayer.ts b/src/components/realm/realmWaterLayer.ts index 71a65210..9d00757f 100644 --- a/src/components/realm/realmWaterLayer.ts +++ b/src/components/realm/realmWaterLayer.ts @@ -1211,7 +1211,8 @@ varying vec2 vWarpkeepWaterWorldXZ; varying vec2 vWarpkeepWaterFlow; ${shader.fragmentShader}` .replace('#include ', ` - float waterViewFacing = max(dot(normalize(vNormal), normalize(-vViewPosition)), 0.0); + // 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); diff --git a/tests/realmAmbientEcologyLayer.test.ts b/tests/realmAmbientEcologyLayer.test.ts index bbc70dee..3a83cbc6 100644 --- a/tests/realmAmbientEcologyLayer.test.ts +++ b/tests/realmAmbientEcologyLayer.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +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'; @@ -33,7 +34,11 @@ describe('Living Realm ambient ecology layer', () => { 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', () => { diff --git a/tests/realmForestLayer.test.ts b/tests/realmForestLayer.test.ts index 57d38896..d4ce27e8 100644 --- a/tests/realmForestLayer.test.ts +++ b/tests/realmForestLayer.test.ts @@ -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); @@ -366,10 +371,22 @@ describe('static forest presentation layer', () => { 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 () => { diff --git a/tests/realmGrassLayer.test.ts b/tests/realmGrassLayer.test.ts index 5d94d378..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'; @@ -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); @@ -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/realmLivingQuality.test.ts b/tests/realmLivingQuality.test.ts index b8f93ec8..a5bf809a 100644 --- a/tests/realmLivingQuality.test.ts +++ b/tests/realmLivingQuality.test.ts @@ -4,6 +4,7 @@ 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', () => { @@ -39,4 +40,20 @@ describe('Living Realm quality budgets', () => { 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 6b40dc1b..1aa82283 100644 --- a/tests/realmProceduralForestFallback.test.ts +++ b/tests/realmProceduralForestFallback.test.ts @@ -26,7 +26,8 @@ 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'); @@ -63,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); diff --git a/tests/realmRabbitLayer.test.ts b/tests/realmRabbitLayer.test.ts index e9818159..ae7148bb 100644 --- a/tests/realmRabbitLayer.test.ts +++ b/tests/realmRabbitLayer.test.ts @@ -1,6 +1,7 @@ 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 })); @@ -26,6 +27,7 @@ vi.mock('three/addons/loaders/GLTFLoader.js', async () => { }); 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, '..'); @@ -90,6 +92,7 @@ describe('Living Realm compact Rabbit layer', () => { 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 @@ -109,6 +112,8 @@ describe('Living Realm compact Rabbit layer', () => { triangleCount: 0 }); layer.dispose(); + layer.dispose(); + expect(dispose).toHaveBeenCalledOnce(); expect(layer.getTelemetry()).toMatchObject({ enabled: false, instanceCapacity: 0 }); }); @@ -160,4 +165,127 @@ describe('Living Realm compact Rabbit layer', () => { }); 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/realmWaterLayer.test.ts b/tests/realmWaterLayer.test.ts index 6abd3ae6..38c5eb7a 100644 --- a/tests/realmWaterLayer.test.ts +++ b/tests/realmWaterLayer.test.ts @@ -823,6 +823,10 @@ describe('Realm canonical water layer', () => { expect(shader.vertexShader).not.toContain('vViewPosition.xz'); expect(shader.fragmentShader).toContain('outgoingLight +='); 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)