@call-me-sensei/toonlab 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -1,25 +1,72 @@
1
1
  import * as THREE from 'three';
2
2
 
3
+ import { updateProjectedWaterCaustics } from '../shaders-tsl/chunks/projected-water-caustics.js';
4
+
3
5
  import {
4
6
  applyWaterSettingsToMaterial,
5
7
  createWaterMaterial,
6
8
  setWaterDebugMode,
7
9
  updateWaterMaterialCamera,
8
10
  } from './waterMaterial.js';
9
- import { WaterBreakerSystem } from './waterBreakerSystem.js';
11
+ import { WaterCurrentField } from './waterCurrentField.js';
12
+ import {
13
+ buildNearshorePhaseField,
14
+ sampleNearshorePhaseField,
15
+ } from './waterNearshorePhase.js';
16
+ import { shouldUseDedicatedBreakerShell, WaterBreakerSystem } from './waterBreakerSystem.js';
10
17
  import { WaterInteractionManager } from './waterInteraction.js';
11
18
  import { WaterRippleSimulation } from './waterRippleSimulation.js';
12
19
  import { WaterScenePasses } from './waterScenePasses.js';
20
+ import { WaterShoreStateField } from './waterShoreStateField.js';
21
+ import { updateWaterShoreMaterial } from './waterShoreMaterial.js';
13
22
  import { WaterSplashSystem } from './waterSplashSystem.js';
23
+ import {
24
+ WATER_SCENE_OVERRIDE_KEYS,
25
+ WATER_SCENE_OVERRIDE_PRIORITIES,
26
+ } from './sceneOverrideLayers.js';
14
27
  import {
15
28
  createWaterSettings,
16
29
  sampleGerstnerHeight,
30
+ sampleSwashEdgeOffset,
31
+ sampleSwashFrameState,
17
32
  } from './waterSettings.js';
18
33
 
19
34
  const worldPositionScratch = new THREE.Vector3();
20
35
  const localScratch = new THREE.Vector3();
21
36
  const breakerSampleScratch = { weight: 0, crestY: 0, flowX: 0, flowZ: 0 };
22
37
  const followScratch = new THREE.Vector3();
38
+ const currentSampleScratch = new THREE.Vector2();
39
+
40
+ const waterSceneOverrideKeySet = new Set(WATER_SCENE_OVERRIDE_KEYS);
41
+
42
+ function cleanObject(value) {
43
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
44
+ }
45
+
46
+ function cloneValue(value) {
47
+ if (Array.isArray(value)) return [...value];
48
+ if (value && typeof value === 'object') return { ...value };
49
+ return value;
50
+ }
51
+
52
+ function collectWaterSceneOverrides(options) {
53
+ return Object.fromEntries(
54
+ Object.entries(cleanObject(options))
55
+ .filter(([key, value]) => waterSceneOverrideKeySet.has(key) && value !== undefined)
56
+ .map(([key, value]) => [key, cloneValue(value)]),
57
+ );
58
+ }
59
+
60
+ function disposeAfterRenderBoundary(disposable) {
61
+ const dispose = () => disposable.dispose();
62
+ if (typeof globalThis.requestAnimationFrame !== 'function') {
63
+ globalThis.setTimeout(dispose, 34);
64
+ return;
65
+ }
66
+ globalThis.requestAnimationFrame(() => {
67
+ globalThis.requestAnimationFrame(dispose);
68
+ });
69
+ }
23
70
 
24
71
  // All-in-one stylized water body: surface mesh + material, GPU ripple
25
72
  // simulation, splash particles, interaction tracking, and the scene passes
