incanto 0.75.0 → 0.77.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 (39) hide show
  1. package/dist/2d.d.ts +1 -1
  2. package/dist/2d.js +4 -4
  3. package/dist/3d.d.ts +121 -3
  4. package/dist/3d.js +4 -4
  5. package/dist/{create-game-DnwhJ5nW.js → create-game-BMIPKrQW.js} +5 -5
  6. package/dist/{create-game-DVUhHs2B.js → create-game-Dai5vfVu.js} +5 -5
  7. package/dist/{editor-switch-O8mxpgRX.d.ts → editor-switch-Bzt0GzVp.d.ts} +12 -2
  8. package/dist/{environment-presets-BP77Dl0K.js → environment-presets-e_9YCnGu.js} +348 -36
  9. package/dist/{gameplay-D-JuGura.js → gameplay-Bfff_beb.js} +1468 -61
  10. package/dist/gameplay.js +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/{physics-2d-x3bCrQf0.js → physics-2d-Tuf5LAyq.js} +2 -2
  13. package/dist/{physics-3d-Bz2VPQ6D.js → physics-3d-C5MOujfQ.js} +2 -2
  14. package/dist/{picking-Buita5T9.js → picking-DIwE48sU.js} +8 -5
  15. package/dist/react.js +1 -1
  16. package/dist/{register-DxAbTO2T.js → register-7E3P1gz6.js} +1 -1
  17. package/dist/{src-DLKVc6Hj.js → src-DygJfhOb.js} +1 -1
  18. package/dist/{test-HlVUeCSn.js → test-CVbxnXlv.js} +7 -7
  19. package/dist/test.js +1 -1
  20. package/dist/vite.d.ts +47 -3
  21. package/dist/vite.js +81 -20
  22. package/editor/assets/{agent8-BXlYM9rv.js → agent8-DA-I5qhv.js} +1 -1
  23. package/editor/assets/{debug-S42XGP4Y.js → debug-99tEPoGm.js} +1 -1
  24. package/editor/assets/index-Db74LZY-.js +11579 -0
  25. package/editor/index.html +1 -1
  26. package/package.json +1 -1
  27. package/schemas/scene.schema.json +30 -2
  28. package/skills/incanto-3d-character.md +26 -1
  29. package/skills/incanto-editor.md +56 -2
  30. package/skills/incanto-environment.md +30 -12
  31. package/skills/incanto-node-reference.md +6 -1
  32. package/templates-app/beacon-isle-3d/package.json +1 -1
  33. package/templates-app/beacon-isle-3d/src/game.scene.json +6 -1
  34. package/templates-app/molehill-2d/package.json +1 -1
  35. package/templates-app/platformer-2d/package.json +1 -1
  36. package/templates-app/star-survivor/package.json +1 -1
  37. package/templates-app/tps-3d/package.json +1 -1
  38. package/templates-app/village-quest-3d/package.json +1 -1
  39. package/editor/assets/index-DgrgUKno.js +0 -11046
