Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/design/layered-water-atmosphere.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Layered Water and Atmosphere

This renderer-only layer builds on the immutable Genesis Water v1 rows and the
active ocean-and-river revision. It does not add tables, regenerate topology,
change passability, or publish Water state.

## Phase

`realm_environment_v1.updated_at` is now retained in the already-persisted
public projection. `realmWaterPhase.ts` uses that server boundary, the
environment epoch, and a synchronized server-time estimate when one is
available. Monotonic local time advances only between samples. Reduced motion
uses a deterministic frozen phase. A tab resume clamps the scheduler delta and
never fast-forwards a shader by the hidden-tab duration.

## Geometry

- Ocean and legacy lake rows share one deterministic world-space vertex map.
High uses three subdivisions per canonical hex, Balanced two, and Reduced
one. The geometry keeps a triangle-to-cell-key table for selection.
- The twelve canonical river paths become joined ribbons. Each cell contributes
one centerline point, a flow-aligned bank pair, bounded width in the
`0.50–0.72` range, and deterministic source/mouth caps. Terrain contact is
sampled only for presentation clearance; the underlying terrain and route
authority remain unchanged.
- Full-fog cells are still rendered as a bounded coarse buffer, but the shader
replaces all Water lighting with the exact fog color when the canonical fog
factor reaches `1`. A single opaque vertical curtain closes the outer ring.

## Material

The Three.js r185 `MeshStandardMaterial` path remains authoritative. The
vertex shader displaces ocean swell and river flow from world-space functions,
then derives analytic normals from the same function. The fragment path layers
depth absorption, restrained Fresnel, sun glitter, shore/crest/river foam, and
fog replacement. A stable cache key and explicit shader markers make contract
drift fail closed through the existing Water-unavailable scene path.

## Budgets

| Quality | Water triangles | Water draws | Wave components | Cadence |
| --- | ---: | ---: | ---: | ---: |
| High | 220,000 | 5 | 8 | 30 fps |
| Balanced | 105,000 | 5 | 5 | 22 fps |
| Reduced | 35,000 | 4 | 0 | static |

The ambience scheduler remains the only RAF owner. Hidden tabs, disposed
scenes, reduced motion, and static Water stop work. No per-frame geometry
rebuild, per-vertex CPU wave loop, reflection target, or per-cell draw exists.

## Selection and fallback

The Water layer maps raycast triangle indices back to exact canonical cell keys,
rejects full-fog hits, and owns a pointer-inert selected-cell outline. This
mapping is independent of subdivision quality and subscription row order. If
geometry or shader construction fails, the existing scene keeps terrain,
controls, and the static fallback path; no gameplay authority is revoked.
11 changes: 10 additions & 1 deletion src/components/realm/RealmMapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,13 @@ function CanonicalRealmMapScreen({
if (node) selectStoneNode(node, target.source !== 'wagon');
return;
}
if (target.kind === 'water') {
// Water selection is intentionally renderer-owned: ocean coordinates
// are outside playable terrain, while the exact canonical cell key must
// survive mesh subdivision and quality changes.
sceneRef.current?.setSelectedWaterCell(target.cellKey);
return;
}
selectCoord(target.coord);
}, [selectCastle, selectCoord, selectFoodNode, selectGoldNode, selectStoneNode, selectWoodNode]);

Expand Down Expand Up @@ -1271,6 +1278,8 @@ function CanonicalRealmMapScreen({
sharedForestLayout: sharedForestProjection.layout,
sharedForestTrees: sharedForestProjection.trees,
waterCells,
waterEnvironment: snapshot.realmEnvironment,
waterBodies: snapshot.waterBodies,
realmId: snapshot.realm.realmId,
// The retired local planner is exposed only to the synthetic dev
// observer. Player scenes wait for the paired shared public tables.
Expand Down Expand Up @@ -1351,7 +1360,7 @@ function CanonicalRealmMapScreen({
scene?.dispose();
if (sceneRef.current === scene) sceneRef.current = null;
};
}, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererUnavailable, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, sharedForestProjection, snapshot.realm.realmId, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]);
}, [foodNodeCatalog, goldNodeCatalog, handleSceneTargetHover, handleSceneTargetSelect, hasNearbyFoundingKeeps, isSceneCoordPassable, keepCoord, markRendererUnavailable, observerMode, ownCastle.castleId, peerCastles, projectedTileMetadata, qualitySpec, reducedMotion, sharedForestProjection, snapshot.realm.realmId, snapshot.realmEnvironment, stoneNodeCatalog, surface, updateCastlePresentationTelemetry, updateCastleProjection, updateFoodNodePresentationTelemetry, updateGoldNodePresentationTelemetry, updateSceneComposition, updateStoneNodePresentationTelemetry, updateTerrainPresentationTelemetry, updateWoodNodePresentationTelemetry, waterCells, woodNodeCatalog]);