@@ -44,6 +91,9 @@ export class WaterSurface extends THREE.Mesh {
44
91
  splashes = {},
45
92
  passes = {},
46
93
  interaction = {},
94
+ currentField = null,
95
+ nearshorePhase = false,
96
+ shoreState = false,
47
97
  follow = null,
48
98
  bedHeight = null,
49
99
  ...settingsOptions
@@ -63,7 +113,15 @@ export class WaterSurface extends THREE.Mesh {
63
113
  this.name = 'WaterSurface';
64
114
  this.width = width;
65
115
  this.depth = depth;
116
+ this.segmentsX = segmentsX;
117
+ this.segmentsZ = segmentsZ;
66
118
  this.settings = this.material.userData.waterSettings;
119
+ this._authoredQualityRequest = settingsOptions.quality && typeof settingsOptions.quality === 'object'
120
+ ? { ...settingsOptions.quality }
121
+ : this.settings.quality;
122
+ this._sceneOverrideLayers = new Map();
123
+ this._sceneOverrideSequence = 0;
124
+ this._sceneOverrides = {};
67
125
  this.time = 0;
68
126
  this.followTarget = follow;
69
127
  // Displaced waves escape the flat-plane bounds; skip culling entirely.
@@ -77,9 +135,29 @@ export class WaterSurface extends THREE.Mesh {
77
135
  // whenever the surface's world XZ moves (>5cm) from where it was baked —
78
136
  // immune to first-frame ordering races where the mesh isn't positioned
79
137
  // yet. The terrain itself is assumed static.
80
- this.bedHeightSampler = typeof bedHeight === 'function' ? bedHeight : null;
138
+ this.shoalingEnabled = typeof bedHeight === 'function';
139
+ this.bedHeightSampler = this.shoalingEnabled ? bedHeight : null;
81
140
  this.shoalingBakedX = NaN;
141
+ this.shoalingBakedY = NaN;
82
142
  this.shoalingBakedZ = NaN;
143
+ // Optional mild-slope phase refraction for the primary swell and a true
144
+ // same-direction set-beat partner when that feature is enabled.
145
+ // The graph always reads aNearshorePhase on shoaling surfaces, so even a
146
+ // disabled/unsupported stage receives a disabled fallback attribute.
147
+ const nearshoreOptions = nearshorePhase === true ? {} : nearshorePhase;
148
+ this.nearshorePhaseEnabled = Boolean(this.shoalingEnabled && nearshoreOptions);
149
+ this.nearshorePhaseOptions = this.nearshorePhaseEnabled
150
+ ? { ...nearshoreOptions }
151
+ : null;
152
+ this.nearshorePhaseField = null;
153
+ this.nearshorePhaseBakedSignature = '';
154
+ this.nearshorePhaseReference = null;
155
+ this.nearshorePhaseSample = {};
156
+ this.nearshorePhaseStatus = {
157
+ active: false,
158
+ reason: this.nearshorePhaseEnabled ? 'not-baked' : 'disabled',
159
+ };
160
+ if (this.shoalingEnabled) this.writeNearshorePhaseAttribute(null);
83
161
  // Dedicated plunging-breaker shells along the break line; created lazily
84
162
  // in update() when settings.breakerAmount > 0 and a bed sampler exists.
85
163
  this.breakers = null;
@@ -112,13 +190,208 @@ export class WaterSurface extends THREE.Mesh {
112
190
 
113
191
  this.interactions = new WaterInteractionManager(this, interaction || {});
114
192
 
193
+ // Optional authored world-space current atlas. Passing a WaterCurrentField
194
+ // shares external ownership; passing an options object creates a field
195
+ // owned by this surface. Currents feed CPU gameplay queries and the small
196
+ // shoreline-state pass, not the private-memory-heavy main water shader.
197
+ const currentFieldOptions = currentField === true ? {} : currentField;
198
+ this.ownsCurrentField = Boolean(
199
+ currentFieldOptions && !currentFieldOptions.isWaterCurrentField,
200
+ );
201
+ this.currentField = currentFieldOptions?.isWaterCurrentField
202
+ ? currentFieldOptions
203
+ : currentFieldOptions
204
+ ? new WaterCurrentField({
205
+ region: { centerX: 0, centerZ: 0, width, depth },
206
+ ...currentFieldOptions,
207
+ })
208
+ : null;
209
+
210
+ // Persistent beach history is opt-in because rivers/open-water bodies do
211
+ // not all need a fixed shoreline atlas. It is a separate small GPU pass,
212
+ // so foam transport and wet-sand memory do not enlarge the already-dense
213
+ // main water material (important for WebGPU's private-memory limit).
214
+ const shoreStateOptions = shoreState === true ? {} : shoreState;
215
+ this.shoreState = this.bedHeightSampler && shoreStateOptions
216
+ ? new WaterShoreStateField({
217
+ region: {
218
+ centerX: 0,
219
+ centerZ: 0,
220
+ width,
221
+ depth,
222
+ },
223
+ bedHeightSampler: this.bedHeightSampler,
224
+ currentField: this.currentField,
225
+ ...(shoreStateOptions || {}),
226
+ })
227
+ : null;
228
+ this.shoreStateMaterials = new Set();
229
+
115
230
  this.syncSimulationParameters();
231
+ this.bindShoreState();
116
232
  }
117
233
 
118
234
  get gerstnerWaves() {
119
235
  return this.material.userData.gerstnerWaves ?? [];
120
236
  }
121
237
 
238
+ dominantNearshoreFrame() {
239
+ const primary = this.gerstnerWaves[0];
240
+ const secondary = this.gerstnerWaves[1];
241
+ const directionLength = Math.hypot(primary?.dirX ?? 0, primary?.dirZ ?? 0) || 1;
242
+ const secondaryDirectionLength = Math.hypot(
243
+ secondary?.dirX ?? 0,
244
+ secondary?.dirZ ?? 0,
245
+ ) || 1;
246
+ const directionAgreement = primary && secondary
247
+ ? (
248
+ (primary.dirX * secondary.dirX + primary.dirZ * secondary.dirZ) /
249
+ (directionLength * secondaryDirectionLength)
250
+ )
251
+ : -1;
252
+ // The authored set-beat partner copies the primary direction exactly.
253
+ // Leave a tiny tolerance for serialization/normalization roundoff, but do
254
+ // not fold an ordinary spread-spectrum slot into the shared phase field.
255
+ const secondarySharesPhase = (this.settings.waveSetStrength ?? 0) > 0.001 &&
256
+ directionAgreement >= 0.9999;
257
+ const waves = secondarySharesPhase ? [primary, secondary] : [primary];
258
+ let weightedWaveNumber = 0;
259
+ let totalWeight = 0;
260
+ for (const wave of waves) {
261
+ const weight = Math.max(Math.abs(Number(wave?.amplitude) || 0), 1e-6);
262
+ const waveNumber = Number(wave?.waveNumber);
263
+ if (!(waveNumber > 0)) continue;
264
+ weightedWaveNumber += waveNumber * weight;
265
+ totalWeight += weight;
266
+ }
267
+ return {
268
+ directionX: (primary?.dirX ?? 1) / directionLength,
269
+ directionZ: (primary?.dirZ ?? 0) / directionLength,
270
+ deepWaveNumber: totalWeight > 0
271
+ ? weightedWaveNumber / totalWeight
272
+ : Math.max(Number(primary?.waveNumber) || 1, 1e-4),
273
+ slotMask: secondarySharesPhase ? 2 : 1,
274
+ };
275
+ }
276
+
277
+ nearshorePhaseSignature() {
278
+ const frame = this.dominantNearshoreFrame();
279
+ const options = this.nearshorePhaseOptions ?? {};
280
+ const dedicatedBreakers = shouldUseDedicatedBreakerShell(
281
+ this.settings,
282
+ Boolean(this.bedHeightSampler),
283
+ );
284
+ const solvesField = this.nearshorePhaseEnabled && !dedicatedBreakers;
285
+ return [
286
+ this.nearshorePhaseEnabled ? 1 : 0,
287
+ dedicatedBreakers ? 1 : 0,
288
+ frame.directionX,
289
+ frame.directionZ,
290
+ solvesField ? frame.slotMask : 0,
291
+ solvesField ? frame.deepWaveNumber : '',
292
+ solvesField ? options.incidentAxis ?? '' : '',
293
+ solvesField ? options.minDepth ?? '' : '',
294
+ solvesField ? options.maxWavenumberRatio ?? '' : '',
295
+ ].join('|');
296
+ }
297
+
298
+ // Writes packed q,dq/dx,dq/dz,slot-mask vertex data. A null field clears the
299
+ // mask, making the shader path an exact no-op for every wave
300
+ // direction on non-beach stages and whenever the mild-slope solve is unsafe.
301
+ writeNearshorePhaseAttribute(field) {
302
+ if (!this.shoalingEnabled) return this;
303
+ this.updateWorldMatrix(true, false);
304
+ const position = this.geometry.attributes.position;
305
+ const existing = this.geometry.attributes.aNearshorePhase;
306
+ const packed = existing?.count === position.count && existing.itemSize === 4
307
+ ? existing.array
308
+ : new Float32Array(position.count * 4);
309
+ const frame = this.dominantNearshoreFrame();
310
+ for (let index = 0; index < position.count; index += 1) {
311
+ localScratch.fromBufferAttribute(position, index).applyMatrix4(this.matrixWorld);
312
+ if (field) {
313
+ packed[index * 4] = field.phaseCoordinate[index];
314
+ packed[index * 4 + 1] = field.waveVector[index * 2];
315
+ packed[index * 4 + 2] = field.waveVector[index * 2 + 1];
316
+ packed[index * 4 + 3] = field.slotMask ?? frame.slotMask;
317
+ } else {
318
+ packed[index * 4] = frame.directionX * localScratch.x +
319
+ frame.directionZ * localScratch.z;
320
+ packed[index * 4 + 1] = frame.directionX;
321
+ packed[index * 4 + 2] = frame.directionZ;
322
+ packed[index * 4 + 3] = 0;
323
+ }
324
+ }
325
+ if (existing?.array === packed) {
326
+ existing.needsUpdate = true;
327
+ } else {
328
+ this.geometry.setAttribute('aNearshorePhase', new THREE.BufferAttribute(packed, 4));
329
+ }
330
+
331
+ this.nearshorePhaseReference = null;
332
+ if (field) {
333
+ const options = this.nearshorePhaseOptions ?? {};
334
+ const referenceX = Number.isFinite(options.referenceX) ? options.referenceX : 0;
335
+ const referenceZ = Number.isFinite(options.referenceZ) ? options.referenceZ : 0;
336
+ const sample = sampleNearshorePhaseField(field, referenceX, referenceZ, {});
337
+ const directionLength = Math.hypot(sample.waveVectorX, sample.waveVectorZ) || 1;
338
+ this.nearshorePhaseReference = {
339
+ phaseCoordinate: sample.phaseCoordinate,
340
+ directionX: sample.waveVectorX / directionLength,
341
+ directionZ: sample.waveVectorZ / directionLength,
342
+ x: referenceX,
343
+ z: referenceZ,
344
+ };
345
+ }
346
+ return this;
347
+ }
348
+
349
+ nearshoreBlendAt(restDepth) {
350
+ if (!this.nearshorePhaseField) return 0;
351
+ const range = Math.max(
352
+ this.settings.shoalingDepth ?? 1.4,
353
+ Math.max(this.waveEnergy, 1e-3) * 2.2,
354
+ 1e-3,
355
+ );
356
+ return 1 - THREE.MathUtils.smoothstep(restDepth, range * 0.8, range * 1.8);
357
+ }
358
+
359
+ sampleNearshoreAt(x, z, restDepth) {
360
+ const blend = this.nearshoreBlendAt(restDepth);
361
+ if (!(blend > 0)) return null;
362
+ const sample = sampleNearshorePhaseField(
363
+ this.nearshorePhaseField,
364
+ x,
365
+ z,
366
+ this.nearshorePhaseSample,
367
+ );
368
+ sample.blend = blend;
369
+ sample.slotMask = this.nearshorePhaseField.slotMask ?? 1;
370
+ return sample;
371
+ }
372
+
373
+ nearshoreSwashTime(time = this.time) {
374
+ const primary = this.gerstnerWaves[0];
375
+ const coordinate = this.nearshorePhaseReference?.phaseCoordinate;
376
+ if (!primary || !Number.isFinite(coordinate) || !(primary.omega > 1e-6)) return time;
377
+ return time - (primary.waveNumber * coordinate) / primary.omega;
378
+ }
379
+
380
+ sampleSwashFrame(time = this.time, runupDistance = this.settings.runupDistance) {
381
+ const frame = sampleSwashFrameState(
382
+ this.gerstnerWaves,
383
+ this.nearshoreSwashTime(time),
384
+ runupDistance,
385
+ );
386
+ const reference = this.nearshorePhaseReference;
387
+ if (reference) {
388
+ frame.primaryDirectionX = reference.directionX;
389
+ frame.primaryDirectionZ = reference.directionZ;
390
+ frame.primaryDirection = [reference.directionX, reference.directionZ];
391
+ }
392
+ return frame;
393
+ }
394
+
122
395
  syncSimulationParameters() {
123
396
  this.ripples?.setParameters({
124
397
  rippleDamping: this.settings.rippleDamping,
@@ -126,13 +399,105 @@ export class WaterSurface extends THREE.Mesh {
126
399
  rippleFoamDecay: this.settings.rippleFoamDecay,
127
400
  rippleFoamGain: this.settings.rippleFoamGain,
128
401
  });
402
+ this.shoreState?.setParameters({
403
+ moistureDryTime: this.settings.wetSandDryTime,
404
+ foamWetLifetime: this.settings.swashFoamLifetime,
405
+ foamDryLifetime: Math.max(this.settings.swashFoamLifetime * 0.45, 0.25),
406
+ residueLifetime: this.settings.swashFoamResidueLifetime,
407
+ });
408
+ }
409
+
410
+ // A ground material can consume the same ping-pong texture as the water.
411
+ // Registering it here refreshes its texture binding immediately after every
412
+ // state swap and before refraction/reflection scene passes are captured.
413
+ attachShoreStateMaterial(material) {
414
+ if (!material) return this;
415
+ this.shoreStateMaterials.add(material);
416
+ updateWaterShoreMaterial(material, { stateField: this.shoreState });
417
+ return this;
418
+ }
419
+
420
+ detachShoreStateMaterial(material) {
421
+ this.shoreStateMaterials.delete(material);
422
+ return this;
423
+ }
424
+
425
+ bindShoreState() {
426
+ const uniforms = this.material.uniforms;
427
+ if (this.shoreState) {
428
+ uniforms.uShoreStateMap.value = this.shoreState.texture;
429
+ uniforms.uUseShoreState.value = 1;
430
+ this.shoreState.getRegion(uniforms.uShoreStateRegion.value);
431
+ } else {
432
+ uniforms.uUseShoreState.value = 0;
433
+ }
434
+ for (const material of this.shoreStateMaterials ?? []) {
435
+ updateWaterShoreMaterial(material, { stateField: this.shoreState });
436
+ }
437
+ return this;
438
+ }
439
+
440
+ /** Settings currently uploaded after transient scene overrides. */
441
+ get renderedSettings() {
442
+ return this.material.userData.waterSettings;
443
+ }
444
+
445
+ /** Current effective transient overrides, separate from authored settings. */
446
+ get sceneOverrides() {
447
+ return Object.fromEntries(
448
+ Object.entries(this._sceneOverrides).map(([key, value]) => [key, cloneValue(value)]),
449
+ );
450
+ }
451
+
452
+ /** Ordered runtime layer metadata without exposing mutable layer values. */
453
+ get sceneOverrideLayers() {
454
+ return [...this._sceneOverrideLayers.values()]
455
+ .sort((a, b) => a.priority - b.priority || a.order - b.order)
456
+ .map((layer) => ({ id: layer.id, priority: layer.priority }));
457
+ }
458
+
459
+ _composeSceneSettings() {
460
+ let composed = createWaterSettings(this.settings);
461
+ const layers = [...this._sceneOverrideLayers.values()]
462
+ .sort((a, b) => a.priority - b.priority || a.order - b.order);
463
+ for (const layer of layers) {
464
+ const source = layer.resolve
465
+ ? layer.resolve(createWaterSettings(composed))
466
+ : layer.settings;
467
+ composed = createWaterSettings({
468
+ ...composed,
469
+ ...collectWaterSceneOverrides(source),
470
+ });
471
+ }
472
+ return composed;
473
+ }
474
+
475
+ _applyComposedSceneSettings() {
476
+ const composed = this._composeSceneSettings();
477
+ this._sceneOverrides = Object.fromEntries(
478
+ WATER_SCENE_OVERRIDE_KEYS
479
+ .filter((key) => JSON.stringify(composed[key]) !== JSON.stringify(this.settings[key]))
480
+ .map((key) => [key, cloneValue(composed[key])]),
481
+ );
482
+ applyWaterSettingsToMaterial(this.material, {
483
+ ...composed,
484
+ quality: this._authoredQualityRequest,
485
+ });
486
+ return this.renderedSettings;
129
487
  }
130
488
 
131
489
  // Merges option overrides into the current settings (pass { preset } to
132
- // switch presets while keeping explicit overrides you re-supply).
490
+ // switch presets while keeping explicit overrides you re-supply). This is
491
+ // the backward-compatible authored path; scene overrides stay separate.
133
492
  applySettings(options = {}) {
134
- applyWaterSettingsToMaterial(this.material, { ...this.settings, ...options });
135
- this.settings = this.material.userData.waterSettings;
493
+ const source = cleanObject(options);
494
+ this.settings = createWaterSettings({ ...this.settings, ...source });
495
+ if (source.quality !== undefined) {
496
+ this._authoredQualityRequest = source.quality && typeof source.quality === 'object'
497
+ ? { ...source.quality }
498
+ : this.settings.quality;
499
+ }
500
+ this._applyComposedSceneSettings();
136
501
  this.splashSystem?.applySettings(this.settings);
137
502
  this.syncSimulationParameters();
138
503
  return this.settings;
@@ -140,13 +505,76 @@ export class WaterSurface extends THREE.Mesh {
140
505
 
141
506
  // Loads a preset from scratch (unlike applySettings, prior overrides drop).
142
507
  setPreset(name, overrides = {}) {
143
- applyWaterSettingsToMaterial(this.material, { preset: name, ...overrides });
144
- this.settings = this.material.userData.waterSettings;
508
+ const source = cleanObject(overrides);
509
+ this.settings = createWaterSettings({ preset: name, ...source });
510
+ this._authoredQualityRequest = source.quality && typeof source.quality === 'object'
511
+ ? { ...source.quality }
512
+ : this.settings.quality;
513
+ this._applyComposedSceneSettings();
145
514
  this.splashSystem?.applySettings(this.settings);
146
515
  this.syncSimulationParameters();
147
516
  return this.settings;
148
517
  }
149
518
 
519
+ /**
520
+ * Adds or replaces one transient runtime owner. Static layers accept only
521
+ * {@link WATER_SCENE_OVERRIDE_KEYS}; a resolver receives the result of all
522
+ * lower-priority layers, enabling additive weather over an authored wave
523
+ * baseline without mutating {@link settings}.
524
+ */
525
+ setSceneOverrideLayer(id, optionsOrResolver = {}, {
526
+ priority = WATER_SCENE_OVERRIDE_PRIORITIES.scene,
527
+ replace = true,
528
+ } = {}) {
529
+ if ((typeof id !== 'string' || id.length === 0) && typeof id !== 'symbol') {
530
+ throw new TypeError('A water scene override layer needs a non-empty string or Symbol id.');
531
+ }
532
+ const existing = this._sceneOverrideLayers.get(id);
533
+ const resolve = typeof optionsOrResolver === 'function' ? optionsOrResolver : null;
534
+ let settings = null;
535
+ if (!resolve) {
536
+ const next = collectWaterSceneOverrides(optionsOrResolver);
537
+ settings = !replace && existing?.settings
538
+ ? { ...existing.settings, ...next }
539
+ : next;
540
+ }
541
+ this._sceneOverrideLayers.set(id, {
542
+ id,
543
+ order: existing?.order ?? this._sceneOverrideSequence++,
544
+ priority: Number.isFinite(Number(priority))
545
+ ? Number(priority)
546
+ : WATER_SCENE_OVERRIDE_PRIORITIES.scene,
547
+ resolve,
548
+ settings,
549
+ });
550
+ return this._applyComposedSceneSettings();
551
+ }
552
+
553
+ /** Removes one runtime owner without disturbing other active layers. */
554
+ clearSceneOverrideLayer(id) {
555
+ if (!this._sceneOverrideLayers.delete(id)) return this.renderedSettings;
556
+ return this._applyComposedSceneSettings();
557
+ }
558
+
559
+ /** Convenience scene layer for current wave/light state. */
560
+ setSceneOverrides(options = {}, { replace = false } = {}) {
561
+ return this.setSceneOverrideLayer('scene', options, {
562
+ priority: WATER_SCENE_OVERRIDE_PRIORITIES.scene,
563
+ replace,
564
+ });
565
+ }
566
+
567
+ /** Clears only the convenience scene layer, preserving independent owners. */
568
+ clearSceneOverrides() {
569
+ return this.clearSceneOverrideLayer('scene');
570
+ }
571
+
572
+ /** Explicitly clears every transient owner and restores the authored baseline. */
573
+ clearAllSceneOverrideLayers() {
574
+ this._sceneOverrideLayers.clear();
575
+ return this._applyComposedSceneSettings();
576
+ }
577
+
150
578
  setDebugMode(mode) {
151
579
  setWaterDebugMode(this.material, mode);
152
580
  return this;
@@ -189,6 +617,89 @@ export class WaterSurface extends THREE.Mesh {
189
617
  return this;
190
618
  }
191
619
 
620
+ // The material graph always supports the packed attribute, so a lab can
621
+ // opt a smooth beach in/out without recompiling WebGPU pipelines.
622
+ setNearshorePhase(nearshorePhase, { bake = true } = {}) {
623
+ const options = nearshorePhase === true ? {} : nearshorePhase;
624
+ this.nearshorePhaseEnabled = Boolean(this.shoalingEnabled && options);
625
+ this.nearshorePhaseOptions = this.nearshorePhaseEnabled ? { ...options } : null;
626
+ this.nearshorePhaseField = null;
627
+ this.nearshorePhaseReference = null;
628
+ this.nearshorePhaseBakedSignature = '';
629
+ this.nearshorePhaseStatus = {
630
+ active: false,
631
+ reason: this.nearshorePhaseEnabled ? 'not-baked' : 'disabled',
632
+ };
633
+ if (this.shoalingEnabled) {
634
+ this.writeNearshorePhaseAttribute(null);
635
+ if (bake && this.bedHeightSampler) this.bakeShoalingDepths();
636
+ }
637
+ return this;
638
+ }
639
+
640
+ // Replace the authored current atlas at runtime. Options objects become
641
+ // surface-owned fields; WaterCurrentField instances remain caller-owned.
642
+ setCurrentField(currentField, { disposePrevious = true } = {}) {
643
+ const options = currentField === true ? {} : currentField;
644
+ const nextOwned = Boolean(options && !options.isWaterCurrentField);
645
+ const next = options?.isWaterCurrentField
646
+ ? options
647
+ : options
648
+ ? new WaterCurrentField({
649
+ region: { centerX: 0, centerZ: 0, width: this.width, depth: this.depth },
650
+ ...options,
651
+ })
652
+ : null;
653
+ const previous = this.currentField;
654
+ const previousOwned = this.ownsCurrentField;
655
+ this.currentField = next;
656
+ this.ownsCurrentField = nextOwned;
657
+ this.shoreState?.setCurrentField(next);
658
+ if (disposePrevious && previousOwned && previous && previous !== next) previous.dispose();
659
+ return this;
660
+ }
661
+
662
+ // Authored large-scale current only. getFlowAt() below adds breakers and
663
+ // swash so boats/characters can choose either signal explicitly.
664
+ getCurrentAt(x, z, out = new THREE.Vector2()) {
665
+ if (!this.currentField) return out.set(0, 0);
666
+ return this.currentField.sampleAt(x, z, out);
667
+ }
668
+
669
+ // Replace static terrain under an existing shoaling surface without
670
+ // rebuilding its material, render passes, ripple simulation, or animation
671
+ // clock. Whether shoaling exists is a graph-build choice, so callers cannot
672
+ // toggle it after construction; they may freely swap one sampler for
673
+ // another. This is useful for labs/worlds with selectable ground profiles.
674
+ setBedHeightSampler(bedHeight, { bake = true } = {}) {
675
+ const sampler = typeof bedHeight === 'function' ? bedHeight : null;
676
+ if (Boolean(sampler) !== this.shoalingEnabled) {
677
+ throw new Error(
678
+ 'WaterSurface cannot toggle shoaling after construction; create a new surface instead.',
679
+ );
680
+ }
681
+ if (sampler === this.bedHeightSampler) return this;
682
+
683
+ this.bedHeightSampler = sampler;
684
+ this.shoreState?.setBedHeightSampler(sampler);
685
+ this.shoalingBakedX = NaN;
686
+ this.shoalingBakedY = NaN;
687
+ this.shoalingBakedZ = NaN;
688
+ this.nearshorePhaseBakedSignature = '';
689
+ this.breakerBuilt.x = NaN;
690
+ this.breakerBuilt.y = NaN;
691
+ this.breakerBuilt.z = NaN;
692
+ this.breakerBuilt.energy = NaN;
693
+ if (this.breakers) {
694
+ const staleBreakers = this.breakers;
695
+ this.remove(staleBreakers);
696
+ disposeAfterRenderBoundary(staleBreakers);
697
+ this.breakers = null;
698
+ }
699
+ if (sampler && bake) this.bakeShoalingDepths();
700
+ return this;
701
+ }
702
+
192
703
  get waveEnergy() {
193
704
  return this.material.userData.waterWaveEnergy ?? 0.3;
194
705
  }
@@ -218,7 +729,14 @@ export class WaterSurface extends THREE.Mesh {
218
729
  // Mirror of the vertex shader's shallow-water chop filter.
219
730
  const chopWeight = THREE.MathUtils.lerp(
220
731
  0.15, 1, THREE.MathUtils.smoothstep(restDepth, range * 0.3, range * 1.4));
221
- const raw = sampleGerstnerHeight(this.gerstnerWaves, x, z, this.time, chopWeight);
732
+ const raw = sampleGerstnerHeight(
733
+ this.gerstnerWaves,
734
+ x,
735
+ z,
736
+ this.time,
737
+ chopWeight,
738
+ this.sampleNearshoreAt(x, z, restDepth),
739
+ );
222
740
  const deepFactor = THREE.MathUtils.smoothstep(restDepth, 0, range);
223
741
  const rearUp = (1 - THREE.MathUtils.smoothstep(restDepth, range * 0.45, range * 1.5)) *
224
742
  THREE.MathUtils.smoothstep(restDepth, 0.05, 0.35);
@@ -229,11 +747,41 @@ export class WaterSurface extends THREE.Mesh {
229
747
  const brokenY = Math.max(
230
748
  Math.min(targetY, capY) + Math.max(targetY - capY, 0) * 0.1,
231
749
  -Math.max(restDepth, 0) + 0.04);
232
- const wavePhase = THREE.MathUtils.clamp(raw / energy, -1, 1);
233
- const runup = (settings.shorelineRunup ?? 0.6) * energy * Math.max(wavePhase, 0);
234
- const film = THREE.MathUtils.clamp(restDepth + runup, -0.2, 0.05);
235
- const filmY = (bed - surfaceY) + film;
236
- const beach = 1 - THREE.MathUtils.smoothstep(restDepth, -0.02, 0.06);
750
+ // Connected irregular swash mirror (keep in sync with water.js): event N
751
+ // begins at event N-1's rundown endpoint, reaches its own bounded peak,
752
+ // then drains to a new endpoint without resetting at the still-water line.
753
+ const explicit = (settings.runupDistance ?? 0) > 0.01;
754
+ const waves = this.gerstnerWaves;
755
+ const d = 0.5;
756
+ const gradX = (this.bedHeightSampler(x + d, z) - this.bedHeightSampler(x - d, z)) / (2 * d);
757
+ const gradZ = (this.bedHeightSampler(x, z + d) - this.bedHeightSampler(x, z - d)) / (2 * d);
758
+ const slope = Math.min(Math.hypot(gradX, gradZ), 1);
759
+ const safeSlope = Math.max(slope, 0.005);
760
+ const gradientLength = Math.hypot(gradX, gradZ) || 1;
761
+ const alongCoordinate = x * (gradZ / gradientLength) - z * (gradX / gradientLength);
762
+ const automaticDistance = (settings.shorelineRunup ?? 0.6) * energy / safeSlope;
763
+ const maximumDistance = explicit ? settings.runupDistance : automaticDistance;
764
+ const swashFrame = this.sampleSwashFrame(this.time, maximumDistance);
765
+ const swashProgress = swashFrame.progress;
766
+ const edgeDistance = explicit
767
+ ? swashFrame.edgeDistance
768
+ : maximumDistance * swashProgress;
769
+ const edgeOffset = sampleSwashEdgeOffset(
770
+ alongCoordinate,
771
+ this.time,
772
+ swashProgress,
773
+ swashFrame.primaryDirectionX ?? waves[0]?.dirX ?? settings.flowDirection?.[0] ?? 0,
774
+ swashFrame.cycle,
775
+ swashFrame.edgeShape,
776
+ );
777
+ const edgeHead = edgeDistance * safeSlope + edgeOffset * safeSlope;
778
+ const filmHead = restDepth + edgeHead;
779
+ const film = THREE.MathUtils.clamp(filmHead * 0.45 + 0.008, 0.003, 0.045);
780
+ const filmY = Math.min(
781
+ (bed - surfaceY) + film,
782
+ Math.max(0.5, maximumDistance * safeSlope * 1.1),
783
+ );
784
+ const beach = 1 - THREE.MathUtils.smoothstep(restDepth, 0.06, 0.22);
237
785
  const base = THREE.MathUtils.lerp(brokenY, filmY, beach);
238
786
 
239
787
  // Breakers are physical: blend the traveling shell face over the base
@@ -249,15 +797,68 @@ export class WaterSurface extends THREE.Mesh {
249
797
  return base;
250
798
  }
251
799
 
252
- // Horizontal water push velocity (m/s, world space) at (x, z) — currently
253
- // the shoreward surge of a passing breaker: strongest in the whitewater
254
- // just after the wave breaks, modest on the unbroken face. Zero elsewhere.
255
- // Feed it to floating bodies and swimmers so surf actually carries them.
800
+ // Horizontal water push velocity (m/s, world space). Breaker shells provide
801
+ // their traveling face flow; explicit swash adds uphill uprush and downhill
802
+ // backwash so gameplay objects move with the rendered shoreline.
256
803
  getFlowAt(x, z, out = new THREE.Vector2()) {
257
804
  out.set(0, 0);
805
+ if (this.currentField) {
806
+ this.currentField.sampleAt(x, z, currentSampleScratch);
807
+ out.add(currentSampleScratch);
808
+ }
258
809
  if (this.breakers) {
259
810
  const shell = this.breakers.sampleAt(x, z, breakerSampleScratch);
260
- out.set(shell.flowX, shell.flowZ);
811
+ out.x += shell.flowX;
812
+ out.y += shell.flowZ;
813
+ }
814
+ if (this.bedHeightSampler && (this.settings.runupDistance ?? 0) > 0.01) {
815
+ this.getWorldPosition(worldPositionScratch);
816
+ const surfaceY = worldPositionScratch.y;
817
+ const bed = this.bedHeightSampler(x, z);
818
+ const restDepth = surfaceY - bed;
819
+ const d = 0.5;
820
+ const gradX = (this.bedHeightSampler(x + d, z) - this.bedHeightSampler(x - d, z)) / (2 * d);
821
+ const gradZ = (this.bedHeightSampler(x, z + d) - this.bedHeightSampler(x, z - d)) / (2 * d);
822
+ const slope = Math.max(Math.min(Math.hypot(gradX, gradZ), 1), 0.005);
823
+ const gradientLength = Math.hypot(gradX, gradZ) || 1;
824
+ const alongCoordinate = x * (gradZ / gradientLength) - z * (gradX / gradientLength);
825
+ const swashFrame = this.sampleSwashFrame(this.time, this.settings.runupDistance);
826
+ const progress = swashFrame.progress;
827
+ const offset = sampleSwashEdgeOffset(
828
+ alongCoordinate,
829
+ this.time,
830
+ progress,
831
+ swashFrame.primaryDirectionX ?? this.gerstnerWaves[0]?.dirX ?? 0,
832
+ swashFrame.cycle,
833
+ swashFrame.edgeShape,
834
+ );
835
+ const maximumDistance = this.settings.runupDistance;
836
+ const edgeDistance = swashFrame.edgeDistance;
837
+ const head = edgeDistance * slope + offset * slope;
838
+ const filmHead = restDepth + head;
839
+ const swashZone = 1 - THREE.MathUtils.smoothstep(restDepth, 0.06, 0.22);
840
+ const wet = THREE.MathUtils.smoothstep(filmHead, -0.006, 0.025);
841
+ if (swashZone > 0.001 && wet > 0.001) {
842
+ const dt = 0.03;
843
+ const totalEdgeAt = (sampleTime) => {
844
+ const frame = this.sampleSwashFrame(sampleTime, maximumDistance);
845
+ return frame.edgeDistance + sampleSwashEdgeOffset(
846
+ alongCoordinate,
847
+ sampleTime,
848
+ frame.progress,
849
+ frame.primaryDirectionX ?? this.gerstnerWaves[0]?.dirX ?? 0,
850
+ frame.cycle,
851
+ frame.edgeShape,
852
+ );
853
+ };
854
+ const before = totalEdgeAt(this.time - dt);
855
+ const after = totalEdgeAt(this.time + dt);
856
+ const edgeSpeed = THREE.MathUtils.clamp(
857
+ (after - before) / (2 * dt), -3.5, 3.5,
858
+ );
859
+ out.x += (gradX / gradientLength) * edgeSpeed * swashZone * wet;
860
+ out.y += (gradZ / gradientLength) * edgeSpeed * swashZone * wet;
861
+ }
261
862
  }
262
863
  return out;
263
864
  }
@@ -271,7 +872,7 @@ export class WaterSurface extends THREE.Mesh {
271
872
  const range = Math.max(
272
873
  this.settings.shoalingDepth ?? 1.4, Math.max(this.waveEnergy, 1e-3) * 2.2, 1e-3);
273
874
  const deepFactor = THREE.MathUtils.smoothstep(restDepth, 0, range);
274
- const beach = 1 - THREE.MathUtils.smoothstep(restDepth, -0.02, 0.06);
875
+ const beach = 1 - THREE.MathUtils.smoothstep(restDepth, 0.06, 0.22);
275
876
  return THREE.MathUtils.lerp(this.settings.shorelineWaves ?? 0.35, 1, deepFactor) *
276
877
  (1 - beach);
277
878
  }
@@ -282,23 +883,129 @@ export class WaterSurface extends THREE.Mesh {
282
883
  bakeShoalingDepths() {
283
884
  if (!this.bedHeightSampler) return this;
284
885
  this.updateWorldMatrix(true, false);
886
+ this.getWorldPosition(worldPositionScratch);
887
+ const surfaceX = worldPositionScratch.x;
888
+ const surfaceY = worldPositionScratch.y;
889
+ const surfaceZ = worldPositionScratch.z;
285
890
  const positionAttr = this.geometry.attributes.position;
286
891
  const existing = this.geometry.attributes.aBedHeight;
892
+ const existingSlope = this.geometry.attributes.aBedSlope;
893
+ const existingGradient = this.geometry.attributes.aBedGradient;
287
894
  const bedHeights = existing?.count === positionAttr.count
288
895
  ? existing.array
289
896
  : new Float32Array(positionAttr.count);
897
+ const bedSlopes = existingSlope?.count === positionAttr.count
898
+ ? existingSlope.array
899
+ : new Float32Array(positionAttr.count);
900
+ const bedGradients = existingGradient?.count === positionAttr.count
901
+ ? existingGradient.array
902
+ : new Float32Array(positionAttr.count * 2);
903
+ const useDedicatedBreakers = shouldUseDedicatedBreakerShell(
904
+ this.settings,
905
+ Boolean(this.bedHeightSampler),
906
+ );
907
+ const solveNearshore = this.nearshorePhaseEnabled && !useDedicatedBreakers;
908
+ const restDepths = solveNearshore ? new Float32Array(positionAttr.count) : null;
909
+ const d = 0.5;
290
910
  for (let i = 0; i < positionAttr.count; i += 1) {
291
911
  localScratch.fromBufferAttribute(positionAttr, i).applyMatrix4(this.matrixWorld);
292
- bedHeights[i] = this.bedHeightSampler(localScratch.x, localScratch.z);
912
+ const x = localScratch.x;
913
+ const z = localScratch.z;
914
+ bedHeights[i] = this.bedHeightSampler(x, z);
915
+ if (restDepths) restDepths[i] = surfaceY - bedHeights[i];
916
+ // Local bed gradient magnitude — converts horizontal run-up meters
917
+ // (runupDistance) into the vertical swash reach at this vertex.
918
+ const gradX = (this.bedHeightSampler(x + d, z) - this.bedHeightSampler(x - d, z)) / (2 * d);
919
+ const gradZ = (this.bedHeightSampler(x, z + d) - this.bedHeightSampler(x, z - d)) / (2 * d);
920
+ bedSlopes[i] = Math.min(Math.hypot(gradX, gradZ), 1);
921
+ bedGradients[i * 2] = gradX;
922
+ bedGradients[i * 2 + 1] = gradZ;
293
923
  }
294
924
  if (existing?.array === bedHeights) {
295
925
  existing.needsUpdate = true;
296
926
  } else {
297
927
  this.geometry.setAttribute('aBedHeight', new THREE.BufferAttribute(bedHeights, 1));
298
928
  }
299
- this.getWorldPosition(worldPositionScratch);
300
- this.shoalingBakedX = worldPositionScratch.x;
301
- this.shoalingBakedZ = worldPositionScratch.z;
929
+ if (existingSlope?.array === bedSlopes) {
930
+ existingSlope.needsUpdate = true;
931
+ } else {
932
+ this.geometry.setAttribute('aBedSlope', new THREE.BufferAttribute(bedSlopes, 1));
933
+ }
934
+ if (existingGradient?.array === bedGradients) {
935
+ existingGradient.needsUpdate = true;
936
+ } else {
937
+ this.geometry.setAttribute('aBedGradient', new THREE.BufferAttribute(bedGradients, 2));
938
+ }
939
+
940
+ let phaseField = null;
941
+ let phaseStatus;
942
+ if (!this.nearshorePhaseEnabled) {
943
+ phaseStatus = { active: false, reason: 'disabled' };
944
+ } else if (useDedicatedBreakers) {
945
+ // The separate overhanging shell still evaluates its original plane
946
+ // phase. Until it can consume the same atlas, retain exact coherence by
947
+ // falling the heightfield back rather than showing two crest clocks.
948
+ phaseStatus = { active: false, reason: 'dedicated-breaker-shell' };
949
+ } else if (
950
+ (this.segmentsX + 1) * (this.segmentsZ + 1) !== positionAttr.count
951
+ ) {
952
+ phaseStatus = { active: false, reason: 'unexpected-geometry-grid' };
953
+ } else {
954
+ const frame = this.dominantNearshoreFrame();
955
+ const options = this.nearshorePhaseOptions ?? {};
956
+ try {
957
+ const candidate = buildNearshorePhaseField({
958
+ restDepths,
959
+ columns: this.segmentsX + 1,
960
+ rows: this.segmentsZ + 1,
961
+ originX: surfaceX - this.width * 0.5,
962
+ originZ: surfaceZ - this.depth * 0.5,
963
+ stepX: this.width / this.segmentsX,
964
+ stepZ: this.depth / this.segmentsZ,
965
+ directionX: frame.directionX,
966
+ directionZ: frame.directionZ,
967
+ deepWaveNumber: frame.deepWaveNumber,
968
+ incidentAxis: options.incidentAxis,
969
+ minDepth: options.minDepth ?? 0.05,
970
+ maxWavenumberRatio: options.maxWavenumberRatio ?? 4,
971
+ });
972
+ // A one-way eikonal solve cannot represent turning rays, diffraction,
973
+ // or shadow zones. Invalidity propagates downstream in the builder;
974
+ // rejecting the complete atlas avoids a phase seam where a fallback
975
+ // cell would otherwise meet a refracted cell.
976
+ if (candidate.invalidCount === 0) {
977
+ candidate.slotMask = frame.slotMask;
978
+ phaseField = candidate;
979
+ phaseStatus = {
980
+ active: true,
981
+ reason: 'active',
982
+ incidentAxis: candidate.incidentAxis,
983
+ invalidFraction: 0,
984
+ slotMask: candidate.slotMask,
985
+ };
986
+ } else {
987
+ phaseStatus = {
988
+ active: false,
989
+ reason: 'invalid-mild-slope-field',
990
+ incidentAxis: candidate.incidentAxis,
991
+ invalidFraction: candidate.invalidFraction,
992
+ };
993
+ }
994
+ } catch (error) {
995
+ phaseStatus = {
996
+ active: false,
997
+ reason: 'phase-bake-error',
998
+ error: error instanceof Error ? error.message : String(error),
999
+ };
1000
+ }
1001
+ }
1002
+ this.nearshorePhaseField = phaseField;
1003
+ this.nearshorePhaseStatus = phaseStatus;
1004
+ this.writeNearshorePhaseAttribute(phaseField);
1005
+ this.nearshorePhaseBakedSignature = this.nearshorePhaseSignature();
1006
+ this.shoalingBakedX = surfaceX;
1007
+ this.shoalingBakedY = surfaceY;
1008
+ this.shoalingBakedZ = surfaceZ;
302
1009
  return this;
303
1010
  }
304
1011
 
@@ -306,8 +1013,7 @@ export class WaterSurface extends THREE.Mesh {
306
1013
  // breakerAmount setting and rebuilds the break line whenever the surface
307
1014
  // moves or the swell energy shifts enough to relocate the collapse depth.
308
1015
  updateBreakers() {
309
- const enabled = this.settings.breakerEnabled !== false &&
310
- this.bedHeightSampler && (this.settings.breakerAmount ?? 0) > 0.001;
1016
+ const enabled = shouldUseDedicatedBreakerShell(this.settings, Boolean(this.bedHeightSampler));
311
1017
  if (!enabled) {
312
1018
  if (this.breakers) {
313
1019
  this.remove(this.breakers);
@@ -422,8 +1128,18 @@ export class WaterSurface extends THREE.Mesh {
422
1128
  if (this.bedHeightSampler) {
423
1129
  this.getWorldPosition(worldPositionScratch);
424
1130
  const movedX = Math.abs(worldPositionScratch.x - this.shoalingBakedX);
1131
+ const phaseUsesDepth = this.nearshorePhaseEnabled && !shouldUseDedicatedBreakerShell(
1132
+ this.settings,
1133
+ Boolean(this.bedHeightSampler),
1134
+ );
1135
+ const movedY = phaseUsesDepth
1136
+ ? Math.abs(worldPositionScratch.y - this.shoalingBakedY)
1137
+ : 0;
425
1138
  const movedZ = Math.abs(worldPositionScratch.z - this.shoalingBakedZ);
426
- if (!(movedX <= 0.05 && movedZ <= 0.05)) this.bakeShoalingDepths();
1139
+ const phaseChanged = this.nearshorePhaseBakedSignature !== this.nearshorePhaseSignature();
1140
+ if (!(movedX <= 0.05 && movedY <= 0.02 && movedZ <= 0.05) || phaseChanged) {
1141
+ this.bakeShoalingDepths();
1142
+ }
427
1143
  }
428
1144
  this.updateBreakers();
429
1145
 
@@ -460,7 +1176,37 @@ export class WaterSurface extends THREE.Mesh {
460
1176
  }
461
1177
 
462
1178
  const uniforms = this.material.uniforms;
1179
+ const swashFrame = this.sampleSwashFrame(this.time, this.settings.runupDistance);
1180
+ uniforms.uSwashCycle.value = swashFrame.cycle;
1181
+ uniforms.uSwashProgress.value = swashFrame.progress;
1182
+ uniforms.uSwashIncidenceX.value = swashFrame.primaryDirectionX;
1183
+ uniforms.uSwashEdgeShape.value.set(
1184
+ swashFrame.edgeShape.phase,
1185
+ swashFrame.edgeShape.frequency,
1186
+ swashFrame.edgeShape.amplitude,
1187
+ );
1188
+ uniforms.uSwashRunupScale.value = (this.settings.runupDistance ?? 0) > 0.01
1189
+ ? swashFrame.runupScale
1190
+ : 1;
1191
+ uniforms.uSwashStartOffset.value = (this.settings.runupDistance ?? 0) > 0.01
1192
+ ? swashFrame.startOffset
1193
+ : 0;
1194
+ uniforms.uSwashEndOffset.value = (this.settings.runupDistance ?? 0) > 0.01
1195
+ ? swashFrame.endOffset
1196
+ : 0;
463
1197
  uniforms.uTime.value = this.time;
1198
+ if (this.shoreState) {
1199
+ this.getWorldPosition(worldPositionScratch);
1200
+ this.shoreState.update(renderer, {
1201
+ time: this.time,
1202
+ waterLevel: worldPositionScratch.y,
1203
+ waves: this.gerstnerWaves,
1204
+ settings: this.settings,
1205
+ waveEnergy: this.waveEnergy,
1206
+ swashFrame,
1207
+ }, clampedDelta);
1208
+ this.bindShoreState();
1209
+ }
464
1210
  // Mirror scene.fog so distant water hazes with the rest of the world
465
1211
  // (the material handles fog manually — see uSceneFog* in the shader;
466
1212
  // setDistanceFog adds the environment-matching exponential layer).
@@ -473,8 +1219,28 @@ export class WaterSurface extends THREE.Mesh {
473
1219
  }
474
1220
  updateWaterMaterialCamera(this.material, renderer, camera);
475
1221
  this.getWorldPosition(worldPositionScratch);
476
- uniforms.uCameraBelow.value =
477
- camera.getWorldPosition(followScratch).y < worldPositionScratch.y ? 1 : 0;
1222
+ const cameraBelow = camera.getWorldPosition(followScratch).y < worldPositionScratch.y;
1223
+ uniforms.uCameraBelow.value = cameraBelow ? 1 : 0;
1224
+ const renderedSettings = this.renderedSettings ?? this.settings;
1225
+ updateProjectedWaterCaustics({
1226
+ enabled: cameraBelow && renderedSettings.causticsStrength > 0.001,
1227
+ time: this.time,
1228
+ waterLevel: worldPositionScratch.y,
1229
+ centerX: worldPositionScratch.x,
1230
+ centerZ: worldPositionScratch.z,
1231
+ halfWidth: this.width * 0.5,
1232
+ halfDepth: this.depth * 0.5,
1233
+ color: renderedSettings.sunColor,
1234
+ intensity: renderedSettings.causticsStrength * 0.65,
1235
+ scale: renderedSettings.causticsScale,
1236
+ speed: renderedSettings.causticsSpeed,
1237
+ flowDirection: renderedSettings.flowDirection,
1238
+ waveDistortion: 0.035 + renderedSettings.detailNormalStrength * 0.08,
1239
+ depthAttenuation: 1 / Math.max(
1240
+ renderedSettings.depthFadeDistance + renderedSettings.deepFadeDistance,
1241
+ 0.25,
1242
+ ),
1243
+ });
478
1244
  if (this.ripples) {
479
1245
  uniforms.uRippleMap.value = this.ripples.texture;
480
1246
  uniforms.uUseRippleMap.value = 1;
@@ -495,6 +1261,9 @@ export class WaterSurface extends THREE.Mesh {
495
1261
  this.geometry.dispose();
496
1262
  this.material.dispose();
497
1263
  this.ripples?.dispose();
1264
+ this.shoreState?.dispose();
1265
+ if (this.ownsCurrentField) this.currentField?.dispose();
1266
+ this.shoreStateMaterials.clear();
498
1267
  this.splashSystem?.dispose();
499
1268
  this.breakers?.dispose();
500
1269
  this.passes?.dispose();