@@ -3,7 +3,7 @@ import { $ as Node, G as HudLayer, Z as requireNumber, at as Behavior, b as load
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
4
  import { t as Rng } from "./rng-CDOMybym.js";
5
5
  import { h as didYouMean } from "./touch-CioTZB-y.js";
6
- import { Color, CubeCamera, DepthTexture, DoubleSide, Euler, FloatType, Frustum, HalfFloatType, LinearMipmapLinearFilter, MathUtils, Matrix4, Mesh, MeshDepthMaterial, Object3D, PerspectiveCamera, Plane, PlaneGeometry, Quaternion, ShaderMaterial, Sphere, Vector2, Vector3, Vector4, WebGLCubeRenderTarget, WebGLRenderTarget } from "three";
6
+ import { Color, CubeCamera, DataTexture, DataUtils, DepthTexture, DoubleSide, Euler, FloatType, Frustum, GLSL3, HalfFloatType, InstancedBufferAttribute, InstancedBufferGeometry, LinearFilter, LinearMipmapLinearFilter, MathUtils, Matrix4, Mesh, MeshDepthMaterial, NearestFilter, NormalBlending, Object3D, OrthographicCamera, PerspectiveCamera, Plane, PlaneGeometry, Quaternion, RGBAFormat, RepeatWrapping, Scene, ShaderMaterial, Sphere, Vector2, Vector3, Vector4, WebGLCubeRenderTarget, WebGLRenderTarget } from "three";
7
7
  //#region src/gameplay/spatial.ts
8
8
  /** Duck-type test: every spatial node (Node2D/Node3D) exposes `position: number[]`. */
9
9
  function hasPosition$1(node) {
@@ -736,6 +736,615 @@ function sphereInCameraFrustum(camera, center, radius) {
736
736
  return scratchFrustum.intersectsSphere(scratchSphere);
737
737
  }
738
738
  //#endregion
739
+ //#region src/3d/water/fft.ts
740
+ /**
741
+ * The FFT sea — the CPU half. Pure math, no three.
742
+ *
743
+ * A real sea is not a sum of a few sine trains; it is a SPECTRUM — thousands of
744
+ * waves at every wavelength and heading, each moving at the speed deep water
745
+ * gives its wavelength, with the energy shaped by the wind (JONSWAP: a peaked
746
+ * hump at the wind's dominant period, a k^-4 tail of chop above it, a fast
747
+ * roll-off below). Tessendorf's method builds that spectrum once, spins every
748
+ * mode by its own frequency each frame, and inverse-FFTs the lot into a tiling
749
+ * height + horizontal-displacement patch. The GPU does the inverse FFT
750
+ * (`fft-gpu.ts`, 256² in ~35 tiny passes); THIS file builds the spectrum the GPU
751
+ * uploads, and evaluates the SAME spectrum on the CPU for everything headless —
752
+ * buoyancy, the waterline a wader crosses, the playtest's idea of where the
753
+ * surface is — by summing the strongest modes directly.
754
+ *
755
+ * Two halves, one contract, pinned by `fft.test.ts`:
756
+ * - the spectrum is deterministic per seed and Hermitian (the surface is real)
757
+ * - k lives in the texture in FFT order (index 0 = DC, upper half negative)
758
+ * - the truncated CPU sum tracks the full sum to within a few percent of the
759
+ * rms height, and is exact when every mode is kept
760
+ *
761
+ * The horizontal (choppy) displacement is GPU-only: the CPU answers the height
762
+ * at the undisplaced grid point, which differs from the displaced surface by
763
+ * displacement × slope — centimetres, well under what buoyancy can feel.
764
+ */
765
+ const SEA_GRAVITY = 9.81;
766
+ const DEFAULT_FETCH = 1e5;
767
+ const DEFAULT_MODES = 2048;
768
+ const JONSWAP_GAMMA = 3.3;
769
+ /** cos^(2s) directional spread — 8 is a sea with a clear heading and real cross-chop. */
770
+ const SPREAD_S = 8;
771
+ /** Share of energy that ignores the wind entirely (old swell, reflections). */
772
+ const SPREAD_ISOTROPIC = .06;
773
+ /** Wavenumber of texture index `i` (FFT order: DC first, negatives in the upper half). */
774
+ function fftIndexToK(i, n, patch) {
775
+ const m = i < n / 2 ? i : i - n;
776
+ return 2 * Math.PI * m / patch;
777
+ }
778
+ /** JONSWAP S(ω): energy density per unit angular frequency. */
779
+ function jonswap(omega, omegaPeak, alpha) {
780
+ if (omega <= 0) return 0;
781
+ const sigma = omega <= omegaPeak ? .07 : .09;
782
+ const r = Math.exp(-((omega - omegaPeak) ** 2) / (2 * sigma * sigma * omegaPeak * omegaPeak));
783
+ return alpha * (SEA_GRAVITY * SEA_GRAVITY) / omega ** 5 * Math.exp(-1.25 * (omegaPeak / omega) ** 4) * JONSWAP_GAMMA ** r;
784
+ }
785
+ /** Directional spreading, normalized so ∫D dθ = 1. */
786
+ function spreading(theta, thetaWind, norm) {
787
+ const lobe = Math.cos((theta - thetaWind) / 2) ** (2 * SPREAD_S) / norm;
788
+ return (1 - SPREAD_ISOTROPIC) * lobe + SPREAD_ISOTROPIC / (2 * Math.PI);
789
+ }
790
+ function spreadingNorm() {
791
+ const steps = 720;
792
+ let acc = 0;
793
+ for (let i = 0; i < steps; i++) {
794
+ const th = (i + .5) / steps * 2 * Math.PI;
795
+ acc += Math.cos(th / 2) ** (2 * SPREAD_S);
796
+ }
797
+ return acc * (2 * Math.PI / steps);
798
+ }
799
+ function gaussianPair(rng) {
800
+ const u1 = Math.max(rng.next(), 1e-12);
801
+ const u2 = rng.next();
802
+ const r = Math.sqrt(-2 * Math.log(u1));
803
+ return [r * Math.cos(2 * Math.PI * u2), r * Math.sin(2 * Math.PI * u2)];
804
+ }
805
+ function buildSeaSpectrum(params) {
806
+ const n = params.size;
807
+ if (!(Number.isInteger(n) && n >= 4 && (n & n - 1) === 0)) throw new Error(`sea spectrum size must be a power of two >= 4, got ${n}`);
808
+ const patch = params.patch;
809
+ const wind = Math.max(params.wind, 0);
810
+ const fetch = params.fetch ?? DEFAULT_FETCH;
811
+ const choppiness = params.choppiness ?? .85;
812
+ const modeBudget = params.modes ?? DEFAULT_MODES;
813
+ const rng = new Rng(params.seed);
814
+ const h0 = new Float32Array(n * n * 4);
815
+ const spec = {
816
+ size: n,
817
+ patch,
818
+ wind,
819
+ choppiness,
820
+ h0,
821
+ modes: new Float64Array(0),
822
+ modeCount: 0,
823
+ significantHeight: 0,
824
+ rmsSlope: 0
825
+ };
826
+ if (wind <= 0) return spec;
827
+ const g = SEA_GRAVITY;
828
+ const omegaPeak = 22 * (g * g / (wind * fetch)) ** (1 / 3);
829
+ const alpha = .076 * (wind * wind / (fetch * g)) ** .22;
830
+ const thetaWind = params.windDirectionDeg * Math.PI / 180;
831
+ const norm = spreadingNorm();
832
+ const dk = 2 * Math.PI / patch;
833
+ const cut = patch / n * .9;
834
+ const psi = new Float64Array(n * n);
835
+ let m0 = 0;
836
+ for (let j = 0; j < n; j++) {
837
+ const kz = fftIndexToK(j, n, patch);
838
+ for (let i = 0; i < n; i++) {
839
+ const kx = fftIndexToK(i, n, patch);
840
+ const k = Math.hypot(kx, kz);
841
+ if (k < 1e-9) continue;
842
+ const omega = Math.sqrt(g * k);
843
+ const dOmegaDk = g / (2 * omega);
844
+ const theta = Math.atan2(kx, kz);
845
+ const p = jonswap(omega, omegaPeak, alpha) * spreading(theta, thetaWind, norm) * dOmegaDk / k * Math.exp(-k * k * cut * cut) * dk * dk;
846
+ psi[j * n + i] = p;
847
+ m0 += p;
848
+ }
849
+ }
850
+ const re = new Float64Array(n * n);
851
+ const im = new Float64Array(n * n);
852
+ for (let idx = 0; idx < n * n; idx++) {
853
+ const [gr, gi] = gaussianPair(rng);
854
+ const a = Math.sqrt(psi[idx] / 2);
855
+ re[idx] = gr * a;
856
+ im[idx] = gi * a;
857
+ }
858
+ let realized = 0;
859
+ for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
860
+ const idx = j * n + i;
861
+ const mi = (n - i) % n;
862
+ const midx = (n - j) % n * n + mi;
863
+ const hr = re[idx] + re[midx];
864
+ const hi = im[idx] - im[midx];
865
+ realized += hr * hr + hi * hi;
866
+ }
867
+ const scale = realized > 0 ? Math.sqrt(m0 / realized) : 0;
868
+ let slope2 = 0;
869
+ for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
870
+ const idx = j * n + i;
871
+ const mi = (n - i) % n;
872
+ const midx = (n - j) % n * n + mi;
873
+ const o = idx * 4;
874
+ h0[o] = re[idx] * scale;
875
+ h0[o + 1] = im[idx] * scale;
876
+ h0[o + 2] = re[midx] * scale;
877
+ h0[o + 3] = -im[midx] * scale;
878
+ const hr = h0[o] + h0[o + 2];
879
+ const hi = h0[o + 1] + h0[o + 3];
880
+ const kx = fftIndexToK(i, n, patch);
881
+ const kz = fftIndexToK(j, n, patch);
882
+ slope2 += (hr * hr + hi * hi) * (kx * kx + kz * kz);
883
+ }
884
+ spec.significantHeight = 4 * Math.sqrt(m0);
885
+ spec.rmsSlope = Math.sqrt(slope2);
886
+ const strength = [];
887
+ for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
888
+ const idx = j * n + i;
889
+ const o = idx * 4;
890
+ const hr = h0[o] + h0[o + 2];
891
+ const hi = h0[o + 1] + h0[o + 3];
892
+ strength.push({
893
+ idx,
894
+ s: hr * hr + hi * hi
895
+ });
896
+ }
897
+ strength.sort((a, b) => b.s - a.s || a.idx - b.idx);
898
+ const chosen = /* @__PURE__ */ new Set();
899
+ for (const { idx } of strength) {
900
+ if (chosen.size >= modeBudget) break;
901
+ if (chosen.has(idx)) continue;
902
+ const i = idx % n;
903
+ const midx = (n - (idx - i) / n) % n * n + (n - i) % n;
904
+ chosen.add(idx);
905
+ chosen.add(midx);
906
+ }
907
+ const modes = new Float64Array(chosen.size * 7);
908
+ let m = 0;
909
+ for (const idx of chosen) {
910
+ const i = idx % n;
911
+ const j = (idx - i) / n;
912
+ const kx = fftIndexToK(i, n, patch);
913
+ const kz = fftIndexToK(j, n, patch);
914
+ const o = idx * 4;
915
+ const b = m * 7;
916
+ modes[b] = kx;
917
+ modes[b + 1] = kz;
918
+ modes[b + 2] = Math.sqrt(g * Math.hypot(kx, kz));
919
+ modes[b + 3] = h0[o];
920
+ modes[b + 4] = h0[o + 1];
921
+ modes[b + 5] = h0[o + 2];
922
+ modes[b + 6] = h0[o + 3];
923
+ m++;
924
+ }
925
+ spec.modes = modes;
926
+ spec.modeCount = m;
927
+ return spec;
928
+ }
929
+ /**
930
+ * One mode's contribution at (x, z, t): Re[ h̃(k,t) · e^{i k·x} ] with
931
+ * h̃ = h0 e^{iωt} + conj(h0(-k)) e^{-iωt} — the exact term the GPU sums.
932
+ */
933
+ function modeTerm(kx, kz, omega, h0r, h0i, hmr, hmi, x, z, t) {
934
+ const wt = omega * t;
935
+ const cw = Math.cos(wt);
936
+ const sw = Math.sin(wt);
937
+ const hr = h0r * cw - h0i * sw + (hmr * cw + hmi * sw);
938
+ const hi = h0r * sw + h0i * cw + (hmi * cw - hmr * sw);
939
+ const ph = kx * x + kz * z;
940
+ return hr * Math.cos(ph) - hi * Math.sin(ph);
941
+ }
942
+ /** Height of the sea at world (x, z) and time t — the strongest modes only. */
943
+ function seaElevation(spec, x, z, t) {
944
+ const m = spec.modes;
945
+ let h = 0;
946
+ for (let i = 0; i < spec.modeCount; i++) {
947
+ const b = i * 7;
948
+ h += modeTerm(m[b], m[b + 1], m[b + 2], m[b + 3], m[b + 4], m[b + 5], m[b + 6], x, z, t);
949
+ }
950
+ return h;
951
+ }
952
+ //#endregion
953
+ //#region src/3d/water/fft-shaders.ts
954
+ /**
955
+ * GLSL for the FFT sea (see `fft.ts` for the spectrum, `fft-gpu.ts` for the
956
+ * pass schedule). All GLSL3, all `texelFetch` — an FFT reads exact texels,
957
+ * never filtered ones.
958
+ *
959
+ * Every pass is a fullscreen quad over an N×N target; `gl_FragCoord` is the
960
+ * texel. Complex numbers ride as vec2 (re, im); a texel carries TWO of them
961
+ * (xy, zw) so one butterfly chain transforms two signals for the price of one.
962
+ */
963
+ const COMPLEX = `
964
+ vec2 cmul(vec2 a, vec2 b) {
965
+ return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
966
+ }
967
+ `;
968
+ const FFT_FULLSCREEN_VERT = `
969
+ void main() {
970
+ gl_Position = vec4(position.xy, 0.0, 1.0);
971
+ }
972
+ `;
973
+ /**
974
+ * Spin the spectrum to time t. Slot 0 writes (h̃, D̃x), slot 1 writes (D̃z, 0):
975
+ * h̃(k,t) = h0(k) e^{iωt} + conj(h0(-k)) e^{-iωt}, ω = sqrt(g|k|)
976
+ * D̃ = -i (k/|k|) h̃ · choppiness (Tessendorf's choppy displacement)
977
+ */
978
+ const FFT_SPECTRUM_FRAG = `
979
+ precision highp float;
980
+ uniform sampler2D uH0;
981
+ uniform float uTime;
982
+ uniform int uSize;
983
+ uniform float uPatch;
984
+ uniform float uChoppiness;
985
+ uniform int uSlot;
986
+ out vec4 outColor;
987
+ ${COMPLEX}
988
+ float kOf(int i) {
989
+ int m = i < uSize / 2 ? i : i - uSize;
990
+ return 6.28318530718 * float(m) / uPatch;
991
+ }
992
+ void main() {
993
+ ivec2 ij = ivec2(gl_FragCoord.xy);
994
+ vec4 h0 = texelFetch(uH0, ij, 0);
995
+ float kx = kOf(ij.x);
996
+ float kz = kOf(ij.y);
997
+ float k = length(vec2(kx, kz));
998
+ float omega = sqrt(9.81 * k);
999
+ float wt = omega * uTime;
1000
+ vec2 e = vec2(cos(wt), sin(wt));
1001
+ vec2 h = cmul(h0.xy, e) + cmul(h0.zw, vec2(e.x, -e.y));
1002
+ // -i·h = (h.y, -h.x)
1003
+ vec2 mih = vec2(h.y, -h.x);
1004
+ float inv = k > 1e-6 ? 1.0 / k : 0.0;
1005
+ vec2 dx = mih * (kx * inv) * uChoppiness;
1006
+ vec2 dz = mih * (kz * inv) * uChoppiness;
1007
+ outColor = uSlot == 0 ? vec4(h, dx) : vec4(dz, 0.0, 0.0);
1008
+ }
1009
+ `;
1010
+ /**
1011
+ * One radix-2 butterfly stage over both packed complex signals. The table
1012
+ * (`butterflyTable` in fft-gpu.ts) holds, per stage and index: the twiddle and
1013
+ * the two source indices — bit-reversal folded into stage 0.
1014
+ */
1015
+ const FFT_BUTTERFLY_FRAG = `
1016
+ precision highp float;
1017
+ uniform sampler2D uButterfly;
1018
+ uniform sampler2D uInput;
1019
+ uniform int uStage;
1020
+ uniform int uHorizontal;
1021
+ out vec4 outColor;
1022
+ ${COMPLEX}
1023
+ void main() {
1024
+ ivec2 ij = ivec2(gl_FragCoord.xy);
1025
+ int along = uHorizontal == 1 ? ij.x : ij.y;
1026
+ vec4 b = texelFetch(uButterfly, ivec2(along, uStage), 0);
1027
+ ivec2 pIdx = uHorizontal == 1 ? ivec2(int(b.z), ij.y) : ivec2(ij.x, int(b.z));
1028
+ ivec2 qIdx = uHorizontal == 1 ? ivec2(int(b.w), ij.y) : ivec2(ij.x, int(b.w));
1029
+ vec4 p = texelFetch(uInput, pIdx, 0);
1030
+ vec4 q = texelFetch(uInput, qIdx, 0);
1031
+ vec2 w = b.xy;
1032
+ outColor = vec4(p.xy + cmul(w, q.xy), p.zw + cmul(w, q.zw));
1033
+ }
1034
+ `;
1035
+ /** Gather the transformed signals into the displacement texture (Dx, h, Dz, 0). */
1036
+ const FFT_DISPLACE_FRAG = `
1037
+ precision highp float;
1038
+ uniform sampler2D uA;
1039
+ uniform sampler2D uB;
1040
+ out vec4 outColor;
1041
+ void main() {
1042
+ ivec2 ij = ivec2(gl_FragCoord.xy);
1043
+ vec4 a = texelFetch(uA, ij, 0);
1044
+ vec4 b = texelFetch(uB, ij, 0);
1045
+ outColor = vec4(a.z, a.x, b.x, 0.0);
1046
+ }
1047
+ `;
1048
+ /**
1049
+ * Slopes, the Jacobian and the foam memory, from the displaced grid:
1050
+ * sx = Δh / (Δx + ΔDx) — the slope of the surface as it actually sits
1051
+ * J = (1+∂Dx/∂x)(1+∂Dz/∂z) − ∂Dx/∂z·∂Dz/∂x — folds where it collapses
1052
+ * foam = max(previous · decay, saturate((J₀ − J) · gain)) — whitecaps are
1053
+ * born where the surface folds and die over a few seconds
1054
+ * Output: (sx, sz, foam, sx²+sz²) — the last is what a mip of this texture
1055
+ * turns into slope VARIANCE (E[s²] − E[s]²), the roughness a far pixel sees.
1056
+ */
1057
+ const FFT_NORMAL_FRAG = `
1058
+ precision highp float;
1059
+ uniform sampler2D uDisp;
1060
+ uniform sampler2D uPrev;
1061
+ uniform int uSize;
1062
+ uniform float uPatch;
1063
+ uniform float uFoamDecay;
1064
+ uniform float uFoamThreshold;
1065
+ uniform float uFoamGain;
1066
+ out vec4 outColor;
1067
+ vec4 at(ivec2 ij) {
1068
+ return texelFetch(uDisp, ivec2((ij.x + uSize) % uSize, (ij.y + uSize) % uSize), 0);
1069
+ }
1070
+ void main() {
1071
+ ivec2 ij = ivec2(gl_FragCoord.xy);
1072
+ float texel = uPatch / float(uSize);
1073
+ vec4 l = at(ij + ivec2(-1, 0));
1074
+ vec4 r = at(ij + ivec2(1, 0));
1075
+ vec4 d = at(ij + ivec2(0, -1));
1076
+ vec4 u = at(ij + ivec2(0, 1));
1077
+ float dxdx = (r.x - l.x) / (2.0 * texel);
1078
+ float dzdz = (u.z - d.z) / (2.0 * texel);
1079
+ float dxdz = (u.x - d.x) / (2.0 * texel);
1080
+ float dzdx = (r.z - l.z) / (2.0 * texel);
1081
+ float sx = (r.y - l.y) / max(2.0 * texel + (r.x - l.x), 0.05 * texel);
1082
+ float sz = (u.y - d.y) / max(2.0 * texel + (u.z - d.z), 0.05 * texel);
1083
+ float jac = (1.0 + dxdx) * (1.0 + dzdz) - dxdz * dzdx;
1084
+ float prev = texelFetch(uPrev, ij, 0).z;
1085
+ float born = clamp((uFoamThreshold - jac) * uFoamGain, 0.0, 1.0);
1086
+ float foam = max(prev * uFoamDecay, born);
1087
+ outColor = vec4(sx, sz, foam, sx * sx + sz * sz);
1088
+ }
1089
+ `;
1090
+ //#endregion
1091
+ //#region src/3d/water/fft-gpu.ts
1092
+ /**
1093
+ * The FFT sea — the GPU half (three.js). `fft.ts` builds the spectrum; this
1094
+ * file inverse-FFTs it every frame into two tiling textures the water shaders
1095
+ * read:
1096
+ *
1097
+ * displacement (Dx, h, Dz, 0) — where the surface IS
1098
+ * normal (sx, sz, foam, sx²+sz²) — which way it faces, where it has
1099
+ * folded into whitecaps, and how
1100
+ * rough it is once a pixel
1101
+ * integrates it (the mip chain)
1102
+ *
1103
+ * Schedule per frame, all N×N fullscreen passes: 2 spectrum spins (two packed
1104
+ * complex signals each), 2·log2 N butterfly stages per chain, one gather, one
1105
+ * slope/foam pass. At 256² that is 37 draws of 65 k fragments — less than a
1106
+ * single shadow cascade.
1107
+ *
1108
+ * `butterflyTable` and `runButterflyCpu` are exported for the test that runs
1109
+ * the SAME table on the CPU against the direct spectrum sum — the one way to
1110
+ * prove an FFT schedule headless.
1111
+ */
1112
+ const SEA_FOAM_DEFAULT = {
1113
+ decay: .985,
1114
+ threshold: .8,
1115
+ gain: 4
1116
+ };
1117
+ function bitReverse(x, bits) {
1118
+ let r = 0;
1119
+ for (let b = 0; b < bits; b++) r = r << 1 | x >> b & 1;
1120
+ return r;
1121
+ }
1122
+ /**
1123
+ * The radix-2 DIT butterfly table for an INVERSE transform of size n:
1124
+ * `log2 n` rows of n texels, each (twiddle.re, twiddle.im, index p, index q)
1125
+ * so that out[x] = in[p] + twiddle · in[q]. Stage 0 folds the bit-reversal
1126
+ * into its indices; the bottom wing's twiddle carries the minus sign through
1127
+ * k + n/2, so one formula serves both wings.
1128
+ */
1129
+ function butterflyTable(n) {
1130
+ const stages = Math.round(Math.log2(n));
1131
+ if (1 << stages !== n) throw new Error(`FFT size must be a power of two, got ${n}`);
1132
+ const table = new Float32Array(stages * n * 4);
1133
+ for (let s = 0; s < stages; s++) {
1134
+ const span = 1 << s;
1135
+ for (let x = 0; x < n; x++) {
1136
+ const top = x % (span * 2) < span;
1137
+ const k = x * (n >> s + 1) % n;
1138
+ const ang = 2 * Math.PI * k / n;
1139
+ let p;
1140
+ let q;
1141
+ if (s === 0) {
1142
+ p = top ? bitReverse(x, stages) : bitReverse(x - 1, stages);
1143
+ q = top ? bitReverse(x + 1, stages) : bitReverse(x, stages);
1144
+ } else {
1145
+ p = top ? x : x - span;
1146
+ q = top ? x + span : x;
1147
+ }
1148
+ const o = (s * n + x) * 4;
1149
+ table[o] = Math.cos(ang);
1150
+ table[o + 1] = Math.sin(ang);
1151
+ table[o + 2] = p;
1152
+ table[o + 3] = q;
1153
+ }
1154
+ }
1155
+ return table;
1156
+ }
1157
+ function chainTarget(n) {
1158
+ return new WebGLRenderTarget(n, n, {
1159
+ type: FloatType,
1160
+ format: RGBAFormat,
1161
+ minFilter: NearestFilter,
1162
+ magFilter: NearestFilter,
1163
+ depthBuffer: false,
1164
+ stencilBuffer: false,
1165
+ generateMipmaps: false
1166
+ });
1167
+ }
1168
+ function surfaceTarget(n) {
1169
+ return new WebGLRenderTarget(n, n, {
1170
+ type: HalfFloatType,
1171
+ format: RGBAFormat,
1172
+ minFilter: LinearMipmapLinearFilter,
1173
+ magFilter: LinearFilter,
1174
+ wrapS: RepeatWrapping,
1175
+ wrapT: RepeatWrapping,
1176
+ depthBuffer: false,
1177
+ stencilBuffer: false,
1178
+ generateMipmaps: true
1179
+ });
1180
+ }
1181
+ /** Does this context render to float targets at all? (Every WebGL2 desktop and iOS 15+ does.) */
1182
+ function seaFftSupported(gl) {
1183
+ const ext = gl.extensions;
1184
+ return typeof ext?.has === "function" && ext.has("EXT_color_buffer_float") === true;
1185
+ }
1186
+ var SeaFft = class {
1187
+ size;
1188
+ patch;
1189
+ scene = new Scene();
1190
+ camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
1191
+ quad;
1192
+ spectrumMat;
1193
+ butterflyMat;
1194
+ displaceMat;
1195
+ normalMat;
1196
+ uSpec = {
1197
+ uH0: { value: null },
1198
+ uTime: { value: 0 },
1199
+ uSize: { value: 0 },
1200
+ uPatch: { value: 0 },
1201
+ uChoppiness: { value: .85 },
1202
+ uSlot: { value: 0 }
1203
+ };
1204
+ uButter = {
1205
+ uButterfly: { value: null },
1206
+ uInput: { value: null },
1207
+ uStage: { value: 0 },
1208
+ uHorizontal: { value: 1 }
1209
+ };
1210
+ uDisplace = {
1211
+ uA: { value: null },
1212
+ uB: { value: null }
1213
+ };
1214
+ uNormal = {
1215
+ uDisp: { value: null },
1216
+ uPrev: { value: null },
1217
+ uSize: { value: 0 },
1218
+ uPatch: { value: 0 },
1219
+ uFoamDecay: { value: SEA_FOAM_DEFAULT.decay },
1220
+ uFoamThreshold: { value: SEA_FOAM_DEFAULT.threshold },
1221
+ uFoamGain: { value: SEA_FOAM_DEFAULT.gain }
1222
+ };
1223
+ butterfly;
1224
+ h0 = null;
1225
+ a;
1226
+ b;
1227
+ disp;
1228
+ norm;
1229
+ normIndex = 0;
1230
+ stages;
1231
+ constructor(size, patch) {
1232
+ this.size = size;
1233
+ this.patch = patch;
1234
+ this.stages = Math.round(Math.log2(size));
1235
+ const table = butterflyTable(size);
1236
+ this.butterfly = new DataTexture(table, size, this.stages, RGBAFormat, FloatType);
1237
+ this.butterfly.minFilter = NearestFilter;
1238
+ this.butterfly.magFilter = NearestFilter;
1239
+ this.butterfly.needsUpdate = true;
1240
+ this.uSpec.uSize.value = size;
1241
+ this.uSpec.uPatch.value = patch;
1242
+ this.uButter.uButterfly.value = this.butterfly;
1243
+ this.uNormal.uSize.value = size;
1244
+ this.uNormal.uPatch.value = patch;
1245
+ const pass = (fragmentShader, uniforms) => new ShaderMaterial({
1246
+ glslVersion: GLSL3,
1247
+ vertexShader: FFT_FULLSCREEN_VERT,
1248
+ fragmentShader,
1249
+ uniforms,
1250
+ depthTest: false,
1251
+ depthWrite: false
1252
+ });
1253
+ this.spectrumMat = pass(FFT_SPECTRUM_FRAG, this.uSpec);
1254
+ this.butterflyMat = pass(FFT_BUTTERFLY_FRAG, this.uButter);
1255
+ this.displaceMat = pass(FFT_DISPLACE_FRAG, this.uDisplace);
1256
+ this.normalMat = pass(FFT_NORMAL_FRAG, this.uNormal);
1257
+ this.quad = new Mesh(new PlaneGeometry(2, 2), this.spectrumMat);
1258
+ this.quad.frustumCulled = false;
1259
+ this.scene.add(this.quad);
1260
+ this.a = [chainTarget(size), chainTarget(size)];
1261
+ this.b = [chainTarget(size), chainTarget(size)];
1262
+ this.disp = surfaceTarget(size);
1263
+ this.norm = [surfaceTarget(size), surfaceTarget(size)];
1264
+ }
1265
+ /** Upload a (new) spectrum. Cheap to call with the same one — it is keyed by the caller. */
1266
+ setSpectrum(spec) {
1267
+ if (spec.size !== this.size) throw new Error(`spectrum size ${spec.size} ≠ FFT size ${this.size}`);
1268
+ this.h0?.dispose();
1269
+ this.h0 = new DataTexture(spec.h0, this.size, this.size, RGBAFormat, FloatType);
1270
+ this.h0.minFilter = NearestFilter;
1271
+ this.h0.magFilter = NearestFilter;
1272
+ this.h0.needsUpdate = true;
1273
+ this.uSpec.uH0.value = this.h0;
1274
+ this.uSpec.uChoppiness.value = spec.choppiness;
1275
+ }
1276
+ get displacement() {
1277
+ return this.disp.texture;
1278
+ }
1279
+ get normal() {
1280
+ return (this.normIndex === 0 ? this.norm[0] : this.norm[1]).texture;
1281
+ }
1282
+ draw(gl, mat, target) {
1283
+ this.quad.material = mat;
1284
+ gl.setRenderTarget(target);
1285
+ gl.render(this.scene, this.camera);
1286
+ }
1287
+ /** Run one chain (spectrum slot → log2N horizontal + log2N vertical stages). Returns the target holding the result. */
1288
+ chain(gl, slot, pair) {
1289
+ this.uSpec.uSlot.value = slot;
1290
+ this.draw(gl, this.spectrumMat, pair[0]);
1291
+ let src = 0;
1292
+ const bu = this.uButter;
1293
+ for (let pass = 0; pass < 2; pass++) {
1294
+ bu.uHorizontal.value = pass === 0 ? 1 : 0;
1295
+ for (let s = 0; s < this.stages; s++) {
1296
+ bu.uStage.value = s;
1297
+ bu.uInput.value = (src === 0 ? pair[0] : pair[1]).texture;
1298
+ this.draw(gl, this.butterflyMat, src === 0 ? pair[1] : pair[0]);
1299
+ src = src === 0 ? 1 : 0;
1300
+ }
1301
+ }
1302
+ return src === 0 ? pair[0] : pair[1];
1303
+ }
1304
+ /** Advance the sea to `time` (seconds). Restores the caller's render target. */
1305
+ update(gl, time, foam = SEA_FOAM_DEFAULT) {
1306
+ if (!this.h0) return;
1307
+ const prevTarget = gl.getRenderTarget();
1308
+ const prevAutoClear = gl.autoClear;
1309
+ gl.autoClear = false;
1310
+ this.uSpec.uTime.value = time;
1311
+ const outA = this.chain(gl, 0, this.a);
1312
+ const outB = this.chain(gl, 1, this.b);
1313
+ this.uDisplace.uA.value = outA.texture;
1314
+ this.uDisplace.uB.value = outB.texture;
1315
+ this.draw(gl, this.displaceMat, this.disp);
1316
+ const nu = this.uNormal;
1317
+ const current = this.normIndex === 0 ? this.norm[0] : this.norm[1];
1318
+ const next = this.normIndex === 0 ? this.norm[1] : this.norm[0];
1319
+ nu.uDisp.value = this.disp.texture;
1320
+ nu.uPrev.value = current.texture;
1321
+ nu.uFoamDecay.value = foam.decay;
1322
+ nu.uFoamThreshold.value = foam.threshold;
1323
+ nu.uFoamGain.value = foam.gain;
1324
+ this.draw(gl, this.normalMat, next);
1325
+ this.normIndex = this.normIndex === 0 ? 1 : 0;
1326
+ gl.setRenderTarget(prevTarget);
1327
+ gl.autoClear = prevAutoClear;
1328
+ }
1329
+ dispose() {
1330
+ for (const rt of [
1331
+ ...this.a,
1332
+ ...this.b,
1333
+ this.disp,
1334
+ ...this.norm
1335
+ ]) rt.dispose();
1336
+ this.butterfly.dispose();
1337
+ this.h0?.dispose();
1338
+ this.quad.geometry.dispose();
1339
+ for (const m of [
1340
+ this.spectrumMat,
1341
+ this.butterflyMat,
1342
+ this.displaceMat,
1343
+ this.normalMat
1344
+ ]) m.dispose();
1345
+ }
1346
+ };
1347
+ //#endregion
739
1348
  //#region src/3d/water/interaction.ts
