@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
@@ -45,9 +45,12 @@ import {
45
45
  mix,
46
46
  modelWorldMatrix,
47
47
  normalize,
48
+ pow,
48
49
  positionGeometry,
50
+ refract,
49
51
  screenUV,
50
52
  select,
53
+ sin,
51
54
  smoothstep,
52
55
  texture,
53
56
  uniform,
@@ -61,11 +64,16 @@ import { NodeMaterial } from 'three/webgpu';
61
64
 
62
65
  import { sampleEnvironmentSunShadow } from './chunks/environment-sun-shadow.js';
63
66
  import { stylizedCloudShadow } from './chunks/stylized-cloud-shadow.js';
64
- import { waterCombineNormal, waterFbm, waterRotate2d } from './chunks/water-common.js';
67
+ import {
68
+ waterCombineNormal,
69
+ waterFbm,
70
+ waterRotate2d,
71
+ } from './chunks/water-common.js';
65
72
  import { createWaterColorChunk } from './chunks/water-color.js';
66
73
  import { createWaterFoamChunk } from './chunks/water-foam.js';
67
74
  import { createWaterLightingChunk } from './chunks/water-lighting.js';
68
75
  import { createWaterRippleChunk } from './chunks/water-ripple.js';
76
+ import { createWaterShoreStateChunk } from './chunks/water-shore-state.js';
69
77
  import { createWaterWavesChunk } from './chunks/water-waves.js';
70
78
 
71
79
  // Node-backend placeholders: same roles as waterMaterial.js's placeholders,
@@ -86,6 +94,7 @@ function getPlaceholders() {
86
94
  reflection: createLinearPlaceholder([126, 190, 218, 255]),
87
95
  ripple: createLinearPlaceholder([128, 128, 128, 255]),
88
96
  sceneColor: createLinearPlaceholder([80, 150, 190, 255]),
97
+ shoreState: createLinearPlaceholder([0, 0, 0, 0]),
89
98
  };
90
99
  }
91
100
  return placeholders;
