@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
@@ -476,6 +476,78 @@ function descriptionForField(group, key) {
476
476
  // whenever a new numeric setting would otherwise get a nonsense slider —
477
477
  // name-pattern inference is a fallback, not a contract.
478
478
  const FIELD_RANGE_OVERRIDES = Object.freeze({
479
+ 'averageShadow.defaultStrength': { max: 1, min: 0, step: 0.01 },
480
+ 'averageShadow.eyeStrength': { max: 1, min: 0, step: 0.01 },
481
+ 'averageShadow.faceStrength': { max: 1, min: 0, step: 0.01 },
482
+ 'averageShadow.hairStrength': { max: 1, min: 0, step: 0.01 },
483
+ 'averageShadow.skinStrength': { max: 1, min: 0, step: 0.01 },
484
+ 'celShade.bodyCelSoftness': { max: 1, min: 0.001, step: 0.001 },
485
+ 'celShade.edgeAntiAliasStrength': { max: 4, min: 0, step: 0.01 },
486
+ 'contactShadow.faceStrength': { max: 1, min: 0, step: 0.01 },
487
+ 'contactShadow.fadeRange': { max: 1, min: 0.01, step: 0.001 },
488
+ 'contactShadow.thresholdOffset': { max: 1, min: -0.05, step: 0.005 },
489
+ 'eyeHighlight.maskStrength': { max: 1, min: 0, step: 0.01 },
490
+ 'eyeHighlight.power': { max: 44, min: 1, step: 0.01 },
491
+ 'faceLighting.faceCelSoftness': { max: 1, min: 0.001, step: 0.001 },
492
+ 'faceLighting.faceSceneShadowStrength': { max: 1, min: 0, step: 0.01 },
493
+ 'fur.density': { max: 6, min: 0.1, step: 0.01 },
494
+ 'fur.rootOffset': { max: 0, min: -1, step: 0.005 },
495
+ 'fur.shellCount': { max: 16, min: 1, step: 0.01 },
496
+ 'glitter.density': { max: 1, min: 0.01, step: 0.01 },
497
+ 'glitter.randomNormalStrength': { max: 1, min: 0, step: 0.01 },
498
+ 'glitter.size': { max: 1, min: 0.05, step: 0.01 },
499
+ 'hairHighlight.maskStrength': { max: 1, min: 0, step: 0.01 },
500
+ 'hairHighlight.uvBandHalfWidth': { max: 0.08, min: 0.001, step: 0.0005 },
501
+ 'materialMaps.aoStrength': { max: 1, min: 0, step: 0.01 },
502
+ 'materialMaps.detailStrength': { max: 1, min: 0, step: 0.01 },
503
+ 'materialMaps.matcapStrength': { max: 1, min: 0, step: 0.01 },
504
+ 'materialMaps.metalnessStrength': { max: 1, min: 0, step: 0.01 },
505
+ 'materialMaps.normalStrength': { max: 1, min: 0, step: 0.01 },
506
+ 'materialMaps.rampStrength': { max: 1, min: 0, step: 0.01 },
507
+ 'materialMaps.roughnessStrength': { max: 1, min: 0, step: 0.01 },
508
+ 'materialMaps.specularColorStrength': { max: 1, min: 0, step: 0.01 },
509
+ 'outline.defaultWidth': { max: 0.05, min: 0, step: 0.0005 },
510
+ 'outline.depthOffset': { max: 0.05, min: 0, step: 0.005 },
511
+ 'outline.eyeWidth': { max: 0.05, min: 0, step: 0.0005 },
512
+ 'outline.faceWidth': { max: 0.05, min: 0, step: 0.0005 },
513
+ 'outline.hairCutoutWidth': { max: 0.05, min: 0, step: 0.0005 },
514
+ 'outline.hairWidth': { max: 0.05, min: 0, step: 0.0005 },
515
+ 'outline.maxWidth': { max: 0.05, min: 0, step: 0.0005 },
516
+ 'outline.metalWidth': { max: 0.05, min: 0, step: 0.0005 },
517
+ 'outline.skinWidth': { max: 0.05, min: 0, step: 0.0005 },
518
+ 'outline.transparentOverlayWidth': { max: 0.05, min: 0, step: 0.0005 },
519
+ 'perspectiveRemoval.radius': { max: 2.8, min: 0.05, step: 0.01 },
520
+ 'rimLight.depthFadeRange': { max: 1, min: 0.01, step: 0.001 },
521
+ 'rimLight.depthSafeDistance': { max: 1, min: 0.05, step: 0.01 },
522
+ 'rimLight.depthThresholdOffset': { max: 1, min: -0.05, step: 0.005 },
523
+ 'rimLight.softness': { max: 1, min: 0.001, step: 0.01 },
524
+ 'sceneShadow.defaultStrength': { max: 1, min: 0, step: 0.01 },
525
+ 'sceneShadow.eyeStrength': { max: 1, min: 0, step: 0.01 },
526
+ 'sceneShadow.faceStrength': { max: 1, min: 0, step: 0.01 },
527
+ 'sceneShadow.shadowAreaStrength': { max: 1, min: 0, step: 0.01 },
528
+ 'sceneShadow.skinStrength': { max: 1, min: 0, step: 0.01 },
529
+ 'selfShadow.defaultStrength': { max: 1, min: 0, step: 0.01 },
530
+ 'selfShadow.eyeStrength': { max: 1, min: 0, step: 0.01 },
531
+ 'selfShadow.faceStrength': { max: 1, min: 0, step: 0.01 },
532
+ 'selfShadow.hairStrength': { max: 1, min: 0, step: 0.01 },
533
+ 'selfShadow.shadowAreaStrength': { max: 1, min: 0, step: 0.01 },
534
+ 'selfShadow.skinStrength': { max: 1, min: 0, step: 0.01 },
535
+ 'shadowColor.selfShadowAlbedoMulStrength': { max: 1, min: 0, step: 0.01 },
536
+ 'shadowColor.selfShadowAreaHSVStrength': { max: 1, min: 0, step: 0.01 },
537
+ 'shadowColor.selfShadowAreaSaturationBoost': { max: 1, min: 0, step: 0.01 },
538
+ 'shadowColor.selfShadowAreaValueMul': { max: 1, min: 0, step: 0.01 },
539
+ 'shadowColor.transitionAreaSaturationBoost': { max: 1, min: 0, step: 0.01 },
540
+ 'shadowColor.transitionAreaValueMul': { max: 1, min: 0, step: 0.01 },
541
+ 'skinTone.faceMaxDirectLight': { max: 8, min: 0.05, step: 0.05 },
542
+ 'skinTone.faceShadowTintStrength': { max: 1, min: 0, step: 0.01 },
543
+ 'skinTone.skinMaxDirectLight': { max: 8, min: 0.05, step: 0.05 },
544
+ 'skinTone.skinShadowTintStrength': { max: 1, min: 0, step: 0.01 },
545
+ 'specular.defaultMidPoint': { max: 1, min: 0, step: 0.005 },
546
+ 'specular.defaultRange': { max: 1, min: 0.001, step: 0.001 },
547
+ 'specular.eyeMidPoint': { max: 1, min: 0, step: 0.005 },
548
+ 'specular.eyeRange': { max: 1, min: 0.001, step: 0.001 },
549
+ 'specular.maskStrength': { max: 1, min: 0, step: 0.01 },
550
+
479
551
  'outline.referenceDistance': { max: 20, min: 0.5, step: 0.1 },
480
552
  'outline.referenceFov': { max: 120, min: 10, step: 1 },
481
553
  'outline.widthFadeDistance': { max: 100, min: 1, step: 0.5 },
@@ -509,6 +581,7 @@ function isColorField(key, value) {
509
581
 
510
582
  function fieldTypeFor(groupId, key, value) {
511
583
  const fieldId = `${groupId}.${key}`;
584
+ if (groupId === 'indirectLight' && ['faceMinimumIndirectLight', 'skinMinimumIndirectLight'].includes(key)) return 'number';
512
585
  if (FIELD_TYPE_OVERRIDES[fieldId]) return FIELD_TYPE_OVERRIDES[fieldId];
513
586
  if (SELECT_FIELD_OPTIONS[fieldId]) return 'select';
514
587
  if (typeof value === 'boolean') return 'boolean';
@@ -777,6 +850,7 @@ function coerceFieldValue(value, field) {
777
850
  case 'boolean':
778
851
  return booleanFromValue(value, fallback);
779
852
  case 'number':
853
+ if (value === null && fallback === null) return null;
780
854
  return numberFromValue(value, fallback);
781
855
  case 'select':
782
856
  return selectFromValue(value, fallback, field.options);
@@ -277,6 +277,12 @@ export class StylizedFlower extends StylizedTree {
277
277
  : [options.sky.r, options.sky.g, options.sky.b];
278
278
  uniforms.uSkyColor.value.setRGB(...sky, THREE.SRGBColorSpace);
279
279
  }
280
+ if (Number.isFinite(options.intensity) && uniforms.uSunIntensity) {
281
+ uniforms.uSunIntensity.value = Math.max(options.intensity, 0);
282
+ }
283
+ if (Number.isFinite(options.skyIntensity) && uniforms.uSkyIntensity) {
284
+ uniforms.uSkyIntensity.value = Math.max(options.skyIntensity, 0);
285
+ }
280
286
  return this;
281
287
  }
282
288
 
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Single runtime source of truth for package consumers and ToonLab Pro/MCP.
2
2
  // Keep this value synchronized with package.json during release preparation.
3
- export const TOONLAB_VERSION = '0.4.21';
3
+ export const TOONLAB_VERSION = '0.4.23';
@@ -0,0 +1,91 @@
1
+ import * as THREE from 'three';
2
+ import { inverseFFT2D } from './waterSpectralOcean.js';
3
+
4
+ // Short wind waves: many independently seeded Fourier modes, rather than
5
+ // a handful of full-strength plane waves. Both optics paths sample this field.
6
+ export class WaterDetailSpectrum {
7
+ constructor() {
8
+ this.size = 64;
9
+ this.signature = '';
10
+ this.time = NaN;
11
+ this.bands = [17, 7.3].map((length) => {
12
+ const n = this.size ** 2;
13
+ const band = { length, baseLength: length };
14
+ for (const key of ['r', 'i', 'kx', 'kz', 'omega', 'xr', 'xi', 'zr', 'zi']) band[key] = new Float64Array(n);
15
+ band.data = new Float32Array(n * 4);
16
+ band.texture = new THREE.DataTexture(band.data, this.size, this.size, THREE.RGBAFormat, THREE.FloatType);
17
+ band.texture.wrapS = band.texture.wrapT = THREE.RepeatWrapping;
18
+ band.texture.minFilter = THREE.LinearMipmapLinearFilter;
19
+ band.texture.magFilter = THREE.LinearFilter;
20
+ band.texture.generateMipmaps = true;
21
+ return band;
22
+ });
23
+ }
24
+ configure(settings) {
25
+ const direction = Math.atan2(settings.waveDirection[1], settings.waveDirection[0]);
26
+ const signature = JSON.stringify([direction, settings.detailScale]);
27
+ if (signature === this.signature) return;
28
+ this.signature = signature;
29
+ this.time = NaN;
30
+ const n = this.size, tau = 2 * Math.PI;
31
+ this.bands.forEach((band, index) => {
32
+ let seed = 9731 + index * 1237;
33
+ const random = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) + 0.5) / 4294967296;
34
+ band.length = band.baseLength / settings.detailScale;
35
+ let variance = 0;
36
+ for (let z = 0; z < n; z++) for (let x = 0; x < n; x++) {
37
+ const i = z * n + x;
38
+ const fx = x < n / 2 ? x : x - n, fz = z < n / 2 ? z : z - n;
39
+ const baseK = Math.hypot(fx, fz) * tau / band.baseLength;
40
+ const wavelength = tau / Math.max(baseK, 1e-8);
41
+ const valid = index === 0 ? wavelength >= 0.95 && wavelength < 3.6 : wavelength >= 0.34 && wavelength < 0.95;
42
+ const kx = fx * tau / band.length, kz = fz * tau / band.length, k = Math.hypot(kx, kz);
43
+ band.kx[i] = kx; band.kz[i] = kz;
44
+ band.omega[i] = Math.sqrt(9.81 * k + 0.000074 * k ** 3);
45
+ // Broad directional spread and a broad frequency envelope keep one
46
+ // orientation or wavelength from becoming visible as a repeated grid.
47
+ const wind = Math.max(0, Math.cos(Math.atan2(kz, kx) - direction));
48
+ const envelope = Math.exp(-0.5 * (Math.log(wavelength / (index ? 0.6 : 1.7)) / 0.6) ** 2);
49
+ const amplitude = valid ? Math.sqrt(envelope * (0.12 + wind ** 4)) / Math.max(k * k, 1e-8) : 0;
50
+ const gaussian = Math.sqrt(-2 * Math.log(random())), phase = tau * random();
51
+ band.r[i] = amplitude * gaussian * Math.cos(phase);
52
+ band.i[i] = amplitude * gaussian * Math.sin(phase);
53
+ variance += 2 * (band.r[i] ** 2 + band.i[i] ** 2) * k * k / n ** 4;
54
+ }
55
+ const gain = (index ? 0.045 : 0.075) / Math.sqrt(Math.max(variance, 1e-20));
56
+ for (let i = 0; i < n * n; i++) { band.r[i] *= gain; band.i[i] *= gain; }
57
+ });
58
+ }
59
+ update(time) {
60
+ if (time === this.time) return;
61
+ this.time = time;
62
+ const n = this.size;
63
+ for (const b of this.bands) {
64
+ for (let z = 0; z < n; z++) for (let x = 0; x < n; x++) {
65
+ const i = z * n + x, j = ((n - z) % n) * n + (n - x) % n;
66
+ const phase = b.omega[i] * time, c = Math.cos(phase), s = Math.sin(phase);
67
+ const r = b.r[i] * c + b.i[i] * s + b.r[j] * c + b.i[j] * s;
68
+ const im = b.i[i] * c - b.r[i] * s - b.i[j] * c + b.r[j] * s;
69
+ b.xr[i] = -im * b.kx[i]; b.xi[i] = r * b.kx[i];
70
+ b.zr[i] = -im * b.kz[i]; b.zi[i] = r * b.kz[i];
71
+ }
72
+ inverseFFT2D(b.xr, b.xi, n); inverseFFT2D(b.zr, b.zi, n);
73
+ for (let i = 0; i < n * n; i++) { b.data[i * 4] = b.xr[i]; b.data[i * 4 + 1] = b.zr[i]; }
74
+ b.texture.needsUpdate = true;
75
+ }
76
+ }
77
+ sample(x, z, out) {
78
+ out.x = out.z = 0;
79
+ const n = this.size;
80
+ for (const b of this.bands) {
81
+ const gx = ((x / b.length % 1) + 1) % 1 * n, gz = ((z / b.length % 1) + 1) % 1 * n;
82
+ const ix = Math.floor(gx), iz = Math.floor(gz), tx = gx - ix, tz = gz - iz;
83
+ const a = (iz * n + ix) * 4, c = (((iz + 1) % n) * n + ix) * 4;
84
+ const bb = (iz * n + (ix + 1) % n) * 4, d = (((iz + 1) % n) * n + (ix + 1) % n) * 4;
85
+ out.x += (b.data[a] * (1 - tx) + b.data[bb] * tx) * (1 - tz) + (b.data[c] * (1 - tx) + b.data[d] * tx) * tz;
86
+ out.z += (b.data[a + 1] * (1 - tx) + b.data[bb + 1] * tx) * (1 - tz) + (b.data[c + 1] * (1 - tx) + b.data[d + 1] * tx) * tz;
87
+ }
88
+ return out;
89
+ }
90
+ dispose() { for (const b of this.bands) b.texture.dispose(); }
91
+ }
@@ -0,0 +1,422 @@
1
+ import * as THREE from 'three';
2
+ import { WaterHydrodynamics } from './waterHydrodynamics.js';
3
+ import { WaterSpectralOcean } from './waterSpectralOcean.js';
4
+ import { WaterDetailSpectrum } from './waterDetailSpectrum.js';
5
+ import { WaterRenderExtension } from './waterRenderExtension.js';
6
+ import { WaterFoamParticles } from './waterFoamParticles.js';
7
+ import { buildGerstnerWaves } from './waterSettings.js';
8
+ // One authoritative, sampled water surface. The renderer and CPU contacts
9
+ // interpolate the same triangles. Style never changes the solved geometry.
10
+ export class WaterDynamics {
11
+ constructor(surface, options = {}) {
12
+ this.surface = surface;
13
+ this.isWaterDynamics = true;
14
+ this.options = options;
15
+ this.mode = options.mode ?? 'spectral';
16
+ this.time = 0;
17
+ this.columns = surface.geometry.parameters.widthSegments + 1;
18
+ this.rows = surface.geometry.parameters.heightSegments + 1;
19
+ this.state = new Float32Array(this.columns * this.rows * 4);
20
+ this.normals = new Float32Array(this.columns * this.rows * 3);
21
+ this.history = new Float32Array(this.columns * this.rows * 4);
22
+ this.foamCoordinates = new Float32Array(this.history.length);
23
+ this.previousFoamCoordinates = new Float32Array(this.history.length);
24
+ this.shoreDistances = new Float32Array(this.columns * this.rows);
25
+ this.shoreContour = new Float32Array(this.columns);
26
+ this.causticResolution = 256;
27
+ this.light = new Float32Array(this.causticResolution ** 2 * 4);
28
+ this.causticWork = new Float64Array(this.causticResolution ** 2);
29
+ this.causticRegion = new THREE.Vector4();
30
+ this.previousFoam = new Float32Array(this.columns * this.rows);
31
+ this.stateAttribute = new THREE.BufferAttribute(this.state, 4).setUsage(THREE.DynamicDrawUsage);
32
+ this.normalAttribute = new THREE.BufferAttribute(this.normals, 3).setUsage(THREE.DynamicDrawUsage);
33
+ surface.geometry.setAttribute('aWaterState', this.stateAttribute);
34
+ surface.geometry.setAttribute('aWaterNormal', this.normalAttribute);
35
+ surface.geometry.setAttribute('aWaterWetDistance',new THREE.BufferAttribute(this.shoreDistances,1).setUsage(THREE.DynamicDrawUsage));
36
+ const makeTexture = (array) => { const t = new THREE.DataTexture(array, this.columns, this.rows, THREE.RGBAFormat, THREE.FloatType); t.minFilter = t.magFilter = THREE.LinearFilter; t.generateMipmaps = false; return t; };
37
+ this.texture = makeTexture(this.history);
38
+ this.foamCoordinateTexture = makeTexture(this.foamCoordinates);
39
+ this.causticTexture = new THREE.DataTexture(this.light, this.causticResolution, this.causticResolution, THREE.RGBAFormat, THREE.FloatType);
40
+ this.causticTexture.minFilter = this.causticTexture.magFilter = THREE.LinearFilter;
41
+ this.causticTexture.generateMipmaps = false;
42
+ this.spectrum = new WaterSpectralOcean({ resolution: options.spectralResolution ?? 32 });
43
+ this.normalSpectrum = new WaterSpectralOcean({ resolution: options.spectralResolution ?? 32 });
44
+ this.detailSpectrum = new WaterDetailSpectrum();
45
+ const resolution = this.normalSpectrum.resolution;
46
+ this.normalTextures = Array.from({ length: 3 }, () => {
47
+ const texture = new THREE.DataTexture(new Float32Array(resolution * resolution * 4), resolution, resolution, THREE.RGBAFormat, THREE.FloatType);
48
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
49
+ texture.minFilter = texture.magFilter = THREE.LinearFilter;
50
+ texture.generateMipmaps = false;
51
+ return texture;
52
+ });
53
+ this.gridSignature = '';
54
+ this.reference = false;
55
+ this.paused = false;
56
+ this.pendingStep = 0;
57
+ this.sampleScratch = {};
58
+ this.settingsSignature = '';
59
+ }
60
+ getRegion(out = new THREE.Vector4()) { return out.set(this.x ?? 0, this.z ?? 0, (this.surface.width + (this.dx ?? 0)) / 2, (this.surface.depth + (this.dz ?? 0)) / 2); }
61
+ rebuild() {
62
+ this.foamParticles?.dispose();this.foamParticles=null;
63
+ this.renderExtension?.dispose();
64
+ this.renderExtension = null;
65
+ const s = this.surface, p = s.getWorldPosition(new THREE.Vector3());
66
+ this.x = p.x;
67
+ this.z = p.z;
68
+ this.level = p.y;
69
+ this.dx = s.width / (this.columns - 1);
70
+ this.dz = s.depth / (this.rows - 1);
71
+ this.minX = this.x - s.width / 2;
72
+ this.minZ = this.z - s.depth / 2;
73
+ this.bed = s.bedHeightSampler ?? (() => this.level - 20);
74
+ if (this.mode !== 'spectral') {
75
+ this.hydro = new WaterHydrodynamics({ columns: this.columns, rows: this.rows, width: s.width, depth: s.depth, centerX: this.x, centerZ: this.z, waterLevel: this.level, bedHeight: this.bed,
76
+ boundary: (side, x, z, time, ghost) => this.boundary(side, x, z, time, ghost), friction: this.mode === 'tank' ? 0 : 0.018 });
77
+ if (this.mode === 'river') {
78
+ for (let i = 0; i < this.hydro.length; i++)
79
+ this.hydro.qx[i] = this.hydro.h[i] * (s.settings.flowSpeed ?? 0);
80
+ }
81
+ }
82
+ else
83
+ this.hydro = null;
84
+ this.interactions = null;
85
+ this.history.fill(0);
86
+ for(let z=0;z<this.rows;z++)for(let x=0;x<this.columns;x++) {
87
+ const i=(z*this.columns+x)*4;
88
+ this.foamCoordinates[i]=this.minX+x*this.dx;
89
+ this.foamCoordinates[i+1]=this.minZ+z*this.dz;
90
+ }
91
+ if (this.hydro) this.time = 0;
92
+ this.warmupRemaining = this.hydro ? Math.max(0, this.options.warmupSeconds ?? 0) : 0;
93
+ this.spectralTime ??= 0;
94
+ }
95
+ boundary(side, x, z, time, ghost) {
96
+ const settings = this.surface.renderedSettings ?? this.surface.settings;
97
+ if (this.mode === 'river' && (side === 'west' || side === 'east')) {
98
+ if (side === 'west') {
99
+ ghost.h = Math.max(0, this.level - ghost.bed);
100
+ ghost.qx = ghost.h * settings.flowSpeed;
101
+ ghost.qz = 0;
102
+ }
103
+ return true; // east is a zero-gradient outflow; north/south remain walls
104
+ }
105
+ if (this.mode === 'coast' && side === 'south') {
106
+ const h0 = Math.max(this.level - ghost.bed, 0), c = Math.sqrt(9.81 * Math.max(h0, 0.01));
107
+ if (h0 <= 1e-5) {
108
+ ghost.h = ghost.qx = ghost.qz = 0;
109
+ return true;
110
+ }
111
+ // Prescribe only the incoming characteristic. Retain the outgoing one
112
+ // from the interior so reflected waves can leave through this boundary.
113
+ let elevation = 0;
114
+ for (const w of this.incidentWaves ?? [])
115
+ elevation += w.amplitude * Math.sin(w.waveNumber * (w.dirX * x + w.dirZ * z) - w.omega * time + w.phase);
116
+ // Bound incident height by the available column. Lowering the
117
+ // water level must not inject a singular shallow-boundary jet.
118
+ elevation = h0 * 0.35 * Math.tanh(elevation / (h0 * 0.35));
119
+ elevation *= Math.min(time / 3, 1); // smooth startup avoids an impulsive boundary step
120
+ const incoming = 2 * c + 2 * Math.sqrt(9.81 / h0) * elevation;
121
+ const outgoing = (ghost.h > 1e-5 ? ghost.qz / ghost.h : 0) - 2 * Math.sqrt(9.81 * ghost.h);
122
+ const v = (incoming + outgoing) / 2, cb = Math.max((incoming - outgoing) / 4, 0);
123
+ ghost.h = cb * cb / 9.81;
124
+ ghost.qz = ghost.h * v;
125
+ ghost.qx = 0;
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+ update(delta) {
131
+ const s = this.surface, p = s.getWorldPosition(new THREE.Vector3());
132
+ const signature = JSON.stringify([p.x, p.y, p.z, this.mode]);
133
+ if (signature !== this.gridSignature || this.bed !== s.bedHeightSampler && s.bedHeightSampler) {
134
+ this.gridSignature = signature;
135
+ this.rebuild();
136
+ }
137
+ const dt = this.paused ? this.pendingStep : Math.max(0, Math.min(delta, 0.1));
138
+ let transportDelta = dt;
139
+ this.pendingStep = 0;
140
+ this.time += dt;
141
+ const settings = s.renderedSettings ?? s.settings;
142
+ const sig = JSON.stringify([settings.waveAmplitude, settings.waveIntensity, settings.waveLength, settings.waveDirection, settings.waveDirectionSpread, settings.waveSpeed, settings.waveSetPeriod, settings.waveSetStrength]);
143
+ if (sig !== this.settingsSignature) {
144
+ this.settingsSignature = sig;
145
+ this.incidentWaves = buildGerstnerWaves(settings);
146
+ }
147
+ if (this.hydro) {
148
+ this.hydro.foamLifetime = settings.swashFoamLifetime;
149
+ this.hydro.foamResidueLifetime = settings.swashFoamResidueLifetime;
150
+ // Amortize the physical pre-roll over frames; never fake the
151
+ // shoreline position or add an unaccounted water source.
152
+ const preroll = dt > 0 ? Math.min(this.warmupRemaining, 0.25) : 0;
153
+ transportDelta += preroll;
154
+ this.hydro.advance(dt + preroll);
155
+ this.warmupRemaining -= preroll;
156
+ this.time = this.hydro.time;
157
+ }
158
+ else {
159
+ this.spectrum.configure(settings, Math.max(1, this.level - this.bed(this.x, this.z)), Math.max(this.dx, this.dz) * 2.5);
160
+ this.spectralTime += (dt * settings.waveSpeed);
161
+ this.spectrum.update(this.spectralTime);
162
+ this.normalSpectrum.configure(settings, Math.max(1, this.level - this.bed(this.x, this.z)));
163
+ this.normalSpectrum.update(this.spectralTime);
164
+ const lengths = [];
165
+ this.normalSpectrum.bands.forEach((band, index) => {
166
+ const n = band.size, step = band.length / n, data = this.normalTextures[index].image.data;
167
+ for (let z = 0; z < n; z++)
168
+ for (let x = 0; x < n; x++) {
169
+ const i = z * n + x;
170
+ data[i * 4] = (band.real[z * n + (x + 1) % n] - band.real[z * n + (x + n - 1) % n]) / (2 * step);
171
+ data[i * 4 + 1] = (band.real[((z + 1) % n) * n + x] - band.real[((z + n - 1) % n) * n + x]) / (2 * step);
172
+ }
173
+ this.normalTextures[index].needsUpdate = true;
174
+ this.surface.material.uniforms['uSpectralNormal' + index].value = this.normalTextures[index];
175
+ lengths.push(band.length);
176
+ });
177
+ this.surface.material.uniforms.uSpectralLengths.value.set(...lengths);
178
+ this.interactions?.advance(dt);
179
+ }
180
+ const nx = this.columns, nz = this.rows;
181
+ let minimumY = Infinity, maximumY = -Infinity;
182
+ for (let i=0;i<this.previousFoam.length;i++) this.previousFoam[i]=this.history[i*4+2];
183
+ for (let iz = 0; iz < nz; iz++)
184
+ for (let ix = 0; ix < nx; ix++) {
185
+ const i = iz * nx + ix, j = i * 4, x = this.minX + ix * this.dx, z = this.minZ + iz * this.dz, bed = this.bed(x, z);
186
+ let height, depth, u = 0, v = 0, foam = 0;
187
+ if (this.hydro) {
188
+ depth = this.hydro.h[i];
189
+ height = bed + depth;
190
+ u = depth > 1e-5 ? this.hydro.qx[i] / depth : 0;
191
+ v = depth > 1e-5 ? this.hydro.qz[i] / depth : 0;
192
+ foam = this.hydro.foam[i];
193
+ }
194
+ else {
195
+ const restDepth = Math.max(this.level - bed, 0);
196
+ const wave = this.spectrum.sample(x - settings.flowDirection[0] * settings.flowSpeed * this.time, z - settings.flowDirection[1] * settings.flowSpeed * this.time)
197
+ + (this.interactions ? this.interactions.h[i] + this.interactions.bed[i] - this.level : 0);
198
+ // Offshore linear waves taper smoothly as their depth-limited height
199
+ // is approached; no clipped plateaus or disconnected curling shells.
200
+ const allowance = 0.35 * restDepth;
201
+ const displacement = allowance > 1e-5 ? allowance * Math.tanh(wave / allowance) : 0;
202
+ height = this.level + displacement;
203
+ depth = Math.max(height - bed, 0);
204
+ u = settings.flowDirection[0] * settings.flowSpeed;
205
+ v = settings.flowDirection[1] * settings.flowSpeed;
206
+ if (this.interactions && this.interactions.h[i] > 1e-5) {
207
+ u += this.interactions.qx[i] / this.interactions.h[i];
208
+ v += this.interactions.qz[i] / this.interactions.h[i];
209
+ }
210
+ }
211
+ minimumY = Math.min(minimumY, height);
212
+ maximumY = Math.max(maximumY, height);
213
+ this.state[j] = height;
214
+ this.state[j + 1] = depth;
215
+ this.state[j + 2] = u;
216
+ this.state[j + 3] = v;
217
+ this.history[j] = depth > 0.002 ? 1 : this.history[j] * Math.exp(-dt / Math.max(settings.wetSandDryTime, 1));
218
+ this.history[j + 1] = depth; // solved field stores metres, not a binary wet-film flag
219
+ if (this.hydro)
220
+ this.history[j + 2] = foam;
221
+ this.history[j + 3] = foam * (depth < 0.01 ? 1 : 0);
222
+ }
223
+ // Frame and optical focusing derive from this exact surface, including
224
+ // wet/dry cells. No unrelated caustic animation clock or noise texture.
225
+ for (let iz = 0; iz < nz; iz++)
226
+ for (let ix = 0; ix < nx; ix++) {
227
+ const i = iz * nx + ix, j = i * 4, left = (iz * nx + Math.max(0, ix - 1)) * 4, right = (iz * nx + Math.min(nx - 1, ix + 1)) * 4;
228
+ const down = (Math.max(0, iz - 1) * nx + ix) * 4, up = (Math.min(nz - 1, iz + 1) * nx + ix) * 4;
229
+ const gx = (this.state[right] - this.state[left]) / (this.dx * (ix > 0 && ix < nx - 1 ? 2 : 1));
230
+ const gz = (this.state[up] - this.state[down]) / (this.dz * (iz > 0 && iz < nz - 1 ? 2 : 1));
231
+ const inv = 1 / Math.hypot(gx, 1, gz);
232
+ this.normals[i * 3] = -gx * inv;
233
+ this.normals[i * 3 + 1] = inv;
234
+ this.normals[i * 3 + 2] = -gz * inv;
235
+ if (!this.hydro) {
236
+ const source = Math.max(0, Math.hypot(gx, gz) - 0.24) * 3;
237
+ const tracedX = Math.max(0, Math.min(nx-1, ix-this.state[j+2]*dt/this.dx));
238
+ const tracedZ = Math.max(0, Math.min(nz-1, iz-this.state[j+3]*dt/this.dz));
239
+ const x0 = Math.min(nx-2, Math.floor(tracedX)), z0 = Math.min(nz-2, Math.floor(tracedZ));
240
+ const tx = tracedX-x0, tz = tracedZ-z0, fi=z0*nx+x0, old=this.previousFoam;
241
+ const advected = (old[fi]*(1-tx)+old[fi+1]*tx)*(1-tz)+(old[fi+nx]*(1-tx)+old[fi+nx+1]*tx)*tz;
242
+ this.history[j + 2] = Math.min(1, advected * Math.exp(-dt / 3) + source * dt);
243
+ }
244
+ }
245
+ this.updateShoreContour();
246
+ this.updateFoamCoordinates(transportDelta);
247
+ if(this.options.foamParticles&&this.hydro) {
248
+ this.foamParticles ??= new WaterFoamParticles(this);
249
+ this.foamParticles.update(transportDelta);
250
+ }
251
+ s.geometry.boundingBox ??= new THREE.Box3();
252
+ s.geometry.boundingBox.min.set(-s.width / 2, minimumY - this.level, -s.depth / 2);
253
+ s.geometry.boundingBox.max.set(s.width / 2, maximumY - this.level, s.depth / 2);
254
+ s.geometry.boundingSphere ??= new THREE.Sphere();
255
+ s.geometry.boundingBox.getBoundingSphere(s.geometry.boundingSphere);
256
+ if (this.options.renderExtent && this.mode === 'coast') {
257
+ this.renderExtension ??= new WaterRenderExtension(this, this.options.renderExtent);
258
+ this.renderExtension.update();
259
+ }
260
+ this.detailSpectrum.configure(settings);
261
+ this.detailSpectrum.update(this.time);
262
+ this.detailSpectrum.bands.forEach((band, i) => {
263
+ this.surface.material.uniforms['uDetailNormal' + i].value = band.texture;
264
+ });
265
+ this.surface.material.uniforms.uDetailLengths.value.set(...this.detailSpectrum.bands.map(b => b.length));
266
+ this.updateCaustics();
267
+ this.stateAttribute.needsUpdate = true;
268
+ this.normalAttribute.needsUpdate = true;
269
+ this.texture.needsUpdate = true;
270
+ this.causticTexture.needsUpdate = true;
271
+ this.surface.material.uniforms.uReferenceView.value = this.reference ? 1 : 0;
272
+ this.surface.material.uniforms.uUseShoreContour.value = this.options.shorelineAxis === 'z' ? 1 : 0;
273
+ this.surface.material.uniforms.uFoamCoordinates.value = this.foamCoordinateTexture;
274
+ this.surface.material.uniforms.uContinueShoreAlongX.value = this.renderExtension ? 1 : 0;
275
+ this.surface.material.uniforms.uUseSpectralNormals.value = this.hydro ? 0 : 1;
276
+ if (this.mode === 'tank')
277
+ this.surface.material.uniforms.uWaveEnergy.value = 0;
278
+ }
279
+ updateShoreContour() {
280
+ if(this.options.shorelineAxis !== 'z') return;
281
+ for(let x=0;x<this.columns;x++) {
282
+ let edge=this.state[x*4+1]>.002 ? this.minZ+this.surface.depth+this.dz : this.minZ-this.dz;
283
+ for(let z=0;z<this.rows-1;z++) {
284
+ const a=this.state[(z*this.columns+x)*4+1],b=this.state[((z+1)*this.columns+x)*4+1];
285
+ if(a>=.002&&b<.002){
286
+ const i=z*this.columns+x,next=i+this.columns,previous=Math.max(0,z-1)*this.columns+x;
287
+ const eta=this.state[i*4],etaSlope=z>0?eta-this.state[previous*4]:0;
288
+ const nextBed=this.state[next*4]-b;
289
+ // Dry cells store h=0, not the negative signed depth of
290
+ // the water/bed intersection. Extrapolate the adjacent
291
+ // wet free surface before finding that intersection.
292
+ const signedNext=Math.min(b,eta+etaSlope-nextBed);
293
+ edge=this.minZ+(z+(a-.002)/Math.max(a-signedNext,1e-8))*this.dz;
294
+ break;
295
+ }
296
+ }
297
+ this.shoreContour[x]=edge;
298
+ for(let z=0;z<this.rows;z++)this.shoreDistances[z*this.columns+x]=edge-(this.minZ+z*this.dz);
299
+ }
300
+ this.surface.geometry.attributes.aWaterWetDistance.needsUpdate=true;
301
+ }
302
+ updateFoamCoordinates(dt) {
303
+ const old=this.previousFoamCoordinates,nx=this.columns,nz=this.rows;
304
+ old.set(this.foamCoordinates);
305
+ for(let z=0;z<nz;z++)for(let x=0;x<nx;x++) {
306
+ const i=(z*nx+x)*4;
307
+ if(this.history[i+2]<.002) {
308
+ this.foamCoordinates[i]=this.minX+x*this.dx;this.foamCoordinates[i+1]=this.minZ+z*this.dz;
309
+ continue;
310
+ }
311
+ const gx=Math.max(0,Math.min(nx-1,x-this.state[i+2]*dt/this.dx));
312
+ const gz=Math.max(0,Math.min(nz-1,z-this.state[i+3]*dt/this.dz));
313
+ const a=Math.min(nx-2,Math.floor(gx)),b=Math.min(nz-2,Math.floor(gz)),tx=gx-a,tz=gz-b,j=(b*nx+a)*4;
314
+ for(let c=0;c<2;c++)this.foamCoordinates[i+c]=(old[j+c]*(1-tx)+old[j+4+c]*tx)*(1-tz)+(old[j+nx*4+c]*(1-tx)+old[j+(nx+1)*4+c]*tx)*tz;
315
+ }
316
+ this.foamCoordinateTexture.needsUpdate=true;
317
+ }
318
+ updateCaustics() {
319
+ // Resolve focusing at a finer scale than the water mesh. A metre-wide
320
+ // ray grid cannot see the sub-metre ripples responsible for caustics.
321
+ const n=this.causticResolution, size=32, step=size/n;
322
+ const center=this.options.causticsCenter ?? [this.x,this.z];
323
+ this.causticRegion.set(center[0],center[1],size/2,size/2);
324
+ this.causticWork.fill(0);
325
+ const settings=this.surface.renderedSettings ?? this.surface.settings;
326
+ const sun=settings.sunDirection,l=Math.hypot(...sun)||1;
327
+ const ix=-sun[0]/l,iy=-sun[1]/l,iz=-sun[2]/l,eta=1/settings.indexOfRefraction;
328
+ if(iy>-0.02 || settings.causticsStrength<=0) {this.light.fill(0);return;}
329
+ const activity=this.mode==='tank'?0:Math.min(this.surface.waveEnergy/0.35,1);
330
+ const styleGain=this.reference?1:settings.detailNormalStrength*0.45+1;
331
+ const detailSlope = {x:0,z:0};
332
+ for(let z=0;z<n;z++)for(let x=0;x<n;x++) {
333
+ const wx=center[0]-size/2+(x+0.5)*step,wz=center[1]-size/2+(z+0.5)*step;
334
+ const gx=Math.max(0,Math.min(this.columns-1,(wx-this.minX)/this.dx));
335
+ const gz=Math.max(0,Math.min(this.rows-1,(wz-this.minZ)/this.dz));
336
+ const a=Math.min(this.columns-2,Math.floor(gx)),b=Math.min(this.rows-2,Math.floor(gz)),tx=gx-a,tz=gz-b;
337
+ const i=b*this.columns+a;
338
+ const sample=(array,stride,component)=>
339
+ (array[i*stride+component]*(1-tx)+array[(i+1)*stride+component]*tx)*(1-tz)+
340
+ (array[(i+this.columns)*stride+component]*(1-tx)+array[(i+this.columns+1)*stride+component]*tx)*tz;
341
+ const h=sample(this.state,4,1);if(h<0.02)continue;
342
+ let nx=sample(this.normals,3,0),ny=sample(this.normals,3,1),nz=sample(this.normals,3,2);
343
+ const ax=wx-settings.flowDirection[0]*settings.flowSpeed*this.time,az=wz-settings.flowDirection[1]*settings.flowSpeed*this.time;
344
+ const wetT=Math.max(0,Math.min(1,(h-0.025)/0.195)), wetRipple=wetT*wetT*(3-2*wetT);
345
+ this.detailSpectrum.sample(ax,az,detailSlope);
346
+ nx-=detailSlope.x*activity*styleGain*wetRipple;
347
+ nz-=detailSlope.z*activity*styleGain*wetRipple;
348
+ const norm=Math.hypot(nx,ny,nz);nx/=norm;ny/=norm;nz/=norm;
349
+ const dot=nx*ix+ny*iy+nz*iz,k=1-eta*eta*(1-dot*dot);if(k<=0)continue;
350
+ const q=eta*dot+Math.sqrt(k),rx=eta*ix-q*nx,ry=eta*iy-q*ny,rz=eta*iz-q*nz;if(ry>=-0.02)continue;
351
+ const surfaceY=sample(this.state,4,0);
352
+ let distance=-h/ry;
353
+ // Refine the intersection against the receiving bed, rather
354
+ // than stopping on a horizontal plane beneath the source ray.
355
+ for(let refine=0;refine<2;refine++) distance=Math.max(0,(this.bed(wx+rx*distance,wz+rz*distance)-surfaceY)/ry);
356
+ const fx=x+rx*distance/step,fz=z+rz*distance/step;
357
+ const x0=Math.floor(fx),z0=Math.floor(fz),u=fx-x0,v=fz-z0;
358
+ const energy=Math.max(0,-dot)*Math.exp(-distance*0.08);
359
+ for(let dz=0;dz<2;dz++)for(let dx=0;dx<2;dx++){
360
+ const xx=x0+dx,zz=z0+dz;if(xx>=0&&xx<n&&zz>=0&&zz<n)this.causticWork[zz*n+xx]+=energy*(dx?u:1-u)*(dz?v:1-v);
361
+ }
362
+ }
363
+ const flatRayY=Math.sqrt(1-eta*eta*(1-iy*iy));
364
+ for(let i=0;i<n*n;i++){
365
+ const wx=center[0]-size/2+(i%n+0.5)*step,wz=center[1]-size/2+(Math.floor(i/n)+0.5)*step;
366
+ const depth=Math.max(0,this.level-this.bed(wx,wz));
367
+ // Display the focusing excess over an unperturbed interface.
368
+ // A perfectly flat tank must not create a glowing caustic wash.
369
+ const unfocused=Math.abs(iy)*Math.exp(-depth/flatRayY*0.08);
370
+ const focused=Math.max(0,this.causticWork[i]-unfocused);
371
+ this.light[i*4]=this.light[i*4+1]=this.light[i*4+2]=focused*3;
372
+ this.light[i*4+3]=1;
373
+ }
374
+ }
375
+ // PlaneGeometry's diagonal is (x+1,z)--(x,z+1). Matching this, rather than
376
+ // bilinear sampling, makes contact height equal the rendered triangles.
377
+ sample(x, z, out = {}) {
378
+ const gx = Math.max(0, Math.min(this.columns - 1, (x - this.minX) / this.dx)), gz = Math.max(0, Math.min(this.rows - 1, (z - this.minZ) / this.dz));
379
+ const ix = Math.min(this.columns - 2, Math.floor(gx)), iz = Math.min(this.rows - 2, Math.floor(gz)), tx = gx - ix, tz = gz - iz;
380
+ const i = iz * this.columns + ix, ids = tx + tz <= 1 ? [i, i + 1, i + this.columns] : [i + this.columns + 1, i + this.columns, i + 1];
381
+ const weights = tx + tz <= 1 ? [1 - tx - tz, tx, tz] : [tx + tz - 1, 1 - tx, 1 - tz];
382
+ for (const [key, offset] of [['height', 0], ['depth', 1], ['velocityX', 2], ['velocityZ', 3]])
383
+ out[key] = ids.reduce((sum, id, k) => sum + this.state[id * 4 + offset] * weights[k], 0);
384
+ out.wet = out.depth > 0.002;
385
+ if(this.options.shorelineAxis === 'z') {
386
+ const edge=this.shoreContour[ix]*(1-tx)+this.shoreContour[ix+1]*tx;
387
+ out.wet=out.depth>0&&z<edge;
388
+ }
389
+ return out;
390
+ }
391
+ impulse(x, z, strength, radius) {
392
+ if (!this.gridSignature)
393
+ this.update(0);
394
+ if (this.hydro)
395
+ this.hydro.impulse(x, z, strength, radius);
396
+ else {
397
+ if (!this.interactions)
398
+ this.interactions = new WaterHydrodynamics({ columns: this.columns, rows: this.rows,
399
+ width: this.surface.width, depth: this.surface.depth, centerX: this.x, centerZ: this.z, waterLevel: this.level, bedHeight: this.bed,
400
+ boundary: () => true, friction: 0.02 });
401
+ this.interactions.impulse(x, z, strength, radius);
402
+ }
403
+ }
404
+ shoreEdgeAt(x = 0, minimumDepth = 0.005) {
405
+ if (!this.hydro) return null;
406
+ const ix = Math.max(0, Math.min(this.columns-1, Math.round((x-this.minX)/this.dx)));
407
+ let edge = null;
408
+ // Follow the connected water from the incoming boundary; ignore any
409
+ // isolated residual puddle when reporting uprush/backwash position.
410
+ for(let z=0;z<this.rows-1;z++) {
411
+ const a=this.state[(z*this.columns+ix)*4+1], b=this.state[((z+1)*this.columns+ix)*4+1];
412
+ if(a>=minimumDepth && b<minimumDepth) {
413
+ edge=this.minZ+(z+(a-minimumDepth)/Math.max(a-b,1e-8))*this.dz;
414
+ break;
415
+ }
416
+ }
417
+ return edge;
418
+ }
419
+ diagnostics() { return this.hydro?.diagnostics() ?? { time: this.time, model: 'spectral', bands: 3, peakLength: this.spectrum.peakLength }; }
420
+ dispose() { this.foamParticles?.dispose(); this.foamCoordinateTexture.dispose(); this.renderExtension?.dispose(); this.detailSpectrum.dispose(); this.texture.dispose(); this.causticTexture.dispose(); for (const texture of this.normalTextures)
421
+ texture.dispose(); }
422
+ }