740
1349
  /**
741
1350
  * Water3D character interaction — pure functions, no three, node-env testable.
@@ -1445,6 +2054,26 @@ uniform float uShoreWaves;
1445
2054
  // shores — inside an exact-walled basin (pool/pond) it carves a visible
1446
2055
  // elliptical cliff instead, so those presets turn it off.
1447
2056
  uniform float uEdgeFadeOn;
2057
+ // the FFT sea (wind > 0): a tiling displacement patch the vertex reads at the
2058
+ // mip whose texel matches its own spacing — see fft-gpu.ts
2059
+ uniform float uFftOn;
2060
+ uniform sampler2D uFftDisp;
2061
+ uniform float uFftPatch;
2062
+ uniform float uFftLod;
2063
+ varying vec2 vFftUv;
2064
+ // the bed (Water3D.bed): a depth grid over a terrain's footprint, so the
2065
+ // swell trains shoal, peak and break where the bottom comes up
2066
+ uniform float uBedOn;
2067
+ uniform sampler2D uBedDepth;
2068
+ uniform vec4 uBedRect; // minX, minZ, 1/width, 1/depth
2069
+ uniform float uBedDeep;
2070
+ varying float vBreak;
2071
+ float bedDepthAt(vec2 xz) {
2072
+ if (uBedOn < 0.5) return uBedDeep;
2073
+ vec2 uv = (xz - uBedRect.xy) * uBedRect.zw;
2074
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return uBedDeep;
2075
+ return textureLod(uBedDepth, uv, 0.0).r;
2076
+ }
1448
2077
 
1449
2078
  varying vec3 vNormal;
1450
2079
  varying vec3 vWorldPosition;
@@ -1494,6 +2123,8 @@ float snoise(vec2 v) {
1494
2123
 
1495
2124
  // Helper function to calculate elevation at any point
1496
2125
  float getElevation(float x, float z) {
2126
+ // the FFT sea IS the wave field — the noise would only fight its statistics
2127
+ if (uFftOn > 0.5) return 0.0;
1497
2128
  // World-metre wavelength: the source normalized by the water's own size,
1498
2129
  // which shrinks wavelengths on small pools into storm spikes. A fixed
1499
2130
  // 128 m reference reproduces the source's look (256 m lake) at ANY size.
@@ -1538,7 +2169,27 @@ float swellWave(vec2 p, vec2 dir, float lambda, float amp, float sharp) {
1538
2169
  float k = 6.2831853 / max(lambda, 1.0);
1539
2170
  float c = sqrt(9.8 / k);
1540
2171
  float s = 0.5 + 0.5 * sin(dot(p, dir) * k - uTime * c * k);
1541
- return amp * (pow(s, sharp) * 2.0 - 1.0);
2172
+ // shoaling (shore.ts is the CPU copy of this rule): once the water is
2173
+ // shallower than a quarter wavelength the wave RISES as depth^-1/4 (Green's
2174
+ // law, capped at 2×) and its crest peaks; at 0.78× the depth it breaks —
2175
+ // the height is capped there, and vBreak (below) says so to the fragment
2176
+ float d = max(bedDepthAt(p), 0.05);
2177
+ float ks = pow(clamp(lambda * 0.25 / d, 1.0, 16.0), 0.25);
2178
+ float a = min(amp * ks, 0.39 * d);
2179
+ float peak = sharp * mix(1.0, 2.2, clamp(ks - 1.0, 0.0, 1.0));
2180
+ return a * (pow(s, peak) * 2.0 - 1.0);
2181
+ }
2182
+
2183
+ // how far past breaking the primary swell is here (0 = not breaking)
2184
+ float getShoalBreak(vec2 p) {
2185
+ if (uSwellAmplitude <= 0.0 || uBedOn < 0.5) return 0.0;
2186
+ float d = max(bedDepthAt(p), 0.05);
2187
+ float lambda = uSwellWavelength;
2188
+ float ks = pow(clamp(lambda * 0.25 / d, 1.0, 16.0), 0.25);
2189
+ float shoaled = uSwellAmplitude * 0.6 * ks;
2190
+ // 1 as the shoaled height meets the cap, fading out where the film is a
2191
+ // few centimetres (there is nothing left to break)
2192
+ return smoothstep(0.8, 1.05, shoaled / (0.39 * d)) * smoothstep(0.05, 0.25, d);
1542
2193
  }
1543
2194
 
1544
2195
  // incanto swell: three trains — the primary + two shorter crossing sets
@@ -1610,14 +2261,43 @@ void main() {
1610
2261
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);
1611
2262
 
1612
2263
  float elevation = getHeight(modelPosition.x, modelPosition.z);
1613
- modelPosition.y += elevation;
2264
+ // the FFT sea's height and horizontal drag, read at the grid's own mip so a
2265
+ // wave shorter than the vertex spacing never aliases into the mesh (the
2266
+ // fragment adds its slope back at full detail). Sampled at the UNDISPLACED
2267
+ // position, which is the texture's own coordinate.
2268
+ vec2 fftUv = modelPosition.xz / uFftPatch;
2269
+ vFftUv = fftUv;
2270
+ vec3 fftD = vec3(0.0);
2271
+ float fftBreak = 0.0;
2272
+ if (uFftOn > 0.5) {
2273
+ fftD = textureLod(uFftDisp, fftUv, uFftLod).xyz;
2274
+ // the spectrum cannot shoal mode by mode, so its whole field rises by
2275
+ // Green's law for its typical ~20 m wave as the bed comes up, and breaks
2276
+ // at the same 0.78·depth the swell does — the beach reads in the chop too
2277
+ if (uBedOn > 0.5) {
2278
+ // the SURF zone only: a whole lagoon two metres deep would otherwise
2279
+ // shoal and cap every wave in it (measured: the sea went flat and
2280
+ // blotchy out to the reef) — the rise fades in over the last 2.6 m
2281
+ float d = max(bedDepthAt(modelPosition.xz), 0.05);
2282
+ float surf = 1.0 - smoothstep(1.2, 2.6, d);
2283
+ float ks = mix(1.0, pow(clamp(5.0 / d, 1.0, 16.0), 0.25), surf);
2284
+ float cap = 0.39 * d;
2285
+ float h = fftD.y * ks;
2286
+ float over = max(abs(h) - cap, 0.0) * surf;
2287
+ fftBreak = smoothstep(0.8, 1.05, abs(h) / cap) * smoothstep(0.05, 0.25, d) * surf;
2288
+ fftD.y = sign(h) * (abs(h) - over);
2289
+ fftD.xz *= mix(1.0, 0.5, fftBreak); // a broken wave has spent its drag
2290
+ }
2291
+ }
2292
+ modelPosition.y += elevation + fftD.y;
1614
2293
  // Gerstner pinch: the height field is sampled at the UNDISPLACED position
1615
2294
  // (so the analytic finite-difference normals below stay consistent), then
1616
2295
  // the surface is dragged horizontally toward its crests. At the steepness
1617
2296
  // this ships the normal error is far under the detail-normal layers.
1618
2297
  vec3 pinch = getSwellPinch(modelPosition.xz);
1619
- modelPosition.xz += pinch.xy;
2298
+ modelPosition.xz += pinch.xy + fftD.xz;
1620
2299
  vFold = pinch.z;
2300
+ vBreak = max(getShoalBreak(modelPosition.xz), fftBreak);
1621
2301
 
1622
2302
  // Height adjustment to smooth water edges
1623
2303
  // Lower water height as it approaches scale boundaries
@@ -1739,6 +2419,32 @@ uniform float uCausticsAbove;
1739
2419
  // incanto presets: per-preset reflection ceiling (the old hard 0.6) — lakes
1740
2420
  // push toward mirror, pools stay glassy-clear.
1741
2421
  uniform float uReflectivityMax;
2422
+ // screen-space reflections: the scene above the waterline, shaded, at half
2423
+ // res, and the camera's view-projection to walk a world ray through it
2424
+ uniform float uUseSsr;
2425
+ uniform sampler2D uAboveColor;
2426
+ uniform sampler2D uAboveDepth;
2427
+ uniform mat4 uSsrViewProj;
2428
+ // the FFT sea's slopes (xy), whitecap memory (z) and slope energy (w) — see
2429
+ // fft-gpu.ts; the vertex reads its displacement, this reads its facing
2430
+ uniform float uFftOn;
2431
+ uniform sampler2D uFftNormal;
2432
+ uniform float uFftPatch;
2433
+ varying vec2 vFftUv;
2434
+ // the live RIPPLE layer (fft-gpu.ts at 6 m): the wind ripple every fancy
2435
+ // surface wears, read twice so its tile never shows — as is, and turned a
2436
+ // quarter and scaled 0.71× (the second read's slope turns and scales with it)
2437
+ uniform float uRippleOn;
2438
+ uniform sampler2D uRippleNormal;
2439
+ uniform float uRipplePatch;
2440
+ uniform float uRippleGain;
2441
+ // the vertex's word on whether the shoaled swell is breaking here (Water3D.bed)
2442
+ varying float vBreak;
2443
+ vec2 rippleSlope(vec2 worldXZ, float bias) {
2444
+ vec2 r1 = texture2D(uRippleNormal, worldXZ / uRipplePatch, bias).xy;
2445
+ vec2 r2 = texture2D(uRippleNormal, worldXZ.yx * 0.71 / uRipplePatch + 0.37, bias).xy;
2446
+ return r1 * 0.72 + r2.yx * (0.28 * 0.71);
2447
+ }
1742
2448
  // incanto: the scene pre-pass runs at HALF resolution, so everything read from
1743
2449
  // its depth texture arrives quantised to those texels. Derivatives of a
1744
2450
  // quantised signal are garbage (they alternate between ~0 inside a texel and a
@@ -1911,12 +2617,32 @@ vec3 detailNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
1911
2617
  // them by zero. Branch instead. camDist barely varies across a quad, so
1912
2618
  // these are warp-coherent: distant water genuinely skips the work, and the
1913
2619
  // near look is bit-identical because a skipped layer was contributing < 1%.
2620
+ // water-that-reads-as-water: the spectrum's ENERGY sat in the two long
2621
+ // octaves — the fine ones tilted the normal by a degree or two, so under a
2622
+ // 50°-high sun nothing ever faced it (no glitter, ever) and every
2623
+ // reflection was a smooth wobble (the gel). A wind-roughened surface holds
2624
+ // most of its SLOPE in the short waves (Cox-Munk: rms slope ~0.1-0.2 at a
2625
+ // breeze), so the fine octaves carry more, and a fifth 16 cm "spark"
2626
+ // octave lives inside arm's reach — the facets the sun flashes off.
2627
+ float fadeSpark = 1.0 - smoothstep(3.0, 14.0, camDist);
2628
+ // under the FFT sea the two long octaves overlap the spectrum's own band —
2629
+ // keep a whisper for texture, not a second sea on top of the first
2630
+ float longW = uFftOn > 0.5 ? 0.3 : 1.0;
1914
2631
  vec2 slope = vec2(0.0);
1915
- if (fadeCoarse > 0.01) slope += octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse);
1916
- if (fadeMid > 0.01) slope += octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.10 * fadeMid);
1917
- if (fadeFine > 0.01) slope += octavePair(worldXZ, WIND_DIR, 0.9, uTime) * (0.055 * fadeFine);
1918
- if (fadeMicro > 0.01) slope += octavePair(worldXZ, dMid, 0.38, uTime) * (0.042 * fadeMicro);
2632
+ if (fadeCoarse > 0.01) slope += octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse * longW);
2633
+ if (fadeMid > 0.01) slope += octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.10 * fadeMid * longW);
2634
+ // the three short octaves are the RIPPLE band. The live ripple FFT carries
2635
+ // its STRUCTURE (real churn, a mip chain for distance); these stay on under
2636
+ // it at reduced weight for the one thing a filtered texture cannot give —
2637
+ // the sub-pixel facets that flash. Evaluated per pixel and never filtered,
2638
+ // their aliasing IS the glitter; measured: with them off the sun collapsed
2639
+ // into one soft lobe and the sea read as satin.
2640
+ float shortW = uRippleOn > 0.5 ? 0.6 : 1.0;
2641
+ if (fadeFine > 0.01) slope += octavePair(worldXZ, WIND_DIR, 0.9, uTime) * (0.08 * fadeFine * shortW);
2642
+ if (fadeMicro > 0.01) slope += octavePair(worldXZ, dMid, 0.38, uTime) * (0.07 * fadeMicro * shortW);
2643
+ if (fadeSpark > 0.01) slope += octavePair(worldXZ, WIND_DIR, 0.16, uTime) * (0.05 * fadeSpark * shortW);
1919
2644
  slope *= strength * 3.0; // spectrum norm: default strength 0.26 ≈ calm-lively
2645
+ if (uRippleOn > 0.5) slope += rippleSlope(worldXZ, -1.0) * uRippleGain;
1920
2646
  return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
1921
2647
  }
1922
2648
 
@@ -1928,20 +2654,112 @@ vec3 detailNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
1928
2654
  vec3 glossNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
1929
2655
  float fadeCoarse = 1.0 - smoothstep(90.0, 420.0, camDist);
1930
2656
  float fadeMid = 1.0 - smoothstep(40.0, 170.0, camDist);
2657
+ float fadeFine = 1.0 - smoothstep(18.0, 80.0, camDist);
1931
2658
  vec2 dMid = normalize(vec2(0.62, 0.78));
2659
+ float longW = uFftOn > 0.5 ? 0.3 : 1.0;
1932
2660
  vec2 slope = vec2(0.0);
1933
- if (fadeCoarse > 0.01) slope += octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse);
1934
- if (fadeMid > 0.01) slope += octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.075 * fadeMid);
2661
+ if (fadeCoarse > 0.01) slope += octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse * longW);
2662
+ if (fadeMid > 0.01) slope += octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.075 * fadeMid * longW);
2663
+ // water-that-reads-as-water: a share of the 0.9 m octave — a reflection
2664
+ // read only off the long swell is a lava lamp; real sky reflections break
2665
+ // into short vertical shards on the wind ripple, out to mid range
2666
+ if (uRippleOn < 0.5 && fadeFine > 0.01)
2667
+ slope += octavePair(worldXZ, WIND_DIR, 0.9, uTime) * (0.035 * fadeFine);
1935
2668
  slope *= strength * 3.0;
2669
+ // the reflection reads the ripple a mip and a half softer, and weaker — a
2670
+ // mirror the size of a pixel integrates most of it away
2671
+ if (uRippleOn > 0.5) slope += rippleSlope(worldXZ, 1.5) * (uRippleGain * 0.6);
1936
2672
  return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
1937
2673
  }
1938
2674
 
2675
+ // ============ the SEA-STATE fresnel (why the sea is darker than its sky) ============
2676
+ // Smooth-surface Schlick says grazing water is a perfect mirror, and a
2677
+ // flat-Schlick sea therefore whites out into its own horizon haze — the
2678
+ // milky far field. A real sea is a field of tilted facets: at range a pixel
2679
+ // integrates thousands, and the ones tilted toward the viewer reflect a
2680
+ // steeper, darker patch of sky at a less grazing angle. Integrated, the
2681
+ // reflectance at the horizon of a wind-roughened sea is ~0.5-0.6, not 1.0 —
2682
+ // the ocean is always a darker band under its sky. This is Karis' analytic
2683
+ // fit of the split-sum environment BRDF: (scale, bias) with
2684
+ // F_rough = F0 * scale + bias.
2685
+ vec2 envBRDFApprox(float roughness, float NoV) {
2686
+ const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
2687
+ const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
2688
+ vec4 r = roughness * c0 + c1;
2689
+ float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
2690
+ return vec2(-1.04, 1.04) * a004 + r.zw;
2691
+ }
2692
+ // The sea state as a microfacet roughness. Near the camera the detail
2693
+ // octaves are RESOLVED (each facet is pixels wide — glitter); with distance
2694
+ // a pixel integrates them, and what it integrates is exactly a rough mirror.
2695
+ // The fresnel, the horizon sky sample and the sun highlight all read it.
2696
+ float seaRoughness(float strength, float camDist) {
2697
+ float far = smoothstep(15.0, 320.0, camDist);
2698
+ // near: a resolved facet is GLASSY — the lobe must be tight or every
2699
+ // ripple flank glows (the soft-blob glitter of the first pass); far: the
2700
+ // integrated field of a breeze-roughened sea, rms slope ~0.15
2701
+ return clamp(0.02 + strength * (0.2 + 1.5 * far), 0.02, 0.4);
2702
+ }
2703
+
1939
2704
  // Linear depth conversion
1940
2705
  float linearizeDepth(float depth, float near, float far) {
1941
2706
  float z = depth * 2.0 - 1.0;
1942
2707
  return (2.0 * near * far) / (far + near - z * (far - near));
1943
2708
  }
1944
2709
 
2710
+ // ============ screen-space reflections ============
2711
+ // Walk the reflected ray through the above-water image: project each step,
2712
+ // read the scene's depth there, and call it a hit where the ray has passed
2713
+ // behind the surface by less than a step's worth (the "thickness"). Steps
2714
+ // grow geometrically so a 16-step walk reaches ~120 m while still resolving
2715
+ // the first metre — a wader's legs at the waterline and a headland alike.
2716
+ // Three bisection steps then pin the crossing. Returns the hit colour with
2717
+ // its confidence in .a: 0 where the ray left the screen or never crossed,
2718
+ // fading toward the screen edge so a reflection never ends on a hard line.
2719
+ vec4 ssrTrace(vec3 origin, vec3 dir) {
2720
+ vec3 p = origin;
2721
+ float stepLen = 0.35;
2722
+ float prevDelta = 0.0;
2723
+ vec3 prevP = origin;
2724
+ bool hit = false;
2725
+ vec2 hitUv = vec2(0.0);
2726
+ for (int i = 0; i < 16; i++) {
2727
+ prevP = p;
2728
+ p += dir * stepLen;
2729
+ vec4 clip = uSsrViewProj * vec4(p, 1.0);
2730
+ if (clip.w <= 0.0) return vec4(0.0);
2731
+ vec2 uv = clip.xy / clip.w * 0.5 + 0.5;
2732
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec4(0.0);
2733
+ float sceneD = linearizeDepth(texture2D(uAboveDepth, uv).r, uCameraNear, uCameraFar);
2734
+ float delta = clip.w - sceneD; // > 0: the ray point is behind the scene surface
2735
+ if (delta > 0.0 && delta < stepLen * 2.0 + 0.5) {
2736
+ // bisect between prevP (in front) and p (behind)
2737
+ vec3 a = prevP;
2738
+ vec3 b = p;
2739
+ for (int k = 0; k < 3; k++) {
2740
+ vec3 m = (a + b) * 0.5;
2741
+ vec4 mc = uSsrViewProj * vec4(m, 1.0);
2742
+ vec2 muv = mc.xy / mc.w * 0.5 + 0.5;
2743
+ float md = linearizeDepth(texture2D(uAboveDepth, muv).r, uCameraNear, uCameraFar);
2744
+ if (mc.w - md > 0.0) b = m; else a = m;
2745
+ }
2746
+ vec4 hc = uSsrViewProj * vec4(b, 1.0);
2747
+ hitUv = hc.xy / hc.w * 0.5 + 0.5;
2748
+ hit = true;
2749
+ break;
2750
+ }
2751
+ prevDelta = delta;
2752
+ stepLen *= 1.45;
2753
+ }
2754
+ if (!hit) return vec4(0.0);
2755
+ vec2 edge = smoothstep(vec2(0.0), vec2(0.08), hitUv) * (1.0 - smoothstep(vec2(0.92), vec2(1.0), hitUv));
2756
+ float conf = edge.x * edge.y;
2757
+ // a whisper of blur: the half-res image read a mip down is what a rippled
2758
+ // surface would show of it anyway
2759
+ vec3 col = texture2D(uAboveColor, hitUv, 1.0).rgb;
2760
+ return vec4(min(col, vec3(2.5)), conf);
2761
+ }
2762
+
1945
2763
  /**
1946
2764
  * Vertical water column under a screen point, reconstructed from the scene
1947
2765
  * depth along the view ray. Split out so the shoreline can sample it in a
@@ -2204,7 +3022,32 @@ void main() {
2204
3022
  // normal: distant glitter is real and reads as sparkle, not noise.
2205
3023
  float camDist = length(vWorldPosition - cameraPosition);
2206
3024
  vec3 geoNormal = normalize(vNormal);
3025
+ // the FFT sea's facing, per PIXEL: the vertex only carried the waves its
3026
+ // grid could hold, the texture holds all of them, and its mip chain hands a
3027
+ // far pixel the MEAN slope plus (w − |xy|²) the slope VARIANCE it integrated
3028
+ // — the roughness a distant sea really has, instead of a guessed ramp
3029
+ float fftFoam = 0.0;
3030
+ float fftVar = 0.0;
3031
+ if (uFftOn > 0.5) {
3032
+ vec4 fn = texture2D(uFftNormal, vFftUv);
3033
+ geoNormal = normalize(vec3(geoNormal.x - fn.x, geoNormal.y, geoNormal.z - fn.y));
3034
+ fftFoam = fn.z;
3035
+ fftVar = max(fn.w - dot(fn.xy, fn.xy), 0.0);
3036
+ }
3037
+ if (uRippleOn > 0.5) {
3038
+ // the ripple's own integrated variance, scaled the way its slopes are
3039
+ vec4 rr = texture2D(uRippleNormal, vWorldPosition.xz / uRipplePatch);
3040
+ fftVar += max(rr.w - dot(rr.xy, rr.xy), 0.0) * uRippleGain * uRippleGain;
3041
+ }
2207
3042
  vec3 normal = detailNormal(geoNormal, vWorldPosition.xz, uDetailStrength, camDist);
3043
+ // the SHADING normal keeps sub-pixel structure the mip chain would have
3044
+ // averaged away: glitter is facets a pixel can only just resolve, and a
3045
+ // fully filtered normal hands the sun one soft lobe where a sea sparkles.
3046
+ // The fresnel/reflection normal and the variance stay filtered.
3047
+ if (uFftOn > 0.5) {
3048
+ vec2 crispSlope = texture2D(uFftNormal, vFftUv, -1.0).xy - texture2D(uFftNormal, vFftUv).xy;
3049
+ normal = normalize(vec3(normal.x - crispSlope.x, normal.y, normal.z - crispSlope.y));
3050
+ }
2208
3051
  // refinement pass 3: the GEO normal must flatten with distance too — the
2209
3052
  // per-vertex FBM normal varies per pixel near the horizon (its features go
2210
3053
  // subpixel), and against the bright sun-side sky every flicker becomes a
@@ -2220,8 +3063,15 @@ void main() {
2220
3063
  // with distance it hands over to the smooth gloss so the far field never
2221
3064
  // speckles against the HDR sky.
2222
3065
  vec3 gloss = glossNormal(geoNormal, vWorldPosition.xz, uDetailStrength, camDist);
2223
- vec3 crisp = mix(normal, gloss, smoothstep(6.0, 26.0, camDist));
3066
+ // water-that-reads-as-water: the handover used to finish at 26 m, so past
3067
+ // a boat-length every reflection was the smooth gloss wobble; the shards
3068
+ // now survive out to where the eye stops resolving ripple
3069
+ vec3 crisp = mix(normal, gloss, smoothstep(10.0, 60.0, camDist));
2224
3070
  vec3 fresnelNormal = normalize(mix(geoSmooth, crisp, fresnelDetail));
3071
+ float rough = seaRoughness(uDetailStrength, camDist);
3072
+ // measured, not guessed: the spectrum's integrated slope variance widens
3073
+ // the lobe exactly where a pixel spans more wave
3074
+ rough = clamp(sqrt(rough * rough + 0.35 * fftVar), 0.02, 0.5);
2225
3075
  vec3 reflectedDirection = reflect(viewDirection, fresnelNormal);
2226
3076
  // incanto: the source flipped .x here — correct for cube textures LOADED
2227
3077
  // from images, but a left/right mirror for CubeCamera render targets
@@ -2234,7 +3084,10 @@ void main() {
2234
3084
  // 0.1 → 0.15 — the physical sky's brightest haze hugs the horizon, and
2235
3085
  // grazing water sampled it into a blown-white band; a touch higher samples
2236
3086
  // believable pale blue instead.
2237
- reflectedDirection.y = max(abs(reflectedDirection.y), 0.15);
3087
+ // water-that-reads-as-water: a rough sea's MEAN reflected ray points
3088
+ // higher than the mirror ray (the facets tilt it up), so far water samples
3089
+ // the bluer sky above the haze band instead of the white haze itself
3090
+ reflectedDirection.y = max(abs(reflectedDirection.y), 0.15 + 0.4 * rough);
2238
3091
 
2239
3092
  // Sample environment map to get the reflected color
2240
3093
  // refinement pass 3: soft-cap the HDR radiance — the physical sky's
@@ -2244,6 +3097,19 @@ void main() {
2244
3097
  vec4 reflectionColor = textureCube(uEnvironmentMap, reflectedDirection);
2245
3098
  reflectionColor.rgb = min(reflectionColor.rgb, vec3(2.5));
2246
3099
 
3100
+ // SSR: what the screen can see standing above the water — a rock, a pier,
3101
+ // a wader — replaces the cube's sky where its reflected ray lands on it.
3102
+ // Walked with the UNCLAMPED reflection (a shore reflects at a grazing ray
3103
+ // the horizon clamp above would have lifted off it), only where it is
3104
+ // resolvable: past ~150 m the reflection of anything is under a pixel.
3105
+ if (uUseSsr > 0.5 && camDist < 150.0) {
3106
+ vec3 ssrDir = reflect(viewDirection, fresnelNormal);
3107
+ if (ssrDir.y > 0.005) {
3108
+ vec4 ssr = ssrTrace(vWorldPosition, ssrDir);
3109
+ reflectionColor.rgb = mix(reflectionColor.rgb, ssr.rgb, ssr.a * (1.0 - smoothstep(90.0, 150.0, camDist)));
3110
+ }
3111
+ }
3112
+
2247
3113
  // incanto MIRROR: where the planar pass has data it REPLACES the cube — it
2248
3114
  // is the same sky plus everything the cube map is blind to. Rays that leave
2249
3115
  // the screen have no mirror sample, so the border fade hands those back to
@@ -2275,6 +3141,17 @@ void main() {
2275
3141
  // incidence reflectance) keeps a whisper of sky at steep angles while the
2276
3142
  // default exponent 5 stops the HDR sky from whitewashing the whole surface.
2277
3143
  float fresnel = uFresnelScale * (0.02 + 0.98 * pow(1.0 - clamp(dot(-viewDirection, fresnelNormal), 0.0, 1.0), uFresnelPower));
3144
+ // water-that-reads-as-water: scale the smooth Schlick by the ratio a
3145
+ // ROUGH surface reflects at this angle (see envBRDFApprox) — near, calm
3146
+ // water is untouched; the far, wind-roughened field stops mirroring the
3147
+ // haze at 100% and keeps its own colour under the sky
3148
+ {
3149
+ float NoV = clamp(dot(-viewDirection, fresnelNormal), 0.0, 1.0);
3150
+ vec2 fRough = envBRDFApprox(rough, NoV);
3151
+ vec2 fSmooth = envBRDFApprox(0.02, NoV);
3152
+ float roughRatio = (0.02 * fRough.x + fRough.y) / max(0.02 * fSmooth.x + fSmooth.y, 1e-3);
3153
+ fresnel *= clamp(roughRatio, 0.0, 1.35);
3154
+ }
2278
3155
 
2279
3156
  // Calculate scale-normalized height value
2280
3157
  // incanto: relative to the waterline, not absolute world y
@@ -2301,11 +3178,25 @@ void main() {
2301
3178
  if (uSwellAmplitude > 0.0) {
2302
3179
  float crestBand = smoothstep(0.3, 0.9,
2303
3180
  (vWorldPosition.y - uWaterCenter.y) / max(uWaveEnvelope, 0.001));
2304
- mixedColor2 = mix(mixedColor2, uPeakColor, crestBand * 0.45);
3181
+ mixedColor2 = mix(mixedColor2, uPeakColor, crestBand * 0.3);
2305
3182
  float faceShade = clamp(dot(normalize(mix(vec3(0.0, 1.0, 0.0), normalize(vNormal),
2306
3183
  1.0 - 0.9 * smoothstep(150.0, 600.0, length(vWorldPosition - cameraPosition)))),
2307
3184
  normalize(uSunDirection)), 0.0, 1.0);
2308
3185
  mixedColor2 *= 0.80 + 0.32 * faceShade;
3186
+ // ============ light THROUGH the crest (the turquoise glow) ============
3187
+ // Sunlight entering the back of a wave scatters forward through the thin
3188
+ // crest and leaves its face toward the viewer — the translucent
3189
+ // turquoise a real crest lights up with when you look toward the sun.
3190
+ // It is the cue that says the surface is lit from INSIDE (water), not
3191
+ // painted on lumps (gel). Strongest looking into the sun through the
3192
+ // highest crests; the trough is thick water and stays dark.
3193
+ vec3 sssL = normalize(uSunDirection);
3194
+ vec3 sssH = normalize(sssL + geoSmooth * 0.35);
3195
+ float sssView = pow(clamp(dot(viewDirection, sssH), 0.0, 1.0), 3.0);
3196
+ float crestThin = smoothstep(0.1, 0.9,
3197
+ (vWorldPosition.y - uWaterCenter.y) / max(uWaveEnvelope, 0.001));
3198
+ vec3 sssColor = mix(uSurfaceColor, uPeakColor, 0.5) * vec3(0.75, 1.15, 1.05);
3199
+ mixedColor2 += sssColor * (sssView * crestThin * 0.9);
2309
3200
  }
2310
3201
 
2311
3202
  // incanto v2: Beer's-law depth absorption + screen-space refraction.
@@ -2440,41 +3331,45 @@ void main() {
2440
3331
  float reflectivity = clamp(fresnel, 0.0, uReflectivityMax) * uUseEnvironmentMap;
2441
3332
  vec3 finalColor = mix(bodyColor, reflectionColor.rgb, reflectivity);
2442
3333
 
2443
- // incanto v2: sun specular off the detail-perturbed normal.
2444
- // refinement pass 3: glints are SPARSE micro-facets. The old 320-power
2445
- // glint + everywhere-sheen flashed on half the ripples in view the whole
2446
- // sea read as dense white speckle (글로시 슬라임). Now: extreme shininess
2447
- // (1400) so only facets aligned within a fraction of a degree flash, and
2448
- // both terms are weighted toward the sun's AZIMUTH path crests sparkle
2449
- // along the light path, the rest of the surface stays broad calm color
2450
- // fields (the reference look). The sheen survives only as a soft halo on
2451
- // that path, near-zero elsewhere.
3334
+ // ============ the SUN as a microfacet highlight (the glitter road) ============
3335
+ // The old glint/streak/sheen trio was three hand-shaped powers gated by the
3336
+ // sun's azimuth, and it could not put light where a real sea puts it: a
3337
+ // 900-power lobe over ripple that tilts never reaches a 50°-high sun,
3338
+ // so most views had no sparkle at all, the few that did flashed uniform
3339
+ // white, and everything was capped at 0.85 below the bloom threshold,
3340
+ // so the brightest thing in any seascape never bloomed. One GGX lobe over
3341
+ // the shading normal does the whole thing: near the camera the per-pixel
3342
+ // facet churn IS the glitter; with distance the roughness grows (a pixel
3343
+ // integrates more facets) and the same lobe widens into the continuous
3344
+ // sun path a far sea shows. HDR on purpose — the sun's reflection is
3345
+ // meant to pass 1.0, and the bloom pass reads exactly that.
3346
+ // SUN_RADIANCE: the sun is ~1e5× the sky's radiance and the scene's
3347
+ // directional light carries none of that range, so the specular sun is
3348
+ // scaled relative to the unit sky the cube map holds.
3349
+ const float SUN_RADIANCE = 40.0;
2452
3350
  vec3 sunL = normalize(uSunDirection);
2453
- vec3 halfway = normalize(sunL - viewDirection); // -viewDirection = surface→camera
2454
- float specAngle = max(dot(normal, halfway), 0.0);
2455
- vec2 toFrag = normalize(vWorldPosition.xz - cameraPosition.xz);
2456
- float sunPath = pow(clamp(dot(toFrag, normalize(sunL.xz)), 0.0, 1.0), 5.0);
2457
- // micro-facet glints are a NEAR-field phenomenon on screen: past ~200 m a
2458
- // pixel integrates whole wave trains and per-pixel glints alias back into
2459
- // the old speckle fade them out and hand the sun path to a SMOOTH streak
2460
- // off the distance-smoothed normal (a real sea's far sun path is a soft
2461
- // continuous band, not glitter).
2462
- // …and a VIEW-angle phenomenon: looking straight down (the photo-ref
2463
- // clarity framing) a real surface shows almost no sparkle glints live at
2464
- // grazing angles. Weight by a Schlick-like grazing term so steep views
2465
- // stay calm, clear water instead of speckling.
2466
- float grazing = pow(1.0 - clamp(dot(-viewDirection, vec3(0.0, 1.0, 0.0)), 0.0, 1.0), 2.0);
2467
- // water-feel rewrite: NO artificial gate the micro-ripple spectrum's
2468
- // fast churn makes each facet align with the sun for only a flash, so the
2469
- // glitter is naturally granular AND flickers at ripple speed (the old
2470
- // slow-noise gate was another piece of the syrup feel).
2471
- float glintFade = 1.0 - smoothstep(60.0, 240.0, camDist);
2472
- float glint = pow(specAngle, 900.0) * (0.15 + 0.85 * sunPath) * glintFade
2473
- * (0.1 + 0.9 * grazing);
2474
- float farAngle = max(dot(fresnelNormal, halfway), 0.0);
2475
- float streak = pow(farAngle, 240.0) * 0.45 * sunPath * (1.0 - glintFade);
2476
- float sheen = pow(specAngle, 64.0) * 0.05 * sunPath;
2477
- finalColor += uSunColor * min(uSunIntensity * (glint + streak + sheen), 0.85);
3351
+ vec3 toCam = -viewDirection;
3352
+ vec3 halfway = normalize(sunL + toCam);
3353
+ float NoL = clamp(dot(normal, sunL), 0.0, 1.0);
3354
+ float NoVs = max(dot(normal, toCam), 1e-3);
3355
+ float NoH = clamp(dot(normal, halfway), 0.0, 1.0);
3356
+ float LoH = clamp(dot(sunL, halfway), 0.0, 1.0);
3357
+ // the sun is a disc, not a point: a floor on the lobe width so a glassy
3358
+ // pool shows a soft-edged sun, never a one-pixel dot
3359
+ float a = max(rough, 0.035);
3360
+ float a2 = a * a;
3361
+ float dD = NoH * NoH * (a2 - 1.0) + 1.0;
3362
+ float D = a2 / (3.14159265 * dD * dD);
3363
+ float gV = NoL * sqrt(NoVs * NoVs * (1.0 - a2) + a2);
3364
+ float gL = NoVs * sqrt(NoL * NoL * (1.0 - a2) + a2);
3365
+ float Vis = 0.5 / max(gV + gL, 1e-4);
3366
+ float Fh = 0.02 + 0.98 * pow(1.0 - LoH, 5.0);
3367
+ float sunSpec = D * Vis * Fh * NoL * SUN_RADIANCE;
3368
+ // a facet that spans less than a pixel aliases into a lone flash — the
3369
+ // roughness ramp has already widened the lobe out there, so the cap only
3370
+ // catches the last isolated ones
3371
+ float glintCap = mix(6.0, 1.5, smoothstep(40.0, 260.0, camDist));
3372
+ finalColor += uSunColor * (uSunIntensity * min(sunSpec, glintCap));
2478
3373
 
2479
3374
  // Calculate and apply foam effects (only when enableFoam is true)
2480
3375
  float foamAmount = 0.0;
@@ -2535,18 +3430,38 @@ void main() {
2535
3430
  float capFade = 1.0 - smoothstep(220.0, 640.0, camDist);
2536
3431
  // LOD: no cap, no distance, no noise — the patch field is two octaves
2537
3432
  float capNoise = 0.0;
2538
- if (capFade > 0.01 && max(cap, vFold) > 0.001) {
2539
- vec2 capUV = vWorldPosition.xz * 0.3;
2540
- capNoise = noise(capUV * 2.2 + uTime * vec2(0.10, 0.06)) * 0.65
2541
- + noise(capUV * 6.5 - uTime * vec2(0.05, 0.14)) * 0.35;
3433
+ // the FFT sea's whitecaps are its Jacobian's memory — born where the
3434
+ // surface actually folded, fading over seconds (fft-gpu.ts)
3435
+ float fold = max(vFold, fftFoam);
3436
+ if (capFade > 0.01 && max(cap, fold) > 0.001) {
3437
+ // water-that-reads-as-water: finer patches with a harder edge — a
3438
+ // whitecap is a torn streak of bubbles, not a soft white smudge
3439
+ vec2 capUV = vWorldPosition.xz * 0.5;
3440
+ capNoise = noise(capUV * 2.2 + uTime * vec2(0.16, 0.09)) * 0.55
3441
+ + noise(capUV * 7.5 - uTime * vec2(0.08, 0.22)) * 0.45;
2542
3442
  }
2543
3443
  // height says WHERE the crest is; the Jacobian says where it is actually
2544
3444
  // breaking. Taking the max keeps the tuned height-based caps and adds the
2545
3445
  // ones physics insists on — the pinched crest faces about to spill.
2546
- cap = max(cap, vFold);
2547
- float whitecap = uWhitecaps * cap * smoothstep(0.42, 0.78, capNoise) * capFade;
3446
+ cap = max(cap, fold);
3447
+ float whitecap = uWhitecaps * cap * smoothstep(0.50, 0.64, capNoise) * capFade;
2548
3448
  foamAmount = max(foamAmount, whitecap);
2549
3449
  }
3450
+ // the BREAKER: where the bed made the swell break, its crest is whitewater
3451
+ // — torn by a fast fine noise, riding the crest band only, and independent
3452
+ // of the whitecaps dial (a shore breaks in a dead calm sea state too)
3453
+ if (vBreak > 0.001 && uWaveEnvelope > 0.001) {
3454
+ // a broken crest sits LOW (its height was capped at 0.78·depth), so the
3455
+ // crest band starts near the mean line, not at the deep-water crest
3456
+ float crestB = smoothstep(0.02, 0.35, (vWorldPosition.y - uWaterCenter.y) / uWaveEnvelope);
3457
+ // TORN, not smudged: two fast fine octaves thresholded hard — a soft
3458
+ // low-frequency mask painted the surf as pale blotches (the gel again)
3459
+ float tearN = noise(vWorldPosition.xz * 5.5 - uTime * vec2(0.5, 0.3)) * 0.6 +
3460
+ noise(vWorldPosition.xz * 13.0 + uTime * vec2(0.3, 0.5)) * 0.4;
3461
+ float tear = smoothstep(0.38, 0.62, tearN);
3462
+ float nearFade = 1.0 - smoothstep(40.0, 120.0, camDist);
3463
+ foamAmount = max(foamAmount, vBreak * crestB * tear * nearFade);
3464
+ }
2550
3465
  // incanto: splash/wake whitewater rides the ripple buffer, INDEPENDENT of the
2551
3466
  // depth-foam gate so it works even on shallow ponds / before the first pre-pass
2552
3467
  float splashFoam = rippleFoam(vWorldPosition.xz);
@@ -2705,6 +3620,179 @@ void main() {
2705
3620
  }
2706
3621
  `;
2707
3622
  //#endregion
3623
+ //#region src/3d/water/shore.ts
3624
+ /**
3625
+ * The shore — pure math, no three.
3626
+ *
3627
+ * A wave that reaches a beach does three things a deep-water wave never does:
3628
+ * it slows and rises as the bottom comes up (shoaling, Green's law: amplitude
3629
+ * grows as depth^-1/4 once the water is shallower than a quarter wavelength),
3630
+ * its crest peaks up, and when the height reaches ~0.78 of the depth it
3631
+ * BREAKS. None of that can happen without the surface knowing where the
3632
+ * bottom is — which is what `Water3D.bed` supplies: a Terrain3D whose heights
3633
+ * are sampled once into a depth grid the vertex shader reads.
3634
+ *
3635
+ * `bedDepthGrid` builds that grid; `sprayPoints` picks the places in the
3636
+ * breaking band where the spray sprites live; `shoalFactor` is the CPU copy
3637
+ * of the vertex shader's amplitude rule, kept here so the two cannot drift.
3638
+ */
3639
+ /** Depth of the water column over a grid of `n × n` samples of the bed. */
3640
+ function bedDepthGrid(heightAt, waterY, rect, n) {
3641
+ const grid = new Float32Array(n * n);
3642
+ for (let j = 0; j < n; j++) {
3643
+ const z = rect.minZ + (j + .5) / n * rect.depth;
3644
+ for (let i = 0; i < n; i++) {
3645
+ const x = rect.minX + (i + .5) / n * rect.width;
3646
+ grid[j * n + i] = waterY - heightAt(x, z);
3647
+ }
3648
+ }
3649
+ return grid;
3650
+ }
3651
+ /**
3652
+ * Where spray lives: grid cells whose water column sits in the breaking band
3653
+ * (`minDepth`..`maxDepth`), thinned to at most `count` points by a seeded
3654
+ * draw so the set is the same on every client. Each point carries the
3655
+ * column it stands over (its front phase) and a seed (its own timing).
3656
+ */
3657
+ function sprayPoints(grid, n, rect, count, seed, minDepth = .25, maxDepth = 1.3) {
3658
+ const candidates = [];
3659
+ for (let idx = 0; idx < n * n; idx++) {
3660
+ const d = grid[idx];
3661
+ if (d >= minDepth && d <= maxDepth) candidates.push(idx);
3662
+ }
3663
+ const rng = new Rng(seed);
3664
+ const picked = [];
3665
+ const take = Math.min(count, candidates.length);
3666
+ for (let k = 0; k < take; k++) {
3667
+ const r = k + Math.floor(rng.next() * (candidates.length - k));
3668
+ const t = candidates[k];
3669
+ candidates[k] = candidates[r];
3670
+ candidates[r] = t;
3671
+ const idx = candidates[k];
3672
+ const i = idx % n;
3673
+ const j = (idx - i) / n;
3674
+ picked.push({
3675
+ x: rect.minX + (i + rng.next()) / n * rect.width,
3676
+ z: rect.minZ + (j + rng.next()) / n * rect.depth,
3677
+ depth: grid[idx],
3678
+ seed: rng.next()
3679
+ });
3680
+ }
3681
+ return picked;
3682
+ }
3683
+ //#endregion
3684
+ //#region src/3d/water/spray.ts
3685
+ /**
3686
+ * Shore spray (three): camera-facing sprites in the breaking band, each
3687
+ * bursting when the shader's own foam front passes its column — the same
3688
+ * `phase = depth · 0.75 − t · 0.4` the fragment marches (shaders.ts, the
3689
+ * shore-breaker block), so the spray and the whitewater are one event. A
3690
+ * sprite rises on a parabola, grows, and fades over the front's period; a
3691
+ * per-sprite seed staggers the burst so a set of breakers never fires as a
3692
+ * row of identical puffs.
3693
+ *
3694
+ * Instanced: a few hundred quads, one draw. Hung under the water's own mesh,
3695
+ * so every pass that hides the water (the grab, the mirror, the sky cube)
3696
+ * hides the spray with it.
3697
+ */
3698
+ const SPRAY_VERT = `
3699
+ attribute vec3 aSpot; // x, z, water column (m)
3700
+ attribute float aSeed;
3701
+ uniform float uTime;
3702
+ uniform float uWaterY;
3703
+ uniform float uStrength;
3704
+ varying vec2 vUv;
3705
+ varying float vAlpha;
3706
+ void main() {
3707
+ // the front's phase over this column — the fragment shader's rule
3708
+ float phase = aSpot.z * 0.75 - uTime * 0.4 + aSeed * 0.35;
3709
+ float life = fract(phase);
3710
+ // sets: a breaker only every so often, per sprite
3711
+ // sparse on purpose: a breaker throws spray on a few strokes, not every
3712
+ // one, and never in a row of identical puffs — two slow envelopes per
3713
+ // sprite, both must be up
3714
+ float sets = smoothstep(0.55, 0.9, 0.5 + 0.5 * sin(uTime * 0.31 + aSeed * 6.2831853));
3715
+ float sets2 = smoothstep(0.4, 0.8, 0.5 + 0.5 * sin(uTime * 0.17 + aSeed * 19.7));
3716
+ float size = (0.35 + 0.9 * life) * (0.7 + 0.5 * aSeed);
3717
+ float rise = 2.4 * life * (1.0 - life) * (0.9 + 0.6 * aSeed);
3718
+ vAlpha = (1.0 - life) * (1.0 - life) * sets * sets2 * uStrength * 0.45;
3719
+ vUv = uv;
3720
+ vec3 centre = vec3(aSpot.x, uWaterY + 0.1 + rise, aSpot.y);
3721
+ // camera-facing: the view matrix's rows are the camera's axes in world space
3722
+ vec3 right = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]);
3723
+ vec3 up = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]);
3724
+ vec3 world = centre + right * (position.x * size) + up * (position.y * size);
3725
+ gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
3726
+ }
3727
+ `;
3728
+ const SPRAY_FRAG = `
3729
+ precision highp float;
3730
+ varying vec2 vUv;
3731
+ varying float vAlpha;
3732
+ float hash(vec2 p) {
3733
+ p = fract(p * vec2(127.31, 311.7));
3734
+ p += dot(p, p + 34.23);
3735
+ return fract(p.x * p.y);
3736
+ }
3737
+ void main() {
3738
+ vec2 c = vUv - 0.5;
3739
+ float r = length(c);
3740
+ // a torn puff: a soft disc broken by a coarse hash so no two look alike
3741
+ float disc = 1.0 - smoothstep(0.18, 0.5, r);
3742
+ float grain = 0.55 + 0.45 * hash(floor(vUv * 6.0));
3743
+ float a = disc * grain * vAlpha;
3744
+ if (a < 0.01) discard;
3745
+ gl_FragColor = vec4(vec3(0.96, 0.98, 1.0), a);
3746
+ #include <tonemapping_fragment>
3747
+ #include <colorspace_fragment>
3748
+ }
3749
+ `;
3750
+ function createSprayMesh(points) {
3751
+ const quad = new PlaneGeometry(1, 1);
3752
+ const geometry = new InstancedBufferGeometry();
3753
+ geometry.index = quad.index;
3754
+ geometry.setAttribute("position", quad.getAttribute("position"));
3755
+ geometry.setAttribute("uv", quad.getAttribute("uv"));
3756
+ const spots = new Float32Array(points.length * 3);
3757
+ const seeds = new Float32Array(points.length);
3758
+ points.forEach((p, i) => {
3759
+ spots[i * 3] = p.x;
3760
+ spots[i * 3 + 1] = p.z;
3761
+ spots[i * 3 + 2] = p.depth;
3762
+ seeds[i] = p.seed;
3763
+ });
3764
+ geometry.setAttribute("aSpot", new InstancedBufferAttribute(spots, 3));
3765
+ geometry.setAttribute("aSeed", new InstancedBufferAttribute(seeds, 1));
3766
+ geometry.instanceCount = points.length;
3767
+ const uniforms = {
3768
+ uTime: { value: 0 },
3769
+ uWaterY: { value: 0 },
3770
+ uStrength: { value: 1 }
3771
+ };
3772
+ const material = new ShaderMaterial({
3773
+ vertexShader: SPRAY_VERT,
3774
+ fragmentShader: SPRAY_FRAG,
3775
+ uniforms,
3776
+ transparent: true,
3777
+ depthWrite: false,
3778
+ depthTest: true,
3779
+ blending: NormalBlending,
3780
+ side: DoubleSide
3781
+ });
3782
+ const mesh = new Mesh(geometry, material);
3783
+ mesh.frustumCulled = false;
3784
+ mesh.renderOrder = 3;
3785
+ return {
3786
+ mesh,
3787
+ uniforms,
3788
+ dispose() {
3789
+ geometry.dispose();
3790
+ quad.dispose();
3791
+ material.dispose();
3792
+ }
3793
+ };
3794
+ }
3795
+ //#endregion
2708
3796
  //#region src/3d/water/underwater.ts