useEffect(() => {
sceneRef.current?.reconcileLiveGatheringState?.(liveGatheringState);
Expand Down
91 changes: 84 additions & 7 deletions src/components/realm/createRealmScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
import { createRealmAmbientScheduler, type RealmAmbientScheduler } from './realmAmbientScheduler';
import {
createRealmWaterLayer,
REALM_WATER_ANIMATION_FRAME_CAPS,
type RealmWaterLayer
} from './realmWaterLayer';
import {
Expand Down Expand Up @@ -308,6 +309,11 @@ function isGrassShaderContractFailure(error: unknown) {
&& error.message === 'REALM_GRASS_SHADER_BEGIN_VERTEX_CONTRACT_CHANGED';
}

function isWaterShaderContractFailure(error: unknown) {
return error instanceof Error
&& error.message === 'REALM_WATER_SHADER_CONTRACT_CHANGED';
}

type RealmCastleLabelScreenPoint = Readonly<{ x: number; y: number }>;

function validScreenBounds(
Expand Down Expand Up @@ -430,6 +436,7 @@ export type RealmSceneHandle = Readonly<{
setSelectedFoodSiteId: (siteId: string | null) => void;
setSelectedWoodSiteId: (siteId: string | null) => void;
setSelectedStoneSiteId: (siteId: string | null) => void;
setSelectedWaterCell: (cellKey: string | null) => void;
setComposition: (composition: RealmCameraComposition) => void;
showRealm: () => void;
}>;
Expand Down Expand Up @@ -523,6 +530,10 @@ export type CreateRealmSceneOptions = Readonly<{
realmId?: string;
/** Complete, digest-validated public water projection; absent means water is unavailable. */
waterCells?: readonly GenesisWaterCellV1[];
/** Validated body rows provide the canonical seed/preset for Water phase. */
waterBodies?: readonly unknown[];
/** Persisted environment boundary used to synchronize Water phase. */
waterEnvironment?: unknown;
/**
* Test/DEV-observer-only bridge for the retired deterministic preview.
* Production player scenes must not synthesize a forest before the public
Expand Down Expand Up @@ -1245,6 +1256,8 @@ function initializeRealmScene(
quality: runtimeQuality,
reducedMotion: options.reducedMotion,
hexSize: HEX_SIZE,
environment: options.waterEnvironment,
waterBodies: options.waterBodies,
heightAtWorld: (world) => terrainHeightAtWorld(
options.surface.renderMap,
world,
Expand Down Expand Up @@ -1644,6 +1657,7 @@ function initializeRealmScene(
let selectedFoodSiteId: string | undefined;
let selectedWoodSiteId: string | undefined;
let selectedStoneSiteId: string | undefined;
let selectedWaterCellKey: string | undefined;
let selectedTerrainCoord: HexCoord | null = null;
let hoveredTerrainCoord: HexCoord | null = null;
let castleFocusSize: Readonly<{ height: number; footprintDiameter: number }> = Object.freeze({
Expand Down Expand Up @@ -1708,6 +1722,20 @@ function initializeRealmScene(
emitTerrainPresentationTelemetry();
return true;
};
const disableWaterPresentation = () => {
const layer = waterLayer;
if (!layer) return false;
waterLayer = null;
scene.remove(layer.group);
try {
layer.dispose();
} catch {
// A shader contract fallback must not strand the rest of the scene.
}
options.canvas.dataset.waterPresentation = 'unavailable';
ambientScheduler?.setActive(ambientIsNeeded());
return true;
};
const projectionPoint = new THREE.Vector3();
const projectionBoundsPoint = new THREE.Vector3();
const projectCastleLabels = () => {
Expand Down Expand Up @@ -1924,9 +1952,12 @@ function initializeRealmScene(
try {
renderer.render(scene, cameraController.camera);
} catch (error) {
if (!isGrassShaderContractFailure(error) || !disableGrassPresentation()) throw error;
let disabled = false;
if (isGrassShaderContractFailure(error)) disabled = disableGrassPresentation();
if (isWaterShaderContractFailure(error)) disabled = disableWaterPresentation() || disabled;
if (!disabled) throw error;
// `onBeforeCompile` runs during rendering. Retry the same frame without
// only the grass layer if its pinned shader chunk contract has changed.
// only the affected decorative layer(s); terrain and controls remain.
renderer.render(scene, cameraController.camera);
}
projectCastleLabels();
Expand Down Expand Up @@ -2098,7 +2129,10 @@ function initializeRealmScene(
document.addEventListener('visibilitychange', handleRenderVisibility);
cleanup.add(() => document.removeEventListener('visibilitychange', handleRenderVisibility));
ambientScheduler = createRealmAmbientScheduler({
frameCap: renderPlan.grass.animationFrameCap,
frameCap: Math.max(
renderPlan.grass.animationFrameCap,
REALM_WATER_ANIMATION_FRAME_CAPS[runtimeQuality.id]
),
active: ambientIsNeeded(),
onStep: (elapsedSeconds) => {
if (cleanup.isDisposed()) return;
Expand Down Expand Up @@ -2200,6 +2234,16 @@ function initializeRealmScene(
const castleHit = castleLayer?.raycast(raycaster);
const foregroundHit = arbitrateRealmPick({ resourceHits, castleHit });
if (foregroundHit) return foregroundHit;
const waterHit = waterLayer?.raycast(raycaster);
if (waterHit) {
return Object.freeze({
kind: 'water' as const,
cellKey: waterHit.cellKey,
regime: waterHit.regime,
coord: waterHit.coord,
distance: waterHit.distance
});
}
const intersections = raycaster.intersectObject(terrain, false);
for (const intersection of intersections) {
const coord = worldToNearestAxial(
Expand Down Expand Up @@ -2412,10 +2456,12 @@ function initializeRealmScene(
selectedFoodSiteId = picked.kind === 'food-site' ? picked.siteId : undefined;
selectedWoodSiteId = picked.kind === 'wood-site' ? picked.siteId : undefined;
selectedStoneSiteId = picked.kind === 'stone-site' ? picked.siteId : undefined;
selectedWaterCellKey = picked.kind === 'water' ? picked.cellKey : undefined;
goldNodeLayer?.setSelectedSiteId(selectedGoldSiteId ?? null);
foodNodeLayer?.setSelectedSiteId(selectedFoodSiteId ?? null);
woodNodeLayer?.setSelectedSiteId(selectedWoodSiteId ?? null);
stoneNodeLayer?.setSelectedSiteId(selectedStoneSiteId ?? null);
waterLayer?.setSelectedCellKey(selectedWaterCellKey ?? null);
dispatchSelect(picked);
render();
if (picked.kind === 'castle' && picked.castleId === options.ownCastleId) {
Expand Down Expand Up @@ -2946,6 +2992,8 @@ function initializeRealmScene(
setSelected: (coord) => {
if (cleanup.isDisposed()) return;
selectedTerrainCoord = coord;
selectedWaterCellKey = undefined;
waterLayer?.setSelectedCellKey(null);
grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord);
selectedCastleId = coord
? authoritativeCastles.find((castle) => (
Expand All @@ -2964,6 +3012,8 @@ function initializeRealmScene(
if (cleanup.isDisposed()) return;
selectedCastleId = castleId === null ? undefined : castleId;
if (castleId !== null) {
selectedWaterCellKey = undefined;
waterLayer?.setSelectedCellKey(null);
selectedTerrainCoord = null;
grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord);
setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
Expand All @@ -2974,28 +3024,55 @@ function initializeRealmScene(
if (cleanup.isDisposed()) return;
selectedGoldSiteId = siteId === null ? undefined : siteId;
goldNodeLayer?.setSelectedSiteId(siteId);
if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
if (siteId !== null) {
selectedWaterCellKey = undefined;
waterLayer?.setSelectedCellKey(null);
setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
}
render();
},
setSelectedFoodSiteId: (siteId) => {
if (cleanup.isDisposed()) return;
selectedFoodSiteId = siteId === null ? undefined : siteId;
foodNodeLayer?.setSelectedSiteId(siteId);
if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
if (siteId !== null) {
selectedWaterCellKey = undefined;
waterLayer?.setSelectedCellKey(null);
setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
}
render();
},
setSelectedWoodSiteId: (siteId) => {
if (cleanup.isDisposed()) return;
selectedWoodSiteId = siteId === null ? undefined : siteId;
woodNodeLayer?.setSelectedSiteId(siteId);
if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
if (siteId !== null) {
selectedWaterCellKey = undefined;
waterLayer?.setSelectedCellKey(null);
setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
}
render();
},
setSelectedStoneSiteId: (siteId) => {
if (cleanup.isDisposed()) return;
selectedStoneSiteId = siteId === null ? undefined : siteId;
stoneNodeLayer?.setSelectedSiteId(siteId);
if (siteId !== null) setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
if (siteId !== null) {
selectedWaterCellKey = undefined;
waterLayer?.setSelectedCellKey(null);
setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
}
render();
},
setSelectedWaterCell: (cellKey) => {
if (cleanup.isDisposed()) return;
selectedWaterCellKey = cellKey === null ? undefined : cellKey;
waterLayer?.setSelectedCellKey(cellKey);
if (cellKey !== null) {
selectedTerrainCoord = null;
grassLayer?.setInteraction(selectedTerrainCoord, hoveredTerrainCoord);
setOverlay(selectedOverlay, options.surface, null, terrainPlacements);
}
render();
},
setComposition: (composition) => cameraController.setComposition(composition),
Expand Down
9 changes: 9 additions & 0 deletions src/components/realm/realmPickArbitration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export type RealmResourcePickHit = Readonly<{
distance: number;
}>;

export type RealmWaterPickHit = Readonly<{
kind: 'water';
cellKey: string;
regime: 'ocean' | 'lake' | 'river';
coord: HexCoord;
distance: number;
}>;

export type RealmInteractionTarget =
| Readonly<{ kind: 'castle'; castleId: number; coord: HexCoord }>
| Readonly<{
Expand All @@ -22,6 +30,7 @@ export type RealmInteractionTarget =
coord: HexCoord;
source: 'site' | 'wagon';
}>
| RealmWaterPickHit
| Readonly<{ kind: 'terrain'; coord: HexCoord }>;

function nearestValidHit(
Expand Down
Loading
Loading