@call-me-sensei/toonlab 0.4.21 → 0.4.23

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 (129) hide show
  1. package/NPM-LIBRARY.md +87 -27
  2. package/README.md +11 -10
  3. package/agents/PROMPTS.md +17 -3
  4. package/agents/README.md +13 -3
  5. package/agents/claude/CLAUDE.md +3 -0
  6. package/agents/codex/AGENTS.md +112 -159
  7. package/agents/cursor/toonlab.mdc +7 -3
  8. package/agents/examples/game-foundation.mjs +104 -0
  9. package/agents/references/game-lifecycle.md +57 -0
  10. package/agents/references/runtime-entry-points.md +26 -0
  11. package/agents/references/style-bundles.md +4 -1
  12. package/agents/skills/claude/asset-sourcing/SKILL.md +7 -4
  13. package/agents/skills/claude/environment/SKILL.md +4 -0
  14. package/agents/skills/claude/game-dev/SKILL.md +63 -87
  15. package/agents/skills/claude/lighting/SKILL.md +36 -0
  16. package/agents/skills/claude/post-processing/SKILL.md +8 -3
  17. package/agents/skills/claude/rock-ground-shaders/SKILL.md +10 -15
  18. package/agents/skills/claude/rockgen/SKILL.md +52 -21
  19. package/agents/skills/claude/scene-style-application/SKILL.md +4 -0
  20. package/agents/skills/claude/style-presets/SKILL.md +11 -8
  21. package/agents/skills/claude/toon-shading/SKILL.md +4 -0
  22. package/agents/skills/claude/vegetation-sky/SKILL.md +13 -7
  23. package/agents/skills/claude/visual-verification/SKILL.md +45 -119
  24. package/agents/skills/claude/water/SKILL.md +6 -2
  25. package/agents/skills/codex/asset-sourcing/SKILL.md +7 -4
  26. package/agents/skills/codex/environment/SKILL.md +4 -0
  27. package/agents/skills/codex/game-dev/SKILL.md +63 -87
  28. package/agents/skills/codex/lighting/SKILL.md +36 -0
  29. package/agents/skills/codex/post-processing/SKILL.md +8 -3
  30. package/agents/skills/codex/rock-ground-shaders/SKILL.md +10 -15
  31. package/agents/skills/codex/rockgen/SKILL.md +52 -21
  32. package/agents/skills/codex/scene-style-application/SKILL.md +4 -0
  33. package/agents/skills/codex/style-presets/SKILL.md +11 -8
  34. package/agents/skills/codex/toon-shading/SKILL.md +4 -0
  35. package/agents/skills/codex/vegetation-sky/SKILL.md +13 -7
  36. package/agents/skills/codex/visual-verification/SKILL.md +45 -119
  37. package/agents/skills/codex/water/SKILL.md +6 -2
  38. package/database/apply-one-catalog-seed.mjs +63 -0
  39. package/database/providers.mjs +1 -0
  40. package/database/seeds/catalog/0005_2026-08-c8-first12.sql +1101 -0
  41. package/database/seeds/catalog/0006_2026-09-c8-first100.sql +14245 -0
  42. package/database/seeds/catalog/0007_2026-09-c8-first100-primary-model.sql +14845 -0
  43. package/mcp/lab-management.mjs +11 -8
  44. package/mcp/public-catalog.mjs +0 -16
  45. package/mcp/tree-lab-contract.mjs +2381 -0
  46. package/mcp/vite-plugin.mjs +5 -1
  47. package/package.json +29 -18
  48. package/src/catalog/officialCatalog.js +1 -0
  49. package/src/catalog/officialCatalogAssetRuntime.js +98 -1
  50. package/src/catalog/officialCatalogLod.js +160 -16
  51. package/src/catalog/officialCatalogPlacement.js +52 -12
  52. package/src/catalog/officialCatalogProvider.js +79 -8
  53. package/src/catalog/officialCatalogRockPackage.js +172 -0
  54. package/src/cloud/cloudReprojection.js +40 -7
  55. package/src/cloud/cloudVolume.js +44 -7
  56. package/src/cloud/noise/baseShapeVolume.js +8 -6
  57. package/src/cloud/noise/cirrusMap.js +2 -1
  58. package/src/cloud/noise/curlNoise.js +2 -1
  59. package/src/cloud/noise/erosionVolume.js +2 -1
  60. package/src/cloud/noise/textureCache.js +77 -0
  61. package/src/cloud/noise/weatherMap.js +2 -1
  62. package/src/core/sha256.js +83 -0
  63. package/src/ground-shader/groundShaderMaterial.js +12 -3
  64. package/src/rock-shader/rockMaterial.js +205 -28
  65. package/src/rock-shader/rockShaderRuntime.js +17 -1
  66. package/src/rock-shader/rockShaderSettings.js +16 -0
  67. package/src/rockgen/index.js +9 -0
  68. package/src/rockgen/lod/index.js +1 -0
  69. package/src/rockgen/lod/rockDenseFieldPolicy.js +143 -0
  70. package/src/rockgen/rockDocument.js +899 -35
  71. package/src/rockgen/surface/c7GeologySurface.js +39 -9
  72. package/src/rockgen/surface/naturalRockSurface.js +1948 -0
  73. package/src/shaders-tsl/chunks/projected-water-caustics.js +14 -2
  74. package/src/shaders-tsl/chunks/water-foam.js +6 -6
  75. package/src/shaders-tsl/chunks/water-lighting.js +28 -5
  76. package/src/shaders-tsl/chunks/water-shore-state.js +5 -2
  77. package/src/shaders-tsl/chunks/water-waves.js +10 -5
  78. package/src/shaders-tsl/flower.js +7 -4
  79. package/src/shaders-tsl/grass.js +4 -3
  80. package/src/shaders-tsl/water-shore-state-simulation.js +3 -2
  81. package/src/shaders-tsl/water.js +209 -41
  82. package/src/sky/skySystem.js +30 -7
  83. package/src/toon/settings/perspectiveRemovalSettings.js +1 -1
  84. package/src/toon/toonSettings.js +74 -0
  85. package/src/vegetation/stylizedFlower.js +6 -0
  86. package/src/version.js +1 -1
  87. package/src/water/waterDetailSpectrum.js +91 -0
  88. package/src/water/waterDynamics.js +422 -0
  89. package/src/water/waterFoamParticles.js +196 -0
  90. package/src/water/waterFoamTexture.js +57 -0
  91. package/src/water/waterHydrodynamics.js +209 -0
  92. package/src/water/waterMaterial.js +1 -0
  93. package/src/water/waterRenderExtension.js +107 -0
  94. package/src/water/waterScenePasses.js +14 -6
  95. package/src/water/waterSettings.js +53 -26
  96. package/src/water/waterShoreMaterial.js +38 -14
  97. package/src/water/waterSpectralOcean.js +137 -0
  98. package/src/water/waterStageSettings.js +19 -1
  99. package/src/water/waterSurface.js +62 -18
  100. package/src/water/waterUnderwaterAtmosphere.js +30 -3
  101. package/types/catalog/officialCatalog.d.ts +1 -0
  102. package/types/catalog/officialCatalogPlacement.d.ts +4 -0
  103. package/types/catalog/officialCatalogRockPackage.d.ts +68 -0
  104. package/types/cloud/noise/baseShapeVolume.d.ts +1 -1
  105. package/types/cloud/noise/textureCache.d.ts +12 -0
  106. package/types/core/sha256.d.ts +2 -0
  107. package/types/index.d.ts +30 -0
  108. package/types/rock-shader/rockMaterial.d.ts +3 -0
  109. package/types/rock-shader/rockShaderSettings.d.ts +2 -0
  110. package/types/rockgen/index.d.ts +1 -0
  111. package/types/rockgen/lod/index.d.ts +1 -0
  112. package/types/rockgen/lod/rockDenseFieldPolicy.d.ts +181 -0
  113. package/types/rockgen/rockDocument.d.ts +654 -71
  114. package/types/rockgen/surface/naturalRockSurface.d.ts +100 -0
  115. package/types/shaders-tsl/chunks/projected-water-caustics.d.ts +7 -1
  116. package/types/shaders-tsl/chunks/water-lighting.d.ts +4 -3
  117. package/types/shaders-tsl/water.d.ts +2 -1
  118. package/types/version.d.ts +1 -1
  119. package/types/water/waterDetailSpectrum.d.ts +13 -0
  120. package/types/water/waterDynamics.d.ts +89 -0
  121. package/types/water/waterFoamParticles.d.ts +32 -0
  122. package/types/water/waterFoamTexture.d.ts +1 -0
  123. package/types/water/waterHydrodynamics.d.ts +49 -0
  124. package/types/water/waterRenderExtension.d.ts +16 -0
  125. package/types/water/waterScenePasses.d.ts +1 -1
  126. package/types/water/waterSettings.d.ts +1 -0
  127. package/types/water/waterSpectralOcean.d.ts +17 -0
  128. package/types/water/waterStageSettings.d.ts +1 -1
  129. package/types/water/waterUnderwaterAtmosphere.d.ts +19 -4