2709
3797
  /**
2710
3798
  * True when the camera EYE sits below the water surface and inside its
@@ -3739,9 +4827,11 @@ const WATER_PRESET_NAMES = [
3739
4827
  const WATER_PRESETS = {
3740
4828
  ocean: {
3741
4829
  waveHeight: .045,
3742
- swell: .34,
4830
+ wind: 6.5,
4831
+ choppiness: .9,
4832
+ swell: .2,
3743
4833
  swellWavelength: 52,
3744
- whitecaps: .15,
4834
+ whitecaps: .4,
3745
4835
  absorption: .17,
3746
4836
  opacity: .85,
3747
4837
  detailStrength: .17,
@@ -3786,8 +4876,8 @@ const WATER_PRESETS = {
3786
4876
  },
3787
4877
  lake: {
3788
4878
  waveHeight: .028,
3789
- swell: .14,
3790
- swellWavelength: 24,
4879
+ swell: .03,
4880
+ swellWavelength: 9,
3791
4881
  whitecaps: 0,
3792
4882
  absorption: .5,
3793
4883
  opacity: .88,
@@ -3877,6 +4967,30 @@ const WAVE_WORLD_REF = 128;
3877
4967
  const AMPLITUDE_PER_WAVE_HEIGHT = REFERENCE_AMPLITUDE / REFERENCE_WAVE_HEIGHT;
3878
4968
  /** Cube-map reflection resolution (source: reflectionQuality = 256). */