@@ -179,9 +188,26 @@ export function createWaterNodeMaterial({
179
188
  uRippleTexel: uniform(new THREE.Vector2(1 / 256, 1 / 256)),
180
189
  uRippleHeightScale: uniform(1),
181
190
 
191
+ uShoreStateMap: texture(holders.shoreState),
192
+ uUseShoreState: uniform(0),
193
+ uShoreStateRegion: uniform(new THREE.Vector4(0, 0, 1, 1)),
194
+
182
195
  uShoalingDepth: uniform(1.4),
183
196
  uShorelineWaves: uniform(0.35),
184
197
  uShorelineRunup: uniform(0.6),
198
+ uRunupDistance: uniform(0),
199
+ // Runtime event envelope supplied by WaterSurface. Keeping the irregular
200
+ // sequence on the CPU avoids expanding the already-dense WebGPU graph.
201
+ uSwashRunupScale: uniform(1),
202
+ uSwashStartOffset: uniform(0),
203
+ uSwashEndOffset: uniform(0),
204
+ uSwashCycle: uniform(0),
205
+ uSwashProgress: uniform(0),
206
+ uSwashIncidenceX: uniform(0),
207
+ // (phase radians, alongshore frequency rad/m, amplitude metres).
208
+ uSwashEdgeShape: uniform(new THREE.Vector3(0, 0.1, 0)),
209
+ uBreakerEnabled: uniform(1),
210
+ uBreakerAmount: uniform(0.5),
185
211
  uWaveEnergy: uniform(0.3),
186
212
 
187
213
  uWavesA: waterArrayUniformEntry(wavesANode),
@@ -199,12 +225,16 @@ export function createWaterNodeMaterial({
199
225
  uDeepFadeDistance: uniform(3),
200
226
  uOpacity: uniform(0.9),
201
227
  uRefractionStrength: uniform(0.6),
228
+ uIndexOfRefraction: uniform(1.333),
229
+ uUnderwaterTransmission: uniform(1.0),
230
+ uUnderwaterTintStrength: uniform(0.35),
202
231
  uCausticsStrength: uniform(0.6),
203
232
  uCausticsScale: uniform(1),
204
233
  uCausticsSpeed: uniform(1),
205
234
 
206
235
  uFoamColor: uniform(new THREE.Color()),
207
236
  uFoamAmount: uniform(1),
237
+ uSwashFoamAmount: uniform(1.15),
208
238
  uFoamContactDistance: uniform(1),
209
239
  uFoamLineSpacing: uniform(1.4),
210
240
  uFoamNoiseScale: uniform(0.6),
@@ -240,6 +270,7 @@ export function createWaterNodeMaterial({
240
270
 
241
271
  const waves = createWaterWavesChunk({ wavesA: wavesANode, wavesB: wavesBNode, waveCount });
242
272
  const ripple = createWaterRippleChunk({ u });
273
+ const shoreState = createWaterShoreStateChunk({ u });
243
274
  const color = createWaterColorChunk({ u, flags });
244
275
  const foam = createWaterFoamChunk({ u, foamOctaves });
245
276
  const lighting = createWaterLightingChunk({ u });
@@ -257,10 +288,17 @@ export function createWaterNodeMaterial({
257
288
  const vWorldPosition = varying(vec3(), 'vWaterWorldPosition');
258
289
  const vRestWorldXZ = varying(vec2(), 'vWaterRestWorldXZ');
259
290
  const vCrest = varying(float(), 'vWaterCrest');
291
+ const vGerstnerNormal = varying(vec3(), 'vWaterGerstnerNormal');
260
292
  const vShoal = varying(float(), 'vWaterShoal');
261
293
  const vBreaker = varying(float(), 'vWaterBreaker');
262
- const vSwash = varying(float(), 'vWaterSwash');
263
- const vChop = varying(float(), 'vWaterChop');
294
+ // The connected wet/dry geometry is evaluated per vertex, but foam noise
295
+ // is evaluated per fragment. Passing only these smooth physical inputs
296
+ // avoids exposing the water grid as large triangular white patches.
297
+ const vSwashFoamData = varying(vec2(), 'vWaterSwashFoamData');
298
+ // Small shore-normal foam drift. This never displaces geometry: amplifying
299
+ // Gerstner's horizontal orbit folded the near-shore grid into white wedges.
300
+ const vSwashSurge = varying(vec2(), 'vWaterSwashSurge');
301
+ const vSwashZone = varying(float(), 'vWaterSwashZone');
264
302
  // Rest-pose column depth (waterline − bed). Deeply negative = the surface
265
303
  // is under dry land; the fragment shader discards those fragments so
266
304
  // triangles spanning a cliff face can't poke through the terrain.
@@ -277,9 +315,9 @@ export function createWaterNodeMaterial({
277
315
  const shoal = float(1.0).toVar();
278
316
  const rippleShoal = float(1.0).toVar();
279
317
  const breakFoam = float(0.0).toVar();
280
- const swash = float(0.0).toVar();
281
318
 
282
319
  let rawWave;
320
+ let nearshore = null;
283
321
  const waveDisplacement = vec3(0.0).toVar();
284
322
  if (flags.shoaling) {
285
323
  const aBedHeight = attribute('aBedHeight', 'float');
@@ -289,7 +327,30 @@ export function createWaterNodeMaterial({
289
327
  const shoalRange = max(max(u.uShoalingDepth, u.uWaveEnergy.mul(2.2)), 1e-3).toVar();
290
328
  chopWeight.assign(mix(0.15, 1.0, smoothstep(shoalRange.mul(0.3), shoalRange.mul(1.4), restDepth)));
291
329
 
292
- rawWave = waves.gerstnerDisplacementFiltered(restXZ, u.uTime, chopWeight).toVar();
330
+ // Packed CPU-baked mild-slope field: q, dq/dx, dq/dz, slot mask. It is fully
331
+ // active through the breaker/swash handoff, fades back to the original
332
+ // plane phase over a broad deep-water band, and only affects the two
333
+ // dominant swell slots. The attribute is a plane-wave fallback on
334
+ // stages where the nearshore solve is disabled or invalid.
335
+ const aNearshorePhase = attribute('aNearshorePhase', 'vec4').toVar();
336
+ const nearshoreBlend = smoothstep(
337
+ shoalRange.mul(0.8),
338
+ shoalRange.mul(1.8),
339
+ restDepth,
340
+ ).oneMinus().toVar();
341
+ nearshore = {
342
+ phaseCoordinate: aNearshorePhase.x,
343
+ waveVector: aNearshorePhase.yz,
344
+ blend: nearshoreBlend,
345
+ slotMask: aNearshorePhase.w,
346
+ };
347
+
348
+ rawWave = waves.gerstnerDisplacementFiltered(
349
+ restXZ,
350
+ u.uTime,
351
+ chopWeight,
352
+ nearshore,
353
+ ).toVar();
293
354
 
294
355
  // Stylized breaking-wave shoaling (see water.vert.glsl for the model).
295
356
  const deepFactor = smoothstep(0.0, shoalRange, restDepth);
@@ -305,12 +366,99 @@ export function createWaterNodeMaterial({
305
366
  const brokenY = min(targetY, capY).add(capExcess.mul(0.1)).toVar();
306
367
  // A trough can never dip below the seabed.
307
368
  brokenY.assign(max(brokenY, max(restDepth, 0.0).negate().add(0.04)));
308
- breakFoam.assign(clamp(capExcess.mul(0.9).div(max(u.uWaveEnergy.mul(0.3), 1e-3)), 0.0, 1.0));
369
+ breakFoam.assign(
370
+ clamp(capExcess.mul(0.9).div(max(u.uWaveEnergy.mul(0.3), 1e-3)), 0.0, 1.0)
371
+ .mul(u.uBreakerAmount)
372
+ .mul(u.uBreakerEnabled),
373
+ );
309
374
 
310
- const wavePhase = clamp(rawWave.y.div(max(u.uWaveEnergy, 1e-3)), -1.0, 1.0).toVar();
311
- const runup = u.uShorelineRunup.mul(u.uWaveEnergy).mul(max(wavePhase, 0.0));
312
- // Dry land tucks the film 20cm under the terrain.
313
- const film = clamp(restDepth.add(runup), -0.2, 0.05).toVar();
375
+ // The surf hands into one connected swash event. The dominant crest's
376
+ // shoreline arrival starts a quick uprush; gravity then owns the longer
377
+ // drain. Offshore Gerstner phase still sets the visible incidence angle,
378
+ // while the wet/dry edge remains one inequality instead of many islands.
379
+ const aBedSlope = attribute('aBedSlope', 'float');
380
+ const aBedGradient = attribute('aBedGradient', 'vec2').toVar();
381
+ const bedSlope = max(aBedSlope, 0.005).toVar();
382
+ const shoreDirection = aBedGradient.div(max(length(aBedGradient), 0.005)).toVar();
383
+ const alongDirection = vec2(shoreDirection.y, shoreDirection.x.negate()).toVar();
384
+ const alongCoordinate = dot(restXZ, alongDirection).toVar();
385
+ // WaterSurface computes this frame once on the CPU and shares it with
386
+ // the persistent foam/wetness pass. That keeps the visible lip, foam
387
+ // injection, and wet-sand history on the exact same event and removes a
388
+ // duplicate phase/easing stack from this already-large shader.
389
+ const swashCycle = u.uSwashCycle.toVar();
390
+ const swashProgress = u.uSwashProgress.toVar();
391
+
392
+ // The user setting is the maximum horizontal reach. Runtime event
393
+ // uniforms vary ordinary peaks over 80–100%, carry the previous rundown
394
+ // endpoint into the next uprush, and let each backwash stop at a slightly
395
+ // different point around the still-water shoreline.
396
+ const explicitRunup = u.uRunupDistance.greaterThan(0.01);
397
+ const maximumHorizontalReach = select(
398
+ explicitRunup,
399
+ u.uRunupDistance,
400
+ u.uShorelineRunup.mul(u.uWaveEnergy).div(bedSlope),
401
+ ).toVar();
402
+ const runupDistance = maximumHorizontalReach
403
+ .mul(select(explicitRunup, u.uSwashRunupScale, 1.0)).toVar();
404
+ const startOffset = select(explicitRunup, u.uSwashStartOffset, 0.0).toVar();
405
+ const endOffset = select(explicitRunup, u.uSwashEndOffset, 0.0).toVar();
406
+ const edgeDistance = select(
407
+ swashCycle.lessThan(0.34),
408
+ mix(startOffset, runupDistance, swashProgress),
409
+ mix(endOffset, runupDistance, swashProgress),
410
+ ).toVar();
411
+ const maximumSwashReach = maximumHorizontalReach.mul(bedSlope).toVar();
412
+ // Match the incoming oblique crest instead of marching the whole beach
413
+ // forward like a ruler. A broad tilt supplies the incidence angle; two
414
+ // small traveling scallops break up the lip without splitting it.
415
+ // Retain a modest angle even at nominal rest/full reach: an oblique
416
+ // crest reaches one end of a real beach first. Forcing every X column
417
+ // onto the same endpoint created the conspicuous horizontal ruler line.
418
+ const edgeEnvelope = mix(0.18, 1.0, sin(swashProgress.mul(Math.PI))).toVar();
419
+ const incidenceSlope = clamp(u.uSwashIncidenceX.mul(0.52), -0.2, 0.2).toVar();
420
+ const rawEdgeTilt = alongCoordinate.mul(incidenceSlope).negate().toVar();
421
+ // Bounded soft-sign: preserves the local crest angle without ever
422
+ // reaching a flat clamp. The former unbounded x*slope term reached
423
+ // ±22 m across this lab and pinned whole shoreline blocks to one cap.
424
+ const edgeTilt = rawEdgeTilt.div(abs(rawEdgeTilt).div(2.0).add(1.0)).toVar();
425
+ const edgeScallop = sin(alongCoordinate.mul(0.32).sub(u.uTime.mul(0.35)))
426
+ .sub(sin(u.uTime.mul(-0.35)))
427
+ .add(
428
+ sin(alongCoordinate.mul(0.91).add(u.uTime.mul(0.18)))
429
+ .sub(sin(u.uTime.mul(0.18)))
430
+ .mul(0.35),
431
+ )
432
+ .mul(0.4);
433
+ // Broad per-event tongues make the actual water silhouette irregular,
434
+ // not merely the foam painted behind it. The squared cycle envelope is
435
+ // zero with zero velocity at event boundaries, so shape seeds may vary
436
+ // each wave without a shoreline pop. Subtracting the x=0 samples keeps
437
+ // the calibrated 8-10 m centerline reach unchanged.
438
+ const macroWave = sin(swashCycle.mul(Math.PI)).toVar();
439
+ const secondaryPhase = u.uSwashEdgeShape.x.mul(-0.71).toVar();
440
+ const edgeMacro = sin(
441
+ alongCoordinate.mul(u.uSwashEdgeShape.y).add(u.uSwashEdgeShape.x),
442
+ ).sub(sin(u.uSwashEdgeShape.x))
443
+ .add(
444
+ sin(
445
+ alongCoordinate.mul(u.uSwashEdgeShape.y).mul(2.35).add(secondaryPhase),
446
+ ).sub(sin(secondaryPhase)).mul(0.42),
447
+ )
448
+ .mul(u.uSwashEdgeShape.z)
449
+ .mul(macroWave.mul(macroWave))
450
+ .toVar();
451
+ const edgeHead = edgeDistance.mul(bedSlope)
452
+ .add(
453
+ edgeTilt.add(edgeScallop).mul(edgeEnvelope)
454
+ .add(edgeMacro)
455
+ .mul(bedSlope),
456
+ ).toVar();
457
+ const filmHead = restDepth.add(edgeHead).toVar();
458
+ // Behind the edge the sheet is centimetres deep, not a second full-body
459
+ // water plane. Dry vertices remain conformal just above the bed; the
460
+ // fragment-space signed head owns visibility, avoiding lifting rows.
461
+ const film = clamp(filmHead.mul(0.45).add(0.008), 0.003, 0.045).toVar();
314
462
  // Cap the drape: the film follows the bed up the beach, but a steep
315
463
  // bank (terraced shore, cliff base) puts a whole cliff step inside one
316
464
  // mesh cell — the dry vertex would ride 10m+ up the slope and drag
@@ -318,18 +466,37 @@ export function createWaterNodeMaterial({
318
466
  // wedges at distant shorelines). Real swash never climbs above ~½m
319
467
  // over the rest waterline, so clamp the geometry there and let the
320
468
  // vRestDepth fragment discard own the rest.
321
- const filmY = min(aBedHeight.sub(worldPosition.y).add(film), 0.5);
322
- const beach = smoothstep(-0.02, 0.06, restDepth).oneMinus().toVar();
323
-
324
- rippleShoal.assign(smoothstep(0.0, 0.12, restDepth));
325
- swash.assign(beach.mul(smoothstep(0.004, 0.02, film)).mul(wavePhase.mul(0.45).add(0.55)));
469
+ const filmY = min(
470
+ aBedHeight.sub(worldPosition.y).add(film),
471
+ max(0.5, maximumSwashReach.mul(1.1)),
472
+ );
473
+ // Hand off over the last ~4 m of the 1:20 profile. Deeper water stays
474
+ // the Gerstner surface, preventing the visible "three stacked systems"
475
+ // boundary that a reach-sized blend band created.
476
+ const beach = smoothstep(0.06, 0.22, restDepth).oneMinus().toVar();
477
+ vSwashZone.assign(beach);
478
+
479
+ rippleShoal.assign(smoothstep(0.0, 0.18, restDepth));
480
+ const foamWidth = max(bedSlope.mul(0.68), 0.018).toVar();
481
+ vSwashFoamData.assign(vec2(filmHead, foamWidth));
482
+ breakFoam.mulAssign(beach.oneMinus());
326
483
  shoal.mulAssign(beach.oneMinus());
484
+ // Foam moves metres with the sheet, not a token 24 cm texture wobble.
485
+ // Continuous alongshore drift plus the non-resetting animation clock
486
+ // prevents successive drains retracing the exact same texture path.
487
+ const surgeDistance = min(runupDistance.mul(0.32), 3.2).toVar();
488
+ const swashDrift = shoreDirection.mul(swashProgress).mul(surgeDistance)
489
+ .add(alongDirection.mul(sin(u.uTime.mul(0.37))).mul(swashProgress).mul(0.55));
490
+ vSwashSurge.assign(swashDrift.mul(beach));
327
491
  waveDisplacement.assign(vec3(
328
492
  rawWave.x.mul(shoal),
329
493
  mix(brokenY, filmY, beach),
330
494
  rawWave.z.mul(shoal),
331
495
  ));
332
496
  } else {
497
+ vSwashSurge.assign(vec2(0.0));
498
+ vSwashZone.assign(float(0.0));
499
+ vSwashFoamData.assign(vec2(-1.0, 0.018));
333
500
  vRestDepth.assign(float(1000.0));
334
501
  rawWave = waves.gerstnerDisplacementFiltered(restXZ, u.uTime, chopWeight).toVar();
335
502
  waveDisplacement.assign(rawWave);
@@ -341,12 +508,21 @@ export function createWaterNodeMaterial({
341
508
  worldPosition.xyz.addAssign(waveDisplacement);
342
509
  worldPosition.y.addAssign(rippleHeight);
343
510
 
344
- const { crest } = waves.gerstnerNormalFiltered(restXZ, u.uTime, chopWeight);
345
- vCrest.assign(crest.mul(clamp(shoal, 0.0, 1.0)));
346
- vChop.assign(chopWeight);
511
+ // Evaluate the analytic long-wave frame once per vertex. The former
512
+ // per-fragment eight-wave loop was the largest source of WebGPU private
513
+ // address-space pressure; interpolating this macro normal leaves the
514
+ // fragment stage's high-frequency detail and ripple normals intact.
515
+ const gerstner = waves.gerstnerNormalFiltered(
516
+ restXZ,
517
+ u.uTime,
518
+ chopWeight,
519
+ nearshore,
520
+ );
521
+ const shoalBlend = clamp(shoal, 0.0, 1.0).toVar();
522
+ vGerstnerNormal.assign(normalize(mix(vec3(0.0, 1.0, 0.0), gerstner.normal, shoalBlend)));
523
+ vCrest.assign(gerstner.crest);
347
524
  vShoal.assign(shoal);
348
525
  vBreaker.assign(breakFoam);
349
- vSwash.assign(swash);
350
526
  vWorldPosition.assign(worldPosition.xyz);
351
527
 
352
528
  return cameraProjectionMatrix.mul(cameraViewMatrix).mul(vec4(worldPosition.xyz, 1.0));
@@ -354,28 +530,37 @@ export function createWaterNodeMaterial({
354
530
 
355
531
  // ---- Fragment stage (water.frag.glsl) ----
356
532
  material.fragmentNode = Fn(() => {
357
- // Surface sections tucked under dry land (see vRestDepth) never shade:
358
- // without this, water triangles spanning steep banks slice through the
359
- // terrain as visible shards.
533
+ // Apply the nonlinear wet threshold after the signed film head has been
534
+ // interpolated. Evaluating smoothstep at sparse vertices first exposed
535
+ // individual triangle rows as the water advanced at a grazing angle.
360
536
  if (flags.shoaling) {
361
- Discard(vRestDepth.lessThan(-0.35));
537
+ const fragmentWet = mix(
538
+ 1.0,
539
+ smoothstep(-0.004, 0.012, vSwashFoamData.x),
540
+ clamp(vSwashZone, 0.0, 1.0),
541
+ );
542
+ Discard(fragmentWet.lessThan(0.015));
362
543
  }
363
544
  const screenUv = screenUV.toVar();
364
545
  const toCamera = cameraPosition.sub(vWorldPosition).toVar();
365
546
  const viewDir = normalize(toCamera).toVar();
366
547
  const viewDistance = length(toCamera).toVar();
367
548
 
368
- // --- surface normal: Gerstner (analytic, per-pixel) + fbm detail + ripples ---
369
- const gerstner = waves.gerstnerNormalFiltered(vRestWorldXZ, u.uTime, vChop);
370
- const gerstnerNormal = vec3(gerstner.normal).toVar();
371
- const crest = float(gerstner.crest).toVar();
549
+ // --- surface normal: interpolated Gerstner frame + fbm detail + ripples ---
550
+ const gerstnerNormal = normalize(vGerstnerNormal).toVar();
551
+ const crest = float(vCrest).toVar();
372
552
  const shoalBlend = clamp(vShoal, 0.0, 1.0).toVar();
373
- gerstnerNormal.assign(normalize(mix(vec3(0.0, 1.0, 0.0), gerstnerNormal, shoalBlend)));
374
553
  crest.assign(clamp(crest.mul(shoalBlend).add(vBreaker.mul(crest.mul(0.55).add(0.45))), 0.0, 1.0));
375
554
  const gerstnerNormalY = gerstnerNormal.y.toVar();
376
555
 
377
556
  const detailFade = smoothstep(16.0, 60.0, viewDistance).oneMinus();
378
- const detailStrength = u.uDetailNormalStrength.mul(mix(0.2, 1.0, detailFade)).toVar();
557
+ const swashOptics = clamp(vSwashZone, 0.0, 1.0).toVar();
558
+ // A centimetres-thin film is optically smooth at this camera scale. Full
559
+ // open-water normal detail turned every texel into a bright grazing-angle
560
+ // mirror and made the swash look like an opaque sheet of snow.
561
+ const detailStrength = u.uDetailNormalStrength
562
+ .mul(mix(0.2, 1.0, detailFade))
563
+ .mul(mix(1.0, 0.18, swashOptics)).toVar();
379
564
  const flowOffset = u.uFlowDirection.mul(u.uTime).mul(u.uFlowSpeed).toVar();
380
565
  const detailUv1 = vRestWorldXZ.mul(u.uDetailScale).add(flowOffset.mul(0.55));
381
566
  const detailUv2 = waterRotate2d(1.9).mul(vRestWorldXZ).mul(u.uDetailScale.mul(1.63)).sub(flowOffset.mul(0.34));
@@ -398,18 +583,122 @@ export function createWaterNodeMaterial({
398
583
  const output = vec4(0.0).toVar();
399
584
  If(u.uCameraBelow.greaterThan(0.5), () => {
400
585
  // --- underside: camera below the surface looking up ---
401
- // A stylized Snell-window: bright sky punches through overhead, the
402
- // surface darkens to the deep tint toward grazing angles.
403
- const throughDir = normalize(
404
- viewDir.negate().add(vec3(surfaceNormal.x, 0.0, surfaceNormal.z).mul(0.7)),
586
+ // Trace the view ray from water into air. The grab pass uses a clipped
587
+ // same-pose camera while submerged, so this lookup contains the actual
588
+ // sky, clouds, and above-water silhouettes rather than a sky-color
589
+ // approximation. The physical critical angle anchors the composition;
590
+ // color and edge shaping stay deliberately stylized.
591
+ const incident = viewDir.negate().toVar();
592
+ const eta = clamp(u.uIndexOfRefraction, 1.0001, 1.8).toVar();
593
+ const cosIncident = clamp(dot(incident, surfaceNormal), 0.0, 1.0).toVar();
594
+ const criticalCos = max(
595
+ float(1.0).sub(float(1.0).div(eta.mul(eta))),
596
+ 0.0,
597
+ ).sqrt().toVar();
598
+ const snellWindow = smoothstep(
599
+ criticalCos.sub(0.04),
600
+ criticalCos.add(0.045),
601
+ cosIncident,
602
+ ).toVar();
603
+ const refractedDirection = refract(
604
+ incident,
605
+ surfaceNormal.negate(),
606
+ eta,
607
+ ).toVar();
608
+ // refract() returns zero under total internal reflection. Blend toward
609
+ // the incident ray there to keep the projection numerically stable;
610
+ // snellWindow prevents that fallback ray from becoming transmission.
611
+ const transmissionDirection = normalize(mix(
612
+ incident,
613
+ refractedDirection,
614
+ max(snellWindow, 0.001),
615
+ )).toVar();
616
+ const transmissionWorld = vWorldPosition.add(
617
+ transmissionDirection.mul(min(u.uCameraFar.mul(0.75), 400.0)),
618
+ );
619
+ const transmissionClip = cameraProjectionMatrix
620
+ .mul(cameraViewMatrix)
621
+ .mul(vec4(transmissionWorld, 1.0)).toVar();
622
+ const transmissionUv = transmissionClip.xy
623
+ .div(max(abs(transmissionClip.w), 1e-5))
624
+ .mul(vec2(0.5, -0.5))
625
+ .add(0.5)
626
+ .add(refractionNormal.xz.mul(u.uRefractionStrength).mul(0.012))
627
+ .toVar();
628
+ const insideCapture = smoothstep(-0.01, 0.01, transmissionUv.x)
629
+ .mul(smoothstep(0.99, 1.01, transmissionUv.x).oneMinus())
630
+ .mul(smoothstep(-0.01, 0.01, transmissionUv.y))
631
+ .mul(smoothstep(0.99, 1.01, transmissionUv.y).oneMinus())
632
+ .mul(smoothstep(0.001, 0.03, transmissionClip.w));
633
+ const capturedAir = u.uSceneColor
634
+ .sample(clamp(transmissionUv, vec2(0.0), vec2(1.0)))
635
+ .level(0).rgb;
636
+ const transmitted = mix(
637
+ lighting.proceduralSky(transmissionDirection),
638
+ capturedAir,
639
+ clamp(insideCapture.mul(u.uUseSceneColor), 0.0, 1.0),
640
+ ).toVar();
641
+
642
+ // Compact Beer-Lambert-inspired attenuation through the camera's water
643
+ // column. This uses ToonLab's authored palette, keeping the result
644
+ // graphic and readable instead of pursuing the reference's realism.
645
+ const cameraDepth = max(vWorldPosition.y.sub(cameraPosition.y), 0.0).toVar();
646
+ const volumeAbsorb = exp(
647
+ cameraDepth.div(max(
648
+ u.uDepthFadeDistance.add(u.uDeepFadeDistance.mul(0.5)),
649
+ 0.1,
650
+ )).negate(),
651
+ ).oneMinus().toVar();
652
+ const waterTint = mix(
653
+ u.uShallowColor,
654
+ u.uMidColor,
655
+ clamp(volumeAbsorb.mul(1.35), 0.0, 1.0),
656
+ ).toVar();
657
+ transmitted.mulAssign(mix(
658
+ vec3(1.0),
659
+ waterTint.mul(1.12),
660
+ volumeAbsorb.mul(0.3),
661
+ ));
662
+ transmitted.assign(mix(
663
+ transmitted,
664
+ waterTint,
665
+ volumeAbsorb.mul(u.uUnderwaterTintStrength),
666
+ ));
667
+
668
+ const f0 = eta.sub(1.0).div(eta.add(1.0)).pow2().toVar();
669
+ const fresnel = f0.add(
670
+ f0.oneMinus().mul(pow(cosIncident.oneMinus(), 5.0)),
671
+ ).toVar();
672
+ const transmissionWeight = fresnel.oneMinus()
673
+ .mul(snellWindow)
674
+ .mul(u.uUnderwaterTransmission)
675
+ .toVar();
676
+ const tirColor = mix(
677
+ u.uDeepColor.mul(0.48),
678
+ u.uMidColor.mul(0.72),
679
+ cosIncident.mul(0.3),
680
+ );
681
+ const underColor = mix(
682
+ tirColor,
683
+ transmitted,
684
+ clamp(transmissionWeight, 0.0, 1.0),
405
685
  ).toVar();
406
- const skyThrough = lighting.proceduralSky(throughDir);
407
- const window = smoothstep(0.15, 0.8, throughDir.y).toVar();
408
- const underColor = mix(u.uDeepColor.mul(0.55), skyThrough.mul(1.15), window).toVar();
686
+ const criticalRim = smoothstep(
687
+ criticalCos.sub(0.085),
688
+ criticalCos,
689
+ cosIncident,
690
+ ).mul(smoothstep(
691
+ criticalCos,
692
+ criticalCos.add(0.1),
693
+ cosIncident,
694
+ ).oneMinus());
695
+ underColor.addAssign(u.uFresnelColor.mul(criticalRim).mul(0.12));
409
696
  underColor.addAssign(
410
697
  lighting.sparkles(vRestWorldXZ, surfaceNormal, viewDir, viewDistance, u.uTime).mul(0.5),
411
698
  );
412
- output.assign(vec4(underColor, mix(0.95, 0.62, window)));
699
+ // The captured air-side scene is already composited; render the
700
+ // underside opaque to avoid double-blending the main scene behind it.
701
+ output.assign(vec4(underColor, 1.0));
413
702
  }).Else(() => {
414
703
  // --- scene depth, refraction, and the water body color ---
415
704
  const waterViewDistance = cameraViewMatrix.mul(vec4(vWorldPosition, 1.0)).z.negate().toVar();
@@ -440,6 +729,14 @@ export function createWaterNodeMaterial({
440
729
  bodyColor.assign(absorptionTint);
441
730
  alpha.assign(clamp(u.uOpacity.add(absorb.mul(0.3)), 0.0, 1.0));
442
731
  });
732
+ // A thin film still has a readable water body. Preserve enough of the
733
+ // sand for a wet-ground cue, but keep the shallow tint and sky sheen so
734
+ // the run-up remains visibly connected to the sea.
735
+ const clearScene = u.uSceneColor.sample(screenUv).level(0).rgb;
736
+ const wetScene = clearScene.mul(0.85);
737
+ const clearSceneWeight = select(u.uUseSceneColor.greaterThan(0.5), 0.48, 0.0);
738
+ bodyColor.assign(mix(bodyColor, wetScene, swashOptics.mul(clearSceneWeight)));
739
+ bodyColor.assign(mix(bodyColor, u.uShallowColor, swashOptics.mul(0.18)));
443
740
 
444
741
  // Steep wave faces act like windows, not lenses: drop caustics there,
445
742
  // and fade the dappling with view distance (WATER_QUALITY >= 1).
@@ -451,17 +748,77 @@ export function createWaterNodeMaterial({
451
748
  : vec3(0.0);
452
749
 
453
750
  // --- foam ---
751
+ // Foam pattern follows the swash sheet's orbital surge (zero offshore),
752
+ // so the drain visibly pulls the foam seaward instead of the pattern
753
+ // staying glued to rest space while only the envelope moves.
754
+ const foamXZ = vRestWorldXZ.sub(vSwashSurge).toVar();
454
755
  const pierce = color.pierceProximity(screenUv, waterViewDistance, vWorldPosition.y).toVar();
455
- const shoreFoam = foam.shoreFoam(columnDepth, viewDepthDiff, vRestWorldXZ, u.uTime, pierce).toVar();
456
- const whitecaps = foam.whitecaps(crest, gerstnerNormalY, vRestWorldXZ, u.uTime).toVar();
756
+ const shoreFoam = foam.shoreFoam(columnDepth, viewDepthDiff, foamXZ, u.uTime, pierce)
757
+ // The legacy contact mask assumes a water body deeper than the foam
758
+ // distance. On a centimetre-thin swash film it is almost 1 everywhere,
759
+ // so keep its familiar offshore/rock character but let the dedicated
760
+ // turbulent edge above own the beach foam.
761
+ .mul(mix(1.0, 0.05, clamp(vSwashZone, 0.0, 1.0))).toVar();
762
+ const whitecaps = foam.whitecaps(crest, gerstnerNormalY, foamXZ, u.uTime).toVar();
457
763
  const rippleFoamRaw = clamp(
458
764
  rippleState.b.mul(u.uRippleFoamStrength).add(abs(rippleState.r).mul(u.uRippleFoamStrength).mul(4.0)),
459
765
  0.0,
460
766
  1.0,
461
767
  );
462
- const foamRaw = clamp(shoreFoam.add(whitecaps).add(rippleFoamRaw).add(vSwash), 0.0, 1.0)
463
- .mul(clamp(u.uFoamAmount, 0.0, 2.0));
464
- const foamValue = foam.foamShape(foamRaw, vRestWorldXZ, u.uTime).toVar();
768
+ const swashFoamRaw = float(0.0).toVar();
769
+ const persistentShoreState = flags.shoaling
770
+ ? shoreState.shoreStateSample(vRestWorldXZ)
771
+ : vec4(0.0);
772
+ if (flags.shoaling) {
773
+ // The temporal shore field owns transport, decay, breakup, and
774
+ // event-to-event variation. Keep this main material deliberately
775
+ // small: duplicating procedural noise here can exceed WebGPU's hard
776
+ // 8 KiB private-variable budget for StylizedWater.
777
+ If(vSwashZone.greaterThan(0.001), () => {
778
+ const filmHead = vSwashFoamData.x.toVar();
779
+ const foamWidth = max(vSwashFoamData.y, 0.018).toVar();
780
+ const fallbackBand = smoothstep(-0.006, 0.004, filmHead)
781
+ .mul(smoothstep(foamWidth.mul(0.7), foamWidth.mul(1.25), filmHead).oneMinus());
782
+ const storedFoam = clamp(
783
+ // The state pass deliberately stores low-energy values so its
784
+ // advection remains stable. Remap those values into the original
785
+ // foamShape range here: the former 0.16..0.58 / 0.82 mapping
786
+ // required active state B ~= 0.34 before a single solid pixel
787
+ // could appear, and residue A could never reach the shape's
788
+ // onset at all. Active foam gets the lower presentation onset;
789
+ // residue stays a quiet modifier so old events cannot rebuild a
790
+ // continuous bright shoreline. This does not add a coverage
791
+ // floor or refill the source's true gaps.
792
+ smoothstep(0.12, 0.48, persistentShoreState.b).mul(0.9)
793
+ .add(smoothstep(0.25, 0.7, persistentShoreState.a).mul(0.18)),
794
+ 0.0,
795
+ 1.0,
796
+ );
797
+ swashFoamRaw.assign(
798
+ max(
799
+ storedFoam,
800
+ fallbackBand.mul(u.uUseShoreState.oneMinus()),
801
+ ).mul(vSwashZone),
802
+ );
803
+ });
804
+ }
805
+ const foamAmount = clamp(u.uFoamAmount, 0.0, 2.0).toVar();
806
+ const baseFoamRaw = clamp(
807
+ shoreFoam.add(whitecaps).add(vBreaker.mul(0.85)).add(rippleFoamRaw),
808
+ 0.0,
809
+ 1.0,
810
+ ).mul(foamAmount).toVar();
811
+ const swashFoamValue = clamp(
812
+ swashFoamRaw.mul(clamp(u.uSwashFoamAmount, 0.0, 2.0)),
813
+ 0.0,
814
+ 1.0,
815
+ ).toVar();
816
+ // The pre-session foam looked better because every source shared this
817
+ // crisp solid-core + dissolve-fringe shaping. Shape stored swash foam
818
+ // together with offshore/contact foam; never max an unshaped milky mask
819
+ // over the final result.
820
+ const foamRaw = max(baseFoamRaw, swashFoamValue).toVar();
821
+ const foamValue = foam.foamShape(foamRaw, foamXZ, u.uTime).toVar();
465
822
 
466
823
  // Drifting cloud shadows plus scene shadows (shared sun-shadow pass).
467
824
  const cloudShadow = stylizedCloudShadow(
@@ -470,19 +827,35 @@ export function createWaterNodeMaterial({
470
827
  );
471
828
  const sceneShadow = mix(1.0, sampleEnvironmentSunShadow(vWorldPosition), u.uSceneShadowStrength);
472
829
  const sunVisibility = cloudShadow.mul(sceneShadow).toVar();
830
+ // Keep one caustic field across the offshore body and the running sheet.
831
+ // The caustics chunk already fades from the sampled scene depth; applying
832
+ // an additional swash-zone multiplier here exposed the implementation
833
+ // handoff as a hard, visibly separate rendering system.
473
834
  bodyColor.addAssign(caustics.mul(foamValue.oneMinus()).mul(sunVisibility));
474
835
 
475
836
  // --- reflection, fresnel, glints ---
476
837
  const fresnel = lighting.fresnelFactor(viewDir, surfaceNormal).toVar();
477
838
  const reflection = lighting.reflectionColor(vWorldPosition, surfaceNormal, viewDir).toVar();
478
- const reflectionMix = clamp(fresnel.mul(u.uReflectionStrength), 0.0, 0.92).toVar();
839
+ // Thin swash still catches the sky at its lip, but the interior should
840
+ // mostly reveal wet sand. Attenuate (rather than remove) its Fresnel,
841
+ // reflection and glints while keeping offshore water unchanged.
842
+ const swashLight = mix(1.0, 0.36, swashOptics).toVar();
843
+ const surfaceFresnel = fresnel.mul(swashLight).toVar();
844
+ const reflectionMix = clamp(
845
+ surfaceFresnel.mul(u.uReflectionStrength), 0.0, 0.92,
846
+ ).toVar();
479
847
  const litColor = mix(bodyColor, reflection, reflectionMix).toVar();
480
- litColor.addAssign(u.uFresnelColor.mul(fresnel).mul(fresnel).mul(0.35));
481
- litColor.addAssign(lighting.specular(viewDir, surfaceNormal, foamValue.mul(0.75).oneMinus()).mul(sunVisibility));
848
+ litColor.addAssign(
849
+ u.uFresnelColor.mul(surfaceFresnel).mul(surfaceFresnel).mul(0.35),
850
+ );
851
+ litColor.addAssign(
852
+ lighting.specular(viewDir, surfaceNormal, foamValue.mul(0.75).oneMinus())
853
+ .mul(sunVisibility).mul(swashLight),
854
+ );
482
855
  if (flags.sparkles) {
483
856
  litColor.addAssign(
484
857
  lighting.sparkles(vRestWorldXZ, surfaceNormal, viewDir, viewDistance, u.uTime)
485
- .mul(foamValue.oneMinus()).mul(sunVisibility),
858
+ .mul(foamValue.oneMinus()).mul(sunVisibility).mul(swashLight),
486
859
  );
487
860
  }
488
861
  litColor.mulAssign(mix(vec3(0.8, 0.85, 0.94), vec3(1.0), sunVisibility));
@@ -490,11 +863,22 @@ export function createWaterNodeMaterial({
490
863
  // --- foam overlay, slightly shaded by the sun for a two-tone toon read ---
491
864
  const foamLight = clamp(dot(surfaceNormal, u.uSunDirection), 0.0, 1.0).mul(0.18).add(0.82)
492
865
  .mul(sunVisibility.mul(0.18).add(0.82));
493
- litColor.assign(mix(litColor, u.uFoamColor.mul(foamLight), foamValue));
866
+ // Preserve the original bright foam, with just enough shallow-water
867
+ // shade at its porous fringe to show volume instead of flat white paint.
868
+ const foamShade = mix(u.uShallowColor, u.uFoamColor, 0.8).toVar();
869
+ const foamTint = mix(
870
+ foamShade,
871
+ u.uFoamColor.mul(foamLight),
872
+ smoothstep(0.5, 0.82, foamRaw),
873
+ ).toVar();
874
+ litColor.assign(mix(litColor, foamTint, foamValue));
494
875
  alpha.assign(max(alpha, foamValue.mul(0.92)));
495
876
  // The swash film is only centimeters deep; give it body.
496
- alpha.assign(max(alpha, vSwash.mul(0.55)));
497
- alpha.assign(max(alpha, clamp(reflectionMix.add(fresnel.mul(0.4)), 0.0, 1.0).mul(0.85)));
877
+ alpha.assign(max(alpha, swashFoamValue.mul(0.55)));
878
+ alpha.assign(max(
879
+ alpha,
880
+ clamp(reflectionMix.add(surfaceFresnel.mul(0.4)), 0.0, 1.0).mul(0.85),
881
+ ));
498
882
 
499
883
  // Fog last: foam, sparkles, and shorelines haze out with the rest of
500
884
  // the world. Exponential term mirrors the environment height fog;
@@ -530,6 +914,12 @@ export function createWaterNodeMaterial({
530
914
  )],
531
915
  [8, vec4(vec3(fresnel), 1.0)],
532
916
  [9, vec4(crest, vCrest, whitecaps, 1.0)],
917
+ [10, vec4(
918
+ persistentShoreState.r,
919
+ persistentShoreState.g,
920
+ max(persistentShoreState.b, persistentShoreState.a),
921
+ 1.0,
922
+ )],
533
923
  ];
534
924
  let debugMask = float(0.0);
535
925
  let debugSum = vec4(0.0);