@@ -340,9 +340,9 @@ const WATER_PRESETS = Object.freeze({
340
340
  whitecapAmount: 0.08,
341
341
  }),
342
342
  coast: Object.freeze({
343
- waveIntensity: 0.5,
344
- waveAmplitude: 0.42,
345
- waveLength: 9.0,
343
+ waveIntensity: 0.6,
344
+ waveAmplitude: 0.65,
345
+ waveLength: 18.0,
346
346
  // Onshore swell: surf only exists when waves travel toward the shallows.
347
347
  waveDirection: [0.15, -1.0],
348
348
  waveDirectionSpread: 0.5,
@@ -603,8 +603,9 @@ export function createWaterSettings(options = {}) {
603
603
  // `detailNormalStrength` or `reflectionStrength` alongside `colorTone:
604
604
  // 'anime'` was silently inert, which is exactly what §6.4 of the launch spec
605
605
  // asks for and could not get (D19-005). Callers that pass nothing are
606
- // unaffected they still resolve to the tone value, byte for byte.
607
- const toneScalar = (key) => (source[key] === undefined ? tone[key] : source[key]);
606
+ // resolved from the authored style recipe, then the tone recommendation.
607
+ const styleRecipe = styleEntry?.presets?.[preset] ?? styleEntry?.scenarios?.[preset] ?? {};
608
+ const toneScalar = (key) => source[key] ?? styleRecipe[key] ?? tone[key];
608
609
 
609
610
  const quality = WATER_QUALITY_LEVELS.includes(String(source.quality).toLowerCase())
610
611
  ? String(source.quality).toLowerCase()
@@ -806,15 +807,6 @@ export function buildGerstnerWaves(settings) {
806
807
  });
807
808
  }
808
809
 
809
- // Gerstner surfaces self-intersect when the summed steepness exceeds 1.
810
- // Normalize so the whole stack stays stable at any dial position.
811
- const steepnessSum = waves.reduce(
812
- (sum, wave) => sum + wave.steepness * wave.waveNumber * wave.amplitude, 0);
813
- if (steepnessSum > 0.9) {
814
- const scale = 0.9 / steepnessSum;
815
- for (const wave of waves) wave.steepness *= scale;
816
- }
817
-
818
810
  // Wave sets: real swell arrives in groups — the big waves come minutes
819
811
  // apart, not every period. Splitting the primary component into a beat
820
812
  // pair (slots 0 and 1: same direction, slightly different frequency) makes
@@ -826,19 +818,43 @@ export function buildGerstnerWaves(settings) {
826
818
  const setStrength = resolved.waveSetStrength;
827
819
  if (setStrength > 0.001 && waves.length >= 2) {
828
820
  const primary = waves[0];
829
- const beatOmega = primary.omega + (2 * Math.PI) / Math.max(resolved.waveSetPeriod, 1);
830
- const speedScale = Math.max(resolved.waveSpeed, 1e-3);
821
+ // Detune in intrinsic frequency, then apply the common time scale.
822
+ // The old fixed omega offset kept the beat moving at speed=0 and made
823
+ // k explode as speed approached zero. A group requires nearby frequencies.
824
+ const intrinsicOmega = Math.sqrt(9.81 * primary.waveNumber);
825
+ const speedScale = resolved.waveSpeed;
826
+ const detuning = Math.min(
827
+ (2 * Math.PI) / (Math.max(resolved.waveSetPeriod, 1) * Math.max(speedScale, 1e-3)),
828
+ intrinsicOmega * 0.25,
829
+ );
830
+ const beatIntrinsicOmega = intrinsicOmega + detuning;
831
+ const beatOmega = beatIntrinsicOmega * speedScale;
831
832
  const beat = {
832
833
  ...primary,
833
834
  amplitude: primary.amplitude * setStrength * 0.5,
834
835
  omega: beatOmega,
835
- waveNumber: (beatOmega / speedScale) ** 2 / 9.81,
836
+ waveNumber: beatIntrinsicOmega ** 2 / 9.81,
836
837
  phase: primary.phase + 2.39,
837
838
  };
838
839
  primary.amplitude *= 1 - setStrength * 0.5;
839
840
  waves.pop(); // drop the smallest tail component to keep the count fixed
840
841
  waves.splice(1, 0, beat);
841
842
  }
843
+ // Budget the FINAL spectrum, including its beat partner. Per-component
844
+ // ka limits alone allow eight individually legal waves to form spike-like
845
+ // sums. Stretch the spectrum together, preserving height and relative
846
+ // wavelengths, with a conservative aggregate slope budget (not a breaking
847
+ // law). Recompute frequencies through gravity-wave dispersion.
848
+ const slopeSum = waves.reduce((sum, wave) => sum + wave.waveNumber * wave.amplitude, 0);
849
+ const stretch = Math.max(1, slopeSum / 0.8);
850
+ for (const wave of waves) {
851
+ wave.waveNumber /= stretch;
852
+ wave.omega = Math.sqrt(9.81 * wave.waveNumber) * resolved.waveSpeed;
853
+ }
854
+ const steepnessSum = waves.reduce(
855
+ (sum, wave) => sum + wave.steepness * wave.waveNumber * wave.amplitude, 0);
856
+ const chopScale = Math.min(1, 0.85 / Math.max(steepnessSum, 1e-8));
857
+ for (const wave of waves) wave.steepness *= chopScale;
842
858
  return waves;
843
859
  }
844
860
 
@@ -1000,6 +1016,15 @@ export function sampleSwashCycleVariation(cycleIndex) {
1000
1016
  };
1001
1017
  }