3879
4969
  const REFLECTION_RESOLUTION = 256;
4970
+ /** The FFT sea: texels per side of the tiling patch, and the patch in metres
4971
+ * (0.78 m texels — the shortest wave the spectrum resolves is ~1.6 m; the
4972
+ * procedural ripple octaves carry everything finer). */
4973
+ const SEA_FFT_SIZE = 256;
4974
+ const SEA_FFT_PATCH = 200;
4975
+ /** One sea per seed: every client of a multiplayer game sees the same waves. */
4976
+ const SEA_FFT_SEED = 1337;
4977
+ /** The RIPPLE layer: a second, live FFT over a 6 m patch (4.7 cm texels) —
4978
+ * the wind ripple every fancy surface wears, replacing the procedural fine
4979
+ * octaves: real churn instead of noise, and a mip chain instead of hand-tuned
4980
+ * distance fades. A short fetch puts the spectrum's peak inside the patch. */
4981
+ const RIPPLE_FFT_SIZE = 128;
4982
+ const RIPPLE_FFT_PATCH = 6;
4983
+ /** Fetch is only a SHAPE control here: 150 m puts the JONSWAP peak at ~30 cm
4984
+ * (2 km put it at 2 m — the pool wore broad soft bulges, not ripple). */
4985
+ const RIPPLE_FETCH = 150;
4986
+ /** Ripple slope the layer is normalized to, per unit of `detailStrength`. */
4987
+ const RIPPLE_SLOPE_PER_DETAIL = .8;
4988
+ /** The bed depth grid (`bed`): samples per side over the terrain's footprint,
4989
+ * the depth assumed beyond it, and how many spray sprites the band gets. */
4990
+ const BED_GRID = 256;
4991
+ const BED_DEEP = 12;
4992
+ const SPRAY_COUNT = 120;
4993
+ const SPRAY_SEED = 9001;
3880
4994
  /**
3881
4995
  * Idle frames before a surface hands its render targets back — ~2 seconds at
3882
4996
  * 60 fps, and longer at a capped 30. Long enough that turning to look at a lake
@@ -4007,6 +5121,7 @@ var Water3D = class Water3D extends Node3D {
4007
5121
  reflectionInterval: { default: 1e3 },
4008
5122
  mirror: { default: false },
4009
5123
  mirrorInterval: { default: 33 },
5124
+ ssr: { default: true },
4010
5125
  swellSteepness: { default: .35 },
4011
5126
  foam: { default: true },
4012
5127
  reflectivity: {
@@ -4039,6 +5154,18 @@ var Water3D = class Water3D extends Node3D {
4039
5154
  swellDirectionDeg: { default: 0 },
4040
5155
  swellWavelength: { default: 30 },
4041
5156
  whitecaps: { default: 0 },
5157
+ bed: {
5158
+ default: "",
5159
+ nodePath: true
5160
+ },
5161
+ wind: {
5162
+ default: 0,
5163
+ range: { min: 0 }
5164
+ },
5165
+ choppiness: {
5166
+ default: .85,
5167
+ range: { min: 0 }
5168
+ },
4042
5169
  preset: {
4043
5170
  default: "custom",
4044
5171
  options: WATER_PRESET_NAMES
@@ -4080,6 +5207,16 @@ var Water3D = class Water3D extends Node3D {
4080
5207
  */