1002
1018
 
1019
+ // Numerical extinction near zero forcing. This does not turn the authored
1020
+ // run-up animation into a hydrodynamic solver; it prevents a dry/flat spectrum
1021
+ // from driving a full-distance swash event. Fade over the last 5 cm of summed
1022
+ // amplitude so the limit is continuous instead of an on/off snap.
1023
+ function swashActivity(waves) {
1024
+ const amplitude = (waves ?? []).reduce((sum, wave) => sum + Math.abs(wave.amplitude), 0);
1025
+ return THREE.MathUtils.smoothstep(amplitude, 0, 0.05);
1026
+ }
1027
+
1003
1028
  // Continuous centerline position of the swash edge in metres along the beach.
1004
1029
  // Event N begins exactly at event N-1's rundown endpoint, rises to its own
1005
1030
  // varying inland maximum, then drains to a new endpoint without a reset jump.
@@ -1009,9 +1034,9 @@ export function sampleSwashDistance(waves, time, runupDistance, uprushFraction =
1009
1034
  const previous = sampleSwashCycleVariation(sequence.index - 1);
1010
1035
  const progress = shapeSwashProgress(sequence.cycle, uprushFraction);
1011
1036
  const peak = Math.max(Number(runupDistance) || 0, 0) * current.runupScale;
1012
- return sequence.cycle <= uprushFraction
1037
+ return swashActivity(waves) * (sequence.cycle <= uprushFraction
1013
1038
  ? THREE.MathUtils.lerp(previous.rundownOffset, peak, progress)
1014
- : THREE.MathUtils.lerp(current.rundownOffset, peak, progress);
1039
+ : THREE.MathUtils.lerp(current.rundownOffset, peak, progress));
1015
1040
  }