4081
5208
  mirror = false;
4082
5209
  /**
5210
+ * Screen-space reflections (fancy only, default on): the scene ABOVE the
5211
+ * waterline is rendered once at half resolution and every water pixel
5212
+ * marches its reflected ray through that image — so a rock, a pier, a
5213
+ * wader or a boat is mirrored on the water WITHOUT the planar `mirror`
5214
+ * pass. What the screen cannot see (behind the camera, off its edges)
5215
+ * falls back to the sky cube, which is why a mirror-calm lake still wants
5216
+ * `mirror: true`; with `mirror` on this is skipped as redundant.
5217
+ */
5218
+ ssr = true;
5219
+ /**
4083
5220
  * Milliseconds between planar-mirror re-renders. A reflection seen through a
4084
5221
  * rippling surface does not need 60 Hz — 33 ms halves the pass's cost and
4085
5222
  * nobody can tell. 0 re-renders every frame.
@@ -4144,6 +5281,30 @@ var Water3D = class Water3D extends Node3D {
4144
5281
  /** Primary swell wavelength in meters (the two crossing trains derive). */
4145
5282
  swellWavelength = 30;
4146
5283
  /**
5284
+ * The bottom under this water: a node path to a Terrain3D (fancy only). With
5285
+ * it the swell KNOWS the beach — waves slow and rise as the bottom comes up
5286
+ * (Green's law), their crests peak, and at 0.78× the depth they BREAK into
5287
+ * foam, with spray sprites bursting where the foam fronts pass. Sampled
5288
+ * once into a 256² depth grid over the terrain's footprint; beyond it the
5289
+ * water is assumed deep. Pair with `swell` (the waves that shoal) and
5290
+ * `shoreWaves` (the foam fronts the spray rides).
5291
+ */
5292
+ bed = "";
5293
+ /**
5294
+ * Wind speed in m/s — THE realistic-sea dial (fancy only; 0 = off). Above 0
5295
+ * the surface stops being a noise field and becomes a JONSWAP spectrum:
5296
+ * thousands of waves at every wavelength and heading, each moving at the
5297
+ * speed deep water gives it, inverse-FFT'd on the GPU every frame into a
5298
+ * tiling patch with real crest shapes, choppy horizontal drag and whitecaps
5299
+ * born where the surface folds. The wind blows toward `swellDirectionDeg`.
5300
+ * 4 = a light breeze (ripples, 20 cm), 7 = a fresh breeze (1 m waves),
5301
+ * 12 = rough. The CPU twin (buoyancy, waders) sums the same spectrum.
5302
+ */
5303
+ wind = 0;
5304
+ /** Horizontal drag of the FFT sea toward its crests, 0..~1.5 — narrow
5305
+ * crests, broad troughs; past ~1.2 the surface folds and foams everywhere. */
5306
+ choppiness = .85;
5307
+ /**
4147
5308
  * GERSTNER pinch on the swell: 0 keeps the legacy height-only trains, 1 is
4148
5309
  * maximum trochoid (crests narrow to a peak, troughs go broad). It also
4149
5310
  * decides where whitecaps break — the surface's Jacobian collapses on a
@@ -4218,6 +5379,8 @@ var Water3D = class Water3D extends Node3D {
4218
5379
  const sun = w.sunDirection;
4219
5380
  if (!Array.isArray(sun) || sun.length !== 3 || !sun.every((v) => Number.isFinite(v)) || Math.hypot(sun[0] ?? 0, sun[1] ?? 0, sun[2] ?? 0) === 0) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' sunDirection must be a non-zero [x, y, z] vector, got ${JSON.stringify(sun)}.`, { prop: "sunDirection" });
4220
5381
  if (!(w.sunIntensity >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' sunIntensity must be >= 0, got ${w.sunIntensity}.`, { prop: "sunIntensity" });
5382
+ if (!(w.wind >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' wind must be >= 0 m/s, got ${w.wind}.`, { prop: "wind" });
5383
+ if (!(w.choppiness >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' choppiness must be >= 0, got ${w.choppiness}.`, { prop: "choppiness" });
4221
5384
  if (!(w.detailStrength >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' detailStrength must be >= 0, got ${w.detailStrength}.`, { prop: "detailStrength" });
4222
5385
  if (!(w.absorption >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' absorption must be >= 0 (per-meter Beer's-law constant), got ${w.absorption}.`, { prop: "absorption" });
4223
5386
  if (!(w.swell >= 0) || !Number.isFinite(w.swell)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' swell must be >= 0 meters (0 = off), got ${JSON.stringify(w.swell)}.`, { prop: "swell" });
@@ -4270,6 +5433,26 @@ var Water3D = class Water3D extends Node3D {
4270
5433
  /** Per-frame scene pre-pass: depth (absorption/foam) + color grab (refraction). */
4271
5434
  scenePassTarget = null;
4272
5435
  mirrorTarget = null;
5436
+ /** Half-res render of the scene ABOVE the waterline — what SSR reflects. */
5437
+ aboveTarget = null;
5438
+ ssrViewProj = new Matrix4();
5439
+ /** The FFT sea (wind > 0): the GPU passes, and the CPU spectrum they share. */
5440
+ seaFft = null;
5441
+ seaFftUnsupported = false;
5442
+ spectrum = null;
5443
+ spectrumKey = "";
5444
+ uploadedSpectrumKey = "";
5445
+ rippleFft = null;
5446
+ rippleSpectrum = null;
5447
+ rippleKey = "";
5448
+ uploadedRippleKey = "";
5449
+ /** The bed depth grid (see `bed`), its footprint, and the spray that rides it. */
5450
+ bedTexture = null;
5451
+ bedRect = null;
5452
+ bedKey = "";
5453
+ spray = null;
5454
+ /** Vertex spacing of the fancy grid — the mip the vertex shader reads at. */
5455
+ fancySegments = FANCY_SEGMENTS_MIN;
4273
5456
  mirrorCamera = new PerspectiveCamera();
4274
5457
  mirrorMatrix = new Matrix4();
4275
5458
  lastMirrorAt = 0;