1016
1041
 
1017
1042
  // One CPU-authored frame shared by the visible swash and the persistent
@@ -1036,6 +1061,7 @@ export function sampleSwashFrameState(
1036
1061
  shapeSwashProgress(afterSequence.cycle, uprushFraction) -
1037
1062
  shapeSwashProgress(beforeSequence.cycle, uprushFraction)
1038
1063
  ) / (derivativeStep * 2);
1064
+ const activity = swashActivity(waves);
1039
1065
  const maximumDistance = Math.max(Number(runupDistance) || 0, 0);
1040
1066
  const edgeDistance = maximumDistance > 0
1041
1067
  ? sampleSwashDistance(waves, time, maximumDistance, uprushFraction)
@@ -1057,9 +1083,10 @@ export function sampleSwashFrameState(
1057
1083
  primaryDirectionZ: waves?.[0]?.dirZ ?? -1,
1058
1084
  progress,
1059
1085
  progressSpeed,
1060
- runupScale: current.runupScale,
1061
- startOffset: previous.rundownOffset,
1062
- endOffset: current.rundownOffset,
1086
+ activity,
1087
+ runupScale: current.runupScale * activity,
1088
+ startOffset: previous.rundownOffset * activity,
1089
+ endOffset: current.rundownOffset * activity,
1063
1090
  edgeShape,
1064
1091
  };
1065
1092
  }
@@ -1125,7 +1152,7 @@ export const WATER_SETTING_GROUPS = Object.freeze([
1125
1152
  const FIELD_METADATA = {
1126
1153
  waveIntensity: { group: 'waves', label: 'Wave Intensity', min: 0, max: 1, step: 0.01, description: 'Authored baseline from glassy mirror (0) to storm swell (1); scene weather can transiently modulate it without changing the preset.' },
1127
1154
  waterLevel: { group: 'waves', label: 'Water Level', min: 0, max: 4, step: 0.01, description: 'World-space rest height of the surface; waves and run-up displace around it.' },
1128
- waveAmplitude: { group: 'waves', label: 'Wave Amplitude', min: 0, max: 5, step: 0.01, description: 'Largest wave amplitude in meters at full intensity; 5 gives a 10 m crest-to-trough swell.' },
1155
+ waveAmplitude: { group: 'waves', label: 'Wave Amplitude', min: 0, max: 5, step: 0.01, description: 'Primary amplitude in meters at full intensity, before splitting into a wave set. The combined surface includes smaller waves; its wavelengths lengthen to keep slopes bounded.' },
1129
1156
  shoalingDepth: { group: 'waves', label: 'Shoaling Depth', min: 0.05, max: 12, step: 0.05, description: 'Column depth in meters at which waves reach full height; shallower water shrinks them (needs a bed height sampler).' },
1130
1157
  shorelineWaves: { group: 'waves', label: 'Shoreline Waves', min: 0, max: 1, step: 0.01, description: 'Fraction of wave height that keeps rolling through the shallows as surf before dying at the waterline.' },
1131
1158
  shorelineRunup: { group: 'waves', label: 'Shoreline Run-up', min: 0, max: 3, step: 0.05, description: 'How far incoming waves wash a thin foam film up the beach; reach scales with wave energy.' },
@@ -1140,7 +1167,7 @@ const FIELD_METADATA = {
1140
1167
  waveSpeed: { group: 'waves', label: 'Wave Speed', min: 0, max: 4, step: 0.01, description: 'Phase speed multiplier over the deep-water dispersion.' },
1141
1168
  waveDirection: { group: 'waves', label: 'Wave Direction', type: 'vector2', description: 'Main travel direction of the swell in the XZ plane.' },
1142
1169
  waveDirectionSpread: { group: 'waves', label: 'Direction Spread', min: 0, max: 1, step: 0.01, description: '0 keeps all waves aligned (river); 1 spreads them omnidirectionally (open sea). The primary swell always follows Wave Direction exactly.' },
1143
- waveSetPeriod: { group: 'waves', label: 'Set Period (s)', min: 8, max: 600, step: 1, description: 'Seconds between wave-set peaks at a fixed point; big waves arrive in groups, not every period.' },
1170
+ waveSetPeriod: { group: 'waves', label: 'Set Period (s)', min: 8, max: 600, step: 1, description: 'Requested minimum interval between wave-set peaks. Stability limits and slow motion can lengthen it.' },
1144
1171
  waveSetStrength: { group: 'waves', label: 'Set Strength', min: 0, max: 1, step: 0.01, description: 'Depth of the set/lull cycle: 0 = constant swell, 1 = the swell dies completely between sets.' },
1145
1172
  detailNormalStrength: { group: 'waves', label: 'Detail Normals', min: 0, max: 2, step: 0.01, description: 'Strength of the procedural micro-ripple normal detail.' },
1146
1173
  detailScale: { group: 'waves', label: 'Detail Scale', min: 0.05, max: 8, step: 0.05, description: 'Spatial frequency of the micro-ripple detail.' },
@@ -1172,7 +1199,7 @@ const FIELD_METADATA = {
1172
1199
  indexOfRefraction: { group: 'surface', label: 'Water IOR', min: 1.0001, max: 1.8, step: 0.001, description: 'Index of refraction used by the underwater Snell window and total internal reflection.' },
1173
1200
  underwaterTransmission: { group: 'surface', label: 'Underwater View', min: 0, max: 1, step: 0.01, description: 'Visibility of the real above-water scene through the surface from below.' },
1174
1201
  underwaterTintStrength: { group: 'surface', label: 'Underwater Tint', min: 0, max: 1, step: 0.01, description: 'Stylized water-color tint applied to the view through the surface.' },
1175
- causticsStrength: { group: 'surface', label: 'Caustics', min: 0, max: 3, step: 0.01, description: 'Brightness of the procedural voronoi caustics on the bottom.' },
1202
+ causticsStrength: { group: 'surface', label: 'Caustics', min: 0, max: 3, step: 0.01, description: 'Brightness of focused sunlight on the submerged bed. The dynamics path derives focusing from the water normals.' },
1176
1203
  causticsScale: { group: 'surface', label: 'Caustics Scale', min: 0.05, max: 8, step: 0.05, description: 'Spatial frequency of the caustic web.' },
1177
1204
  causticsSpeed: { group: 'surface', label: 'Caustics Speed', min: 0, max: 4, step: 0.01, description: 'Animation speed of the caustic web.' },
1178
1205
 
@@ -1654,7 +1681,7 @@ registerWaterStyle('call_me_sensei', {
1654
1681
  presets: {
1655
1682
  lake: { flowSpeed: 0.4, sparkleStrength: 0.55, waveIntensity: 0.32 },
1656
1683
  river: { flowSpeed: 1.15, foamLineSpacing: 0.38, sparkleStrength: 0.45, waveIntensity: 0.3 },
1657
- coast: { flowSpeed: 0.45, swashFoamAmount: 1.4, sparkleStrength: 0.5, waveIntensity: 0.5 },
1684
+ coast: { flowSpeed: 0.45, swashFoamAmount: 1.4, causticsStrength: 1.25, sparkleStrength: 0, waveIntensity: 0.6 },
1658
1685
  ocean: { flowSpeed: 0.5, sparkleStrength: 0.7, waveIntensity: 0.7, whitecapAmount: 0.45 },
1659
1686
  },
1660
1687
  });
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
 
3
3
  import { MeshPhysicalNodeMaterial } from 'three/webgpu';
4
4
  import {
5
+ abs,
5
6
  clamp,
6
7
  float,
7
8
  luminance,
@@ -21,6 +22,7 @@ import {
21
22
  } from 'three/tsl';
22
23
 
23
24
  import { projectedWaterCaustics } from '../shaders-tsl/chunks/projected-water-caustics.js';
25
+ import { getWaterFoamTexture } from './waterFoamTexture.js';
24
26
  import { sampleEnvironmentSunShadow } from '../shaders-tsl/chunks/environment-sun-shadow.js';
25
27
  import { sampleEnvironmentCloudShadow } from '../sky/cloudShadow.js';
26
28
 
@@ -145,6 +147,11 @@ export function updateWaterShoreMaterial(material, {
145
147
  if (stateField !== undefined) material.userData.waterShoreStateField = stateField;
146
148
  const field = material.userData.waterShoreStateField ?? null;
147
149
  uniforms.uShoreStateMap.value = resolveStateTexture(field);
150
+ uniforms.uShoreFoamCoordinates.value = field?.foamCoordinateTexture ?? getFallbackStateTexture();
151
+ if (uniforms.uShoreSolvedDepth) uniforms.uShoreSolvedDepth.value = field?.isWaterDynamics ? 1 : 0;
152
+ if (uniforms.uShoreContinuation) uniforms.uShoreContinuation.value.set(
153
+ field?.level ?? 0, field?.options?.renderExtent && field?.mode === 'coast' ? 1 : 0,
154
+ );
148
155
  resolveStateRegion(field, regionScratch);
149
156
  uniforms.uShoreStateRegion.value.copy(regionScratch);
150
157
 
@@ -184,6 +191,10 @@ export function createWaterShoreMaterial({
184
191
  } = {}) {
185
192
  const uniforms = {
186
193
  uShoreStateMap: texture(getFallbackStateTexture()),
194
+ uShoreFoamCoordinates: texture(getFallbackStateTexture()),
195
+ uShoreFoamBubbles: texture(getWaterFoamTexture()),
196
+ uShoreSolvedDepth: uniform(0),
197
+ uShoreContinuation: uniform(new THREE.Vector2(0,0)),
187
198
  // (centerX, centerZ, halfWidth, halfDepth), matching the ripple-region
188
199
  // convention used by the rest of the water system.
189
200
  uShoreStateRegion: uniform(new THREE.Vector4(0, 0, 0.5, 0.5)),
@@ -198,17 +209,31 @@ export function createWaterShoreMaterial({
198
209
  .sub(uniforms.uShoreStateRegion.xy)
199
210
  .div(max(uniforms.uShoreStateRegion.zw.mul(2.0), vec2(1e-3)))
200
211
  .add(0.5);
201
- const inside = step(0.0, fieldUv.x).mul(step(fieldUv.x, 1.0))
212
+ const insideX = step(0.0, fieldUv.x).mul(step(fieldUv.x, 1.0));
213
+ const inside = mix(insideX,1.0,uniforms.uShoreContinuation.y)
202
214
  .mul(step(0.0, fieldUv.y)).mul(step(fieldUv.y, 1.0));
203
215
  // State render targets have no mip chain. Explicit level zero is required
204
216
  // by WGSL in non-uniform control flow and is harmless on the GLSL builder.
205
- const state = uniforms.uShoreStateMap
217
+ const rawState = uniforms.uShoreStateMap
206
218
  .sample(clamp(fieldUv, vec2(0.0), vec2(1.0)))
207
- .level(0)
208
- .mul(inside);
219
+ .level(0);
220
+ const state = rawState.mul(inside);
221
+ const boundedUv=clamp(fieldUv,vec2(0),vec2(1));
222
+ const foamCoordinates=uniforms.uShoreFoamCoordinates.sample(boundedUv).level(0).rg
223
+ .add(fieldUv.sub(boundedUv).mul(uniforms.uShoreStateRegion.zw).mul(2));
224
+ const foamBubbles=uniforms.uShoreFoamBubbles.sample(foamCoordinates.div(.72));
225
+
226
+ // Submerged continuation cannot suddenly become dry sand at the atlas
227
+ // boundary. Carry boundary moisture outward, tending to rest submersion.
228
+ const outside = max(abs(positionWorld.xz.sub(uniforms.uShoreStateRegion.xy))
229
+ .sub(uniforms.uShoreStateRegion.zw),vec2(0.0));
230
+ const nearBoundary = smoothstep(0.0,80.0,max(outside.x,outside.y)).oneMinus();
231
+ const restWet = smoothstep(0.002,0.04,uniforms.uShoreContinuation.x.sub(positionWorld.y));
232
+ const continuationWet = max(restWet,rawState.r.mul(nearBoundary))
233
+ .mul(inside.oneMinus()).mul(uniforms.uShoreContinuation.y);
209
234
 
210
- const moisture = clamp(state.r, 0.0, 1.0);
211
- const film = clamp(state.g, 0.0, 1.0);
235
+ const moisture = clamp(max(state.r,continuationWet), 0.0, 1.0);
236
+ const film = clamp(max(mix(state.g, state.g.div(0.04), uniforms.uShoreSolvedDepth),continuationWet), 0.0, 1.0);
212
237
  const foamAmountNode = clamp(uniforms.uShoreFoamAmount, 0.0, 2.0);
213
238
  const activeFoam = clamp(state.b, 0.0, 1.0);
214
239
  const residue = clamp(state.a, 0.0, 1.0);
@@ -270,7 +295,7 @@ export function createWaterShoreMaterial({
270
295
  visibleActiveFoam.mul(0.72).add(visibleResidue.mul(0.12)).mul(foamAmountNode),
271
296
  0.0,
272
297
  0.74,
273
- );
298
+ ).mul(mix(1.0, smoothstep(0.0001, 0.0015, state.g).oneMinus().mul(foamBubbles.g.mul(.7).add(.3)), uniforms.uShoreSolvedDepth));
274
299
  const receiverVisibility = sampleEnvironmentSunShadow(positionWorld)
275
300
  .mul(sampleEnvironmentCloudShadow(positionWorld, 1))
276
301
  .toVar();
@@ -279,7 +304,7 @@ export function createWaterShoreMaterial({
279
304
  vec3(1.0),
280
305
  receiverVisibility,
281
306
  );
282
- const shadowedFoamColor = uniforms.uShoreFoamColor.mul(shadowTint);
307
+ const shadowedFoamColor = uniforms.uShoreFoamColor.mul(shadowTint).mul(mix(1,foamBubbles.r,uniforms.uShoreSolvedDepth));
283
308
  const shoreColor = mix(wetBaseColor, shadowedFoamColor, foamCoverage)
284
309
  .mul(shadowTint);
285
310
 
@@ -301,12 +326,11 @@ export function createWaterShoreMaterial({
301
326
 
302
327
  const material = new MeshPhysicalNodeMaterial({ vertexColors: false });
303
328
  material.name = 'WaterShoreMaterial';
304
- // Fold the underwater light into albedo instead of assigning emissiveNode.
305
- // Some node backends replace the normal lit path when an otherwise-zero
306
- // custom emissive graph is present, which made the dry beach charcoal while
307
- // the camera was above water. The shared enabled uniform keeps this addend
308
- // exactly zero outside the underwater pass.
309
- material.colorNode = shoreColor.add(
329
+ material.colorNode = shoreColor;
330
+ // Focused sunlight is extra incident illumination, not a brighter sand
331
+ // pigment. Keeping it out of albedo preserves its contrast under tone
332
+ // mapping and avoids multiplying it by the ordinary sun a second time.
333
+ material.emissiveNode = shoreColor.mul(
310
334
  projectedWaterCaustics(positionWorld, normalWorld).mul(receiverVisibility),
311
335
  );
312
336
  material.metalnessNode = float(0.0);
@@ -0,0 +1,137 @@
1
+ // Independent spectral ocean implementation: seeded Gaussian amplitudes,
2
+ // JONSWAP-shaped directional frequency density and finite-depth dispersion.
3
+ // Three non-overlapping wave-number bands. FFT evaluates the linear wave
4
+ // solution; it is not a nonlinear overturning/breaking solver.
5
+ const TAU = 2 * Math.PI;
6
+ const G = 9.81;
7
+ function random(seed) { let s = seed >>> 0; return () => { s = (Math.imul(1664525, s) + 1013904223) >>> 0; return (s + 0.5) / 4294967296; }; }
8
+ export function inverseFFT2D(real, imaginary, size) {
9
+ const line = (offset, stride) => {
10
+ for (let i = 1, j = 0; i < size; i++) {
11
+ let bit = size >> 1;
12
+ for (; j & bit; bit >>= 1)
13
+ j ^= bit;
14
+ j ^= bit;
15
+ if (i < j) {
16
+ const a = offset + i * stride, b = offset + j * stride;
17
+ let temp = real[a];
18
+ real[a] = real[b];
19
+ real[b] = temp;
20
+ temp = imaginary[a];
21
+ imaginary[a] = imaginary[b];
22
+ imaginary[b] = temp;
23
+ }
24
+ }
25
+ for (let len = 2; len <= size; len <<= 1) {
26
+ const wr0 = Math.cos(TAU / len), wi0 = Math.sin(TAU / len);
27
+ for (let start = 0; start < size; start += len) {
28
+ let wr = 1, wi = 0;
29
+ for (let j = 0; j < len / 2; j++) {
30
+ const a = offset + (start + j) * stride, b = offset + (start + j + len / 2) * stride;
31
+ const tr = real[b] * wr - imaginary[b] * wi, ti = real[b] * wi + imaginary[b] * wr;
32
+ real[b] = real[a] - tr;
33
+ imaginary[b] = imaginary[a] - ti;
34
+ real[a] += tr;
35
+ imaginary[a] += ti;
36
+ const next = wr * wr0 - wi * wi0;
37
+ wi = wr * wi0 + wi * wr0;
38
+ wr = next;
39
+ }
40
+ }
41
+ }
42
+ };
43
+ for (let z = 0; z < size; z++)
44
+ line(z * size, 1);
45
+ for (let x = 0; x < size; x++)
46
+ line(x, size);
47
+ const norm = 1 / (size * size);
48
+ for (let i = 0; i < real.length; i++) {
49
+ real[i] *= norm;
50
+ imaginary[i] *= norm;
51
+ }
52
+ }
53
+ export class WaterSpectralOcean {
54
+ constructor({ resolution = 64, seed = 731 } = {}) {
55
+ if (resolution < 8 || (resolution & (resolution - 1)))
56
+ throw new RangeError('FFT resolution must be a power of two');
57
+ this.resolution = resolution;
58
+ this.seed = seed;
59
+ this.bands = [];
60
+ this.signature = '';
61
+ this.time = 0;
62
+ }
63
+ configure(settings, depth = 20, minWavelength = 0) {
64
+ const { waveAmplitude = 0.3, waveIntensity = 0.3, waveLength = 13, waveDirection = [1, 0], waveDirectionSpread = 0.5 } = settings;
65
+ const peakLength = Math.max(waveLength, waveAmplitude * Math.pow(waveIntensity, 1.35) * TAU / 0.18, 2);
66
+ const signature = JSON.stringify([peakLength, waveAmplitude, waveIntensity, waveDirection, waveDirectionSpread, depth, minWavelength]);
67
+ if (signature === this.signature)
68
+ return;
69
+ this.signature = signature;
70
+ const targetRms = waveAmplitude * Math.pow(waveIntensity, 1.35) * 0.707;
71
+ const n = this.resolution, dk0 = TAU / (peakLength * 8), kp = TAU / peakLength;
72
+ const direction = Math.atan2(waveDirection[1], waveDirection[0]);
73
+ this.bands = [];
74
+ // Separate k ranges avoid counting the same energy in several cascades.
75
+ for (let band = 0; band < 3; band++) {
76
+ const length = peakLength * 8 / Math.pow(4, band), dk = TAU / length, rand = random(this.seed + band * 101);
77
+ const data = { length, real: new Float64Array(n * n), imag: new Float64Array(n * n), h0r: new Float64Array(n * n), h0i: new Float64Array(n * n), omega: new Float64Array(n * n), size: n };
78
+ for (let z = 0; z < n; z++)
79
+ for (let x = 0; x < n; x++) {
80
+ const i = z * n + x, kx = (x <= n / 2 ? x : x - n) * dk, kz = (z <= n / 2 ? z : z - n) * dk, k = Math.hypot(kx, kz);
81
+ if (k < dk0 || k < (band === 0 ? 0 : dk * n / 16) || k >= dk * n / 4 || (minWavelength > 0 && k > TAU / minWavelength))
82
+ continue;
83
+ const omega = Math.sqrt(G * k * Math.tanh(k * Math.max(depth, 0.2))), wp = Math.sqrt(G * kp * Math.tanh(kp * Math.max(depth, 0.2)));
84
+ const sigma = omega <= wp ? 0.07 : 0.09, r = Math.exp(-((omega / wp - 1) ** 2) / (2 * sigma * sigma));
85
+ const density = Math.pow(omega, -5) * Math.exp(-1.25 * Math.pow(wp / omega, 4)) * Math.pow(3.3, r);
86
+ const c = Math.cos(Math.atan2(kz, kx) - direction);
87
+ const directional = Math.pow(Math.max(c, 0), 2 + 14 * (1 - waveDirectionSpread)) + 0.015 * waveDirectionSpread;
88
+ const kh = k * Math.max(depth, 0.2), t = Math.tanh(kh);
89
+ const derivative = G * (t + kh * (1 - t * t)) / (2 * omega);
90
+ const amplitude = Math.sqrt(Math.max(density * directional * derivative / k * dk * dk, 0));
91
+ const gaussian = Math.sqrt(-2 * Math.log(rand())), phase = TAU * rand();
92
+ data.h0r[i] = amplitude * gaussian * Math.cos(phase);
93
+ data.h0i[i] = amplitude * gaussian * Math.sin(phase);
94
+ data.omega[i] = omega;
95
+ }
96
+ this.bands.push(data);
97
+ }
98
+ const expectedVariance = this.bands.reduce((sum, b) => sum + b.h0r.reduce((a, v, i) => a + 2 * (v * v + b.h0i[i] ** 2), 0) / (n ** 4), 0);
99
+ const scale = targetRms / Math.sqrt(Math.max(expectedVariance, 1e-30));
100
+ for (const b of this.bands)
101
+ for (let i = 0; i < n * n; i++) {
102
+ b.h0r[i] *= scale;
103
+ b.h0i[i] *= scale;
104
+ }
105
+ this.peakLength = peakLength;
106
+ this.rms = targetRms;
107
+ }
108
+ update(time) {
109
+ this.time = time;
110
+ const n = this.resolution;
111
+ for (const b of this.bands) {
112
+ for (let z = 0; z < n; z++)
113
+ for (let x = 0; x < n; x++) {
114
+ const i = z * n + x, j = ((n - z) % n) * n + (n - x) % n;
115
+ // Use negative phase: an h0(k) travelling component moves along +k.
116
+ const phase = b.omega[i] * time, c = Math.cos(phase), s = Math.sin(phase);
117
+ const cj = c, sj = s;
118
+ b.real[i] = b.h0r[i] * c + b.h0i[i] * s + b.h0r[j] * cj + b.h0i[j] * sj;
119
+ b.imag[i] = b.h0i[i] * c - b.h0r[i] * s - b.h0i[j] * cj + b.h0r[j] * sj;
120
+ }
121
+ inverseFFT2D(b.real, b.imag, n);
122
+ }
123
+ }
124
+ sample(x, z, minWavelength = 0) {
125
+ let h = 0;
126
+ const n = this.resolution;
127
+ for (const b of this.bands) {
128
+ // Geometry excludes bands below its spatial sampling limit. Full-band
129
+ // normals/caustics may use sample(...,0) on their denser grid.
130
+ const gx = ((x / b.length % 1) + 1) % 1 * n, gz = ((z / b.length % 1) + 1) % 1 * n;
131
+ const ix = Math.floor(gx), iz = Math.floor(gz), tx = gx - ix, tz = gz - iz;
132
+ const a = iz * n + ix, bb = iz * n + (ix + 1) % n, c = ((iz + 1) % n) * n + ix, d = ((iz + 1) % n) * n + (ix + 1) % n;
133
+ h += (b.real[a] * (1 - tx) + b.real[bb] * tx) * (1 - tz) + (b.real[c] * (1 - tx) + b.real[d] * tx) * tz;
134
+ }
135
+ return h;
136
+ }
137
+ }
@@ -5,7 +5,7 @@
5
5
 
6
6
  export const BEACH_RUNUP_DISTANCE = 10;
7
7
  export const BEACH_WATER_LEVEL = 0.36;
8
- export const BEACH_WAVE_SPEED = 0.35;
8
+ export const BEACH_WAVE_SPEED = 0.65;
9
9
  export const BEACH_DIRECTION_SPREAD = 0.18;
10
10
 
11
11
  export const SHOREWARD_ORIENTATION = Object.freeze({
@@ -14,8 +14,13 @@ export const SHOREWARD_ORIENTATION = Object.freeze({
14
14
  });
15
15
 
16
16
  export function waterStageOverrides(stage, baseSettings = {}) {
17
+ const shoreResponse = {
18
+ ...(Number.isFinite(baseSettings.shorelineRunup) ? { shorelineRunup: baseSettings.shorelineRunup } : {}),
19
+ ...(Number.isFinite(baseSettings.breakerAmount) ? { breakerAmount: baseSettings.breakerAmount } : {}),
20
+ };
17
21
  if (stage === 'beach') {
18
22
  return {
23
+ ...shoreResponse,
19
24
  flowDirection: [...SHOREWARD_ORIENTATION.flowDirection],
20
25
  runupDistance: BEACH_RUNUP_DISTANCE,
21
26
  waterLevel: BEACH_WATER_LEVEL,
@@ -24,8 +29,20 @@ export function waterStageOverrides(stage, baseSettings = {}) {
24
29
  waveDirectionSpread: BEACH_DIRECTION_SPREAD,
25
30
  };
26
31
  }
32
+ if (String(stage ?? '').startsWith('river')) {
33
+ return {
34
+ flowDirection: [1, 0],
35
+ waveDirection: [1, 0],
36
+ runupDistance: 0,
37
+ shorelineRunup: 0,
38
+ breakerAmount: 0,
39
+ ...(Number.isFinite(baseSettings.waterLevel) ? { waterLevel: baseSettings.waterLevel } : {}),
40
+ ...(Number.isFinite(baseSettings.waveSpeed) ? { waveSpeed: baseSettings.waveSpeed } : {}),
41
+ };
42
+ }
27
43
  if (stage === 'shore') {
28
44
  return {
45
+ ...shoreResponse,
29
46
  flowDirection: [...SHOREWARD_ORIENTATION.flowDirection],
30
47
  runupDistance: 0,
31
48
  ...(Number.isFinite(baseSettings.waterLevel)
@@ -42,6 +59,7 @@ export function waterStageOverrides(stage, baseSettings = {}) {
42
59
  }
43
60
  if (stage === 'open') {
44
61
  return {
62
+ ...shoreResponse,
45
63
  ...(baseSettings.flowDirection ? { flowDirection: [...baseSettings.flowDirection] } : {}),
46
64
  runupDistance: 0,
47
65
  ...(Number.isFinite(baseSettings.waterLevel)