@@ -4324,6 +5507,40 @@ var Water3D = class Water3D extends Node3D {
4324
5507
  if (u.uCameraSubmerged) u.uCameraSubmerged.value = 0;
4325
5508
  this.idleFrames = 0;
4326
5509
  const now = Date.now();
5510
+ const spectrum = this.seaSpectrum();
5511
+ if (spectrum && !this.seaFftUnsupported) {
5512
+ if (!this.seaFft) if (!seaFftSupported(ctx.gl)) this.seaFftUnsupported = true;
5513
+ else this.seaFft = new SeaFft(SEA_FFT_SIZE, SEA_FFT_PATCH);
5514
+ if (this.seaFft) {
5515
+ if (this.uploadedSpectrumKey !== this.spectrumKey) {
5516
+ this.seaFft.setSpectrum(spectrum);
5517
+ this.uploadedSpectrumKey = this.spectrumKey;
5518
+ }
5519
+ this.seaFft.update(ctx.gl, this.time);
5520
+ if (u.uFftDisp) u.uFftDisp.value = this.seaFft.displacement;
5521
+ if (u.uFftNormal) u.uFftNormal.value = this.seaFft.normal;
5522
+ if (u.uFftOn) u.uFftOn.value = 1;
5523
+ }
5524
+ } else if (u.uFftOn) u.uFftOn.value = 0;
5525
+ if (!this.seaFftUnsupported) {
5526
+ if (!this.rippleFft) if (!seaFftSupported(ctx.gl)) this.seaFftUnsupported = true;
5527
+ else this.rippleFft = new SeaFft(RIPPLE_FFT_SIZE, RIPPLE_FFT_PATCH);
5528
+ if (this.rippleFft) {
5529
+ const rs = this.rippleSpectrumFor();
5530
+ if (this.uploadedRippleKey !== this.rippleKey) {
5531
+ this.rippleFft.setSpectrum(rs);
5532
+ this.uploadedRippleKey = this.rippleKey;
5533
+ }
5534
+ this.rippleFft.update(ctx.gl, this.time);
5535
+ if (u.uRippleNormal) u.uRippleNormal.value = this.rippleFft.normal;
5536
+ if (u.uRippleOn) u.uRippleOn.value = 1;
5537
+ if (u.uRippleGain) {
5538
+ const target = this.rp("detailStrength") * RIPPLE_SLOPE_PER_DETAIL;
5539
+ u.uRippleGain.value = rs.rmsSlope > 0 ? target / rs.rmsSlope : 0;
5540
+ }
5541
+ }
5542
+ }
5543
+ if (this.seaFftUnsupported && u.uRippleOn) u.uRippleOn.value = 0;
4327
5544
  if (this.reflection) {
4328
5545
  if (!this.cubeTarget) this.cubeTarget = new WebGLCubeRenderTarget(REFLECTION_RESOLUTION, {
4329
5546
  generateMipmaps: true,
@@ -4460,6 +5677,44 @@ var Water3D = class Water3D extends Node3D {
4460
5677
  ctx.scene.overrideMaterial = prevOverride;
4461
5678
  ctx.scene.background = prevBackground;
4462
5679
  this.renderCutoutMasks(ctx);
5680
+ if (this.ssr && !this.effectiveMirror() && (this.caps ? this.caps.waterMirror : true)) {
5681
+ if (this.aboveTarget && (this.aboveTarget.width !== passW || this.aboveTarget.height !== passH)) {
5682
+ this.aboveTarget.depthTexture?.dispose();
5683
+ this.aboveTarget.dispose();
5684
+ this.aboveTarget = null;
5685
+ }
5686
+ if (!this.aboveTarget) {
5687
+ const depthTexture = new DepthTexture(passW, passH);
5688
+ depthTexture.type = FloatType;
5689
+ this.aboveTarget = new WebGLRenderTarget(passW, passH, {
5690
+ depthTexture,
5691
+ depthBuffer: true,
5692
+ stencilBuffer: false,
5693
+ type: HalfFloatType,
5694
+ generateMipmaps: true,
5695
+ minFilter: LinearMipmapLinearFilter
5696
+ });
5697
+ }
5698
+ ctx.gl.clippingPlanes = depthOnlyClipPlanes;
5699
+ ctx.gl.setRenderTarget(this.aboveTarget);
5700
+ ctx.gl.autoClear = true;
5701
+ ctx.gl.clear(true, true, false);
5702
+ hideForWaterPass(ctx.scene, this._passHidden);
5703
+ ctx.gl.render(ctx.scene, ctx.camera);
5704
+ restoreForWaterPass(this._passHidden);
5705
+ this.ssrViewProj.multiplyMatrices(ctx.camera.projectionMatrix, ctx.camera.matrixWorldInverse);
5706
+ if (u.uUseSsr) u.uUseSsr.value = 1;
5707
+ if (u.uAboveColor) u.uAboveColor.value = this.aboveTarget.texture;
5708
+ if (u.uAboveDepth) u.uAboveDepth.value = this.aboveTarget.depthTexture;
5709
+ (u.uSsrViewProj?.value)?.copy(this.ssrViewProj);
5710
+ } else {
5711
+ if (u.uUseSsr) u.uUseSsr.value = 0;
5712
+ if (this.aboveTarget) {
5713
+ this.aboveTarget.depthTexture?.dispose();
5714
+ this.aboveTarget.dispose();
5715
+ this.aboveTarget = null;
5716
+ }
5717
+ }
4463
5718
  ctx.gl.setRenderTarget(prevTarget);
4464
5719
  ctx.gl.autoClear = prevAutoClear;
4465
5720
  ctx.gl.clippingPlanes = prevClippingPlanes;
@@ -4521,6 +5776,23 @@ var Water3D = class Water3D extends Node3D {
4521
5776
  this.scenePassTarget?.depthTexture?.dispose();
4522
5777
  this.scenePassTarget?.dispose();
4523
5778
  this.scenePassTarget = null;
5779
+ this.seaFft?.dispose();
5780
+ this.seaFft = null;
5781
+ this.uploadedSpectrumKey = "";
5782
+ this.aboveTarget?.depthTexture?.dispose();
5783
+ this.aboveTarget?.dispose();
5784
+ this.aboveTarget = null;
5785
+ this.rippleFft?.dispose();
5786
+ this.rippleFft = null;
5787
+ this.uploadedRippleKey = "";
5788
+ this.bedTexture?.dispose();
5789
+ this.bedTexture = null;
5790
+ this.bedKey = "";
5791
+ if (this.spray) {
5792
+ this._builtObject3D?.remove(this.spray.mesh);
5793
+ this.spray.dispose();
5794
+ this.spray = null;
5795
+ }
4524
5796
  }
4525
5797
  /**
4526
5798
  * Frames in a row that took no pass at all (submerged, or off camera).
@@ -4614,7 +5886,9 @@ var Water3D = class Water3D extends Node3D {
4614
5886
  mesh.geometry = buildWaterGeometry(w, d, SIMPLE_SEGMENTS);
4615
5887
  mesh.material = this.buildLakeMaterial();
4616
5888
  } else {
4617
- mesh.geometry = buildWaterGeometry(w, d, Math.min(Math.max(Math.round(Math.max(w, d) * FANCY_SEGMENTS_PER_M), FANCY_SEGMENTS_MIN), FANCY_SEGMENTS_MAX));
5889
+ const segments = Math.min(Math.max(Math.round(Math.max(w, d) * FANCY_SEGMENTS_PER_M), FANCY_SEGMENTS_MIN), FANCY_SEGMENTS_MAX);
5890
+ this.fancySegments = segments;
5891
+ mesh.geometry = buildWaterGeometry(w, d, segments);
4618
5892
  mesh.material = this.buildFancyMaterial();
4619
5893
  }
4620
5894
  }
@@ -4686,7 +5960,24 @@ var Water3D = class Water3D extends Node3D {
4686
5960
  uShoreWaves: { value: this.rp("shoreWaves") },
4687
5961
  uReflectivityMax: { value: .6 },
4688
5962
  uCameraSubmerged: { value: 0 },
4689
- uEdgeFadeOn: { value: 1 }
5963
+ uEdgeFadeOn: { value: 1 },
5964
+ uUseSsr: { value: 0 },
5965
+ uAboveColor: { value: null },
5966
+ uAboveDepth: { value: null },
5967
+ uSsrViewProj: { value: new Matrix4() },
5968
+ uBedOn: { value: 0 },
5969
+ uBedDepth: { value: null },
5970
+ uBedRect: { value: new Vector4(0, 0, 1, 1) },
5971
+ uBedDeep: { value: BED_DEEP },
5972
+ uRippleOn: { value: 0 },
5973
+ uRippleNormal: { value: null },
5974
+ uRipplePatch: { value: RIPPLE_FFT_PATCH },
5975
+ uRippleGain: { value: 0 },
5976
+ uFftOn: { value: 0 },
5977
+ uFftDisp: { value: null },
5978
+ uFftNormal: { value: null },
5979
+ uFftPatch: { value: SEA_FFT_PATCH },
5980
+ uFftLod: { value: 0 }
4690
5981
  },
4691
5982
  transparent: true,
4692
5983
  depthTest: true,
@@ -4817,7 +6108,14 @@ var Water3D = class Water3D extends Node3D {
4817
6108
  if (u.uFoamIntensity) u.uFoamIntensity.value = look.foamIntensity;
4818
6109
  const swellRad = this.swellDirectionDeg * Math.PI / 180;
4819
6110
  (u.uSwellDir?.value)?.set(Math.sin(swellRad), Math.cos(swellRad));
4820
- if (u.uWaveEnvelope) u.uWaveEnvelope.value = this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT * 1.5 + this.rp("swell") * 1.03;
6111
+ const spectrum = this.seaSpectrum();
6112
+ if (u.uWaveEnvelope) u.uWaveEnvelope.value = (spectrum ? spectrum.significantHeight * .5 : this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT * 1.5) + this.rp("swell") * 1.03;
6113
+ if (u.uFftOn) u.uFftOn.value = spectrum ? 1 : 0;
6114
+ if (u.uFftLod) {
6115
+ const spacing = Math.max(w, d) / Math.max(this.fancySegments, 1);
6116
+ const texel = SEA_FFT_PATCH / SEA_FFT_SIZE;
6117
+ u.uFftLod.value = Math.max(0, Math.min(7, Math.log2(spacing / texel)));
6118
+ }
4821
6119
  if (!this.reflection && u.uUseEnvironmentMap) u.uUseEnvironmentMap.value = 0;
4822
6120
  if (!this.foam && u.uEnableFoam) u.uEnableFoam.value = 0;
4823
6121
  if (!this.refraction && u.uUseRefraction) u.uUseRefraction.value = 0;
@@ -4830,6 +6128,67 @@ var Water3D = class Water3D extends Node3D {
4830
6128
  else ripples[i].set(0, 0, 0, 0);
4831
6129
  if (foam) foam[i] = r ? r.foam ?? 0 : 0;
4832
6130
  }
6131
+ this.syncBed(mesh, u, center.y);
6132
+ }
6133
+ /**
6134
+ * The bed depth grid and the spray that rides it. Built once per `bed`
6135
+ * (terrain heights are static); torn down when the path is cleared.
6136
+ */
6137
+ syncBed(mesh, u, waterY) {
6138
+ const path = this.bed;
6139
+ const terrain = path ? this.getNodeOrNull(path) : null;
6140
+ const usable = terrain && typeof terrain.heightAt === "function" && Array.isArray(terrain.size);
6141
+ const key = usable ? `${path}|${waterY.toFixed(3)}|${this.rp("shoreWaves")}` : "";
6142
+ if (key !== this.bedKey) {
6143
+ this.bedKey = key;
6144
+ this.bedTexture?.dispose();
6145
+ this.bedTexture = null;
6146
+ this.bedRect = null;
6147
+ if (this.spray) {
6148
+ mesh.remove(this.spray.mesh);
6149
+ this.spray.dispose();
6150
+ this.spray = null;
6151
+ }
6152
+ if (usable && terrain) {
6153
+ const t = terrain;
6154
+ const centre = worldTranslationOf(t);
6155
+ const w = t.size[0] ?? 1;
6156
+ const d = t.size[1] ?? 1;
6157
+ const rect = {
6158
+ minX: centre.x - w / 2,
6159
+ minZ: centre.z - d / 2,
6160
+ width: w,
6161
+ depth: d
6162
+ };
6163
+ const grid = bedDepthGrid((x, z) => t.heightAt(x, z), waterY, rect, BED_GRID);
6164
+ const half = new Uint16Array(BED_GRID * BED_GRID * 4);
6165
+ for (let i = 0; i < grid.length; i++) {
6166
+ half[i * 4] = DataUtils.toHalfFloat(grid[i]);
6167
+ half[i * 4 + 3] = DataUtils.toHalfFloat(1);
6168
+ }
6169
+ const tex = new DataTexture(half, BED_GRID, BED_GRID, RGBAFormat, HalfFloatType);
6170
+ tex.minFilter = LinearFilter;
6171
+ tex.magFilter = LinearFilter;
6172
+ tex.needsUpdate = true;
6173
+ this.bedTexture = tex;
6174
+ this.bedRect = rect;
6175
+ if (this.rp("shoreWaves") > 0) {
6176
+ const points = sprayPoints(grid, BED_GRID, rect, SPRAY_COUNT, SPRAY_SEED, .3, .9);
6177
+ if (points.length > 0) {
6178
+ this.spray = createSprayMesh(points);
6179
+ mesh.add(this.spray.mesh);
6180
+ }
6181
+ }
6182
+ }
6183
+ }
6184
+ if (u.uBedOn) u.uBedOn.value = this.bedTexture ? 1 : 0;
6185
+ if (u.uBedDepth) u.uBedDepth.value = this.bedTexture;
6186
+ if (this.bedRect) (u.uBedRect?.value)?.set(this.bedRect.minX, this.bedRect.minZ, 1 / this.bedRect.width, 1 / this.bedRect.depth);
6187
+ if (this.spray) {
6188
+ this.spray.uniforms.uTime.value = this.time;
6189
+ this.spray.uniforms.uWaterY.value = waterY;
6190
+ this.spray.uniforms.uStrength.value = Math.min(this.rp("shoreWaves"), 1);
6191
+ }
4833
6192
  }
4834
6193
  /**
4835
6194
  * @internal Environment-sky sun hand-off (see {@link SunConsumer3D}): the
@@ -5019,8 +6378,56 @@ var Water3D = class Water3D extends Node3D {
5019
6378
  }
5020
6379
  /** Base wave height at world (x, z) — the CPU twin of the active shader.
5021
6380
  * Ripples are excluded on purpose: a splash must not re-trigger itself. */
6381
+ /**
6382
+ * The sea's spectrum, or null when there is no wind (or the tier collapsed
6383
+ * the surface to the simple shader, whose CPU waves are its own). Built on
6384
+ * the CPU once per (wind, heading, choppiness) and shared by the GPU upload
6385
+ * and the headless twin — one sea, two evaluators.
6386
+ */
6387
+ seaSpectrum() {
6388
+ const wind = this.rp("wind");
6389
+ if (!(wind > 0) || this.effectiveQuality() !== "fancy" || this.seaFftUnsupported) return null;
6390
+ const key = `${wind}|${this.swellDirectionDeg}|${this.rp("choppiness")}`;
6391
+ if (key !== this.spectrumKey || !this.spectrum) {
6392
+ this.spectrum = buildSeaSpectrum({
6393
+ size: SEA_FFT_SIZE,
6394
+ patch: SEA_FFT_PATCH,
6395
+ wind,
6396
+ windDirectionDeg: this.swellDirectionDeg,
6397
+ seed: SEA_FFT_SEED,
6398
+ choppiness: this.rp("choppiness")
6399
+ });
6400
+ this.spectrumKey = key;
6401
+ }
6402
+ return this.spectrum;
6403
+ }
6404
+ /**
6405
+ * The ripple layer's spectrum: the sea's own wind when it blows, else the
6406
+ * breeze that puts wind ripple on a pool — its ENERGY is irrelevant (the
6407
+ * layer is normalized to `detailStrength` by rms slope), only its shape.
6408
+ */
6409
+ rippleSpectrumFor() {
6410
+ const wind = Math.min(Math.max(this.rp("wind"), 2.5), 9);
6411
+ const key = `${wind}|${this.swellDirectionDeg}`;
6412
+ if (key !== this.rippleKey || !this.rippleSpectrum) {
6413
+ this.rippleSpectrum = buildSeaSpectrum({
6414
+ size: RIPPLE_FFT_SIZE,
6415
+ patch: RIPPLE_FFT_PATCH,
6416
+ wind,
6417
+ windDirectionDeg: this.swellDirectionDeg,
6418
+ seed: 1338,
6419
+ choppiness: .4,
6420
+ fetch: RIPPLE_FETCH,
6421
+ modes: 0
6422
+ });
6423
+ this.rippleKey = key;
6424
+ }
6425
+ return this.rippleSpectrum;
6426
+ }
5022
6427
  waveOffsetAt(x, z, center) {
5023
6428
  if (this.effectiveQuality() === "simple") return simpleWaveHeight(x - center.x, z - center.z, this.time, this.rp("waveHeight"));
6429
+ const spectrum = this.seaSpectrum();
6430
+ if (spectrum) return seaElevation(spectrum, x, z, this.time);
5024
6431
  return waveElevation(x, z, this.time, {
5025
6432
  amplitude: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT,
5026
6433
  frequency: FANCY_WAVE.frequency,
@@ -11424,4 +12831,4 @@ function registerGameplayBehaviors(opts) {
11424
12831
  for (const [name, ctor] of Object.entries(GAMEPLAY_BEHAVIORS)) registerBehavior(name, ctor, { replace });
11425
12832
  }
11426
12833
  //#endregion
11427
- export { Water3D as $, Lifetime as A, FollowCamera as B, Pickup as C, Oscillate as D, PathFollow as E, Interactable as F, phaseOf as G, FloatAway as H, GroupCamera as I, Collector as J, DamageOnContact as K, GameFlow as L, Cooldown as M, hitStop as N, MoveTo as O, screenFlash as P, tolerateUnknownAction as Q, goToScene as R, PrefabShelf as S, Patrol as T, FaceTarget as U, effectRng as V, DayNight as W, ChaseCamera as X, Checkpoint as Y, Chase as Z, Node2D as _, Wander as a, PhysicsBody3D as at, SavePoint as b, ballisticPath as c, Node3D as ct, Sight as d, WATER_MAX_RIPPLES as dt, WATER_CUTOUT_MAX as et, Area2D as f, colliderFootDrop as ft, StaticBody2D as g, RigidBody2D as h, findHealth as ht, WaveSpawner as i, CharacterBody3D as it, CameraShake as j, Mount as k, maxRangeAim as l, validateCollider3D as lt, PhysicsBody2D as m, Health as mt, registerGameplayBehaviors as n, rejectScale as nt, Turret as o, RigidBody3D as ot, CharacterBody2D as p, Blast as pt, Currency as q, ZombieAI as r, Area3D as rt, ballisticAim as s, StaticBody3D as st, GAMEPLAY_BEHAVIORS as t, WaterCutout3D as tt, Spawner as u, createCausticsQuad as ut, validateCollider2D as v, Phases as w, Projectile as x, ScoreKeeper as y, restartScene as z };
12834
+ export { Water3D as $, Lifetime as A, FollowCamera as B, Pickup as C, Oscillate as D, PathFollow as E, Interactable as F, phaseOf as G, FloatAway as H, GroupCamera as I, Collector as J, DamageOnContact as K, GameFlow as L, Cooldown as M, hitStop as N, MoveTo as O, screenFlash as P, tolerateUnknownAction as Q, goToScene as R, PrefabShelf as S, Patrol as T, FaceTarget as U, effectRng as V, DayNight as W, ChaseCamera as X, Checkpoint as Y, Chase as Z, Node2D as _, Health as _t, Wander as a, PhysicsBody3D as at, SavePoint as b, ballisticPath as c, Node3D as ct, Sight as d, WATER_MAX_RIPPLES as dt, WATER_CUTOUT_MAX as et, Area2D as f, colliderFootDrop as ft, StaticBody2D as g, Blast as gt, RigidBody2D as h, buildSeaSpectrum as ht, WaveSpawner as i, CharacterBody3D as it, CameraShake as j, Mount as k, maxRangeAim as l, validateCollider3D as lt, PhysicsBody2D as m, seaFftSupported as mt, registerGameplayBehaviors as n, rejectScale as nt, Turret as o, RigidBody3D as ot, CharacterBody2D as p, SeaFft as pt, Currency as q, ZombieAI as r, Area3D as rt, ballisticAim as s, StaticBody3D as st, GAMEPLAY_BEHAVIORS as t, WaterCutout3D as tt, Spawner as u, createCausticsQuad as ut, validateCollider2D as v, findHealth as vt, Phases as w, Projectile as x, ScoreKeeper as y, restartScene as z };