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.
- package/dist/2d.d.ts +1 -1
- package/dist/2d.js +4 -4
- package/dist/3d.d.ts +121 -3
- package/dist/3d.js +4 -4
- package/dist/{create-game-DnwhJ5nW.js → create-game-BMIPKrQW.js} +5 -5
- package/dist/{create-game-DVUhHs2B.js → create-game-Dai5vfVu.js} +5 -5
- package/dist/{editor-switch-O8mxpgRX.d.ts → editor-switch-Bzt0GzVp.d.ts} +12 -2
- package/dist/{environment-presets-BP77Dl0K.js → environment-presets-e_9YCnGu.js} +348 -36
- package/dist/{gameplay-D-JuGura.js → gameplay-Bfff_beb.js} +1468 -61
- package/dist/gameplay.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{physics-2d-x3bCrQf0.js → physics-2d-Tuf5LAyq.js} +2 -2
- package/dist/{physics-3d-Bz2VPQ6D.js → physics-3d-C5MOujfQ.js} +2 -2
- package/dist/{picking-Buita5T9.js → picking-DIwE48sU.js} +8 -5
- package/dist/react.js +1 -1
- package/dist/{register-DxAbTO2T.js → register-7E3P1gz6.js} +1 -1
- package/dist/{src-DLKVc6Hj.js → src-DygJfhOb.js} +1 -1
- package/dist/{test-HlVUeCSn.js → test-CVbxnXlv.js} +7 -7
- package/dist/test.js +1 -1
- package/dist/vite.d.ts +47 -3
- package/dist/vite.js +81 -20
- package/editor/assets/{agent8-BXlYM9rv.js → agent8-DA-I5qhv.js} +1 -1
- package/editor/assets/{debug-S42XGP4Y.js → debug-99tEPoGm.js} +1 -1
- package/editor/assets/index-Db74LZY-.js +11579 -0
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/schemas/scene.schema.json +30 -2
- package/skills/incanto-3d-character.md +26 -1
- package/skills/incanto-editor.md +56 -2
- package/skills/incanto-environment.md +30 -12
- package/skills/incanto-node-reference.md +6 -1
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +6 -1
- package/templates-app/molehill-2d/package.json +1 -1
- package/templates-app/platformer-2d/package.json +1 -1
- package/templates-app/star-survivor/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
2444
|
-
//
|
|
2445
|
-
//
|
|
2446
|
-
//
|
|
2447
|
-
//
|
|
2448
|
-
//
|
|
2449
|
-
//
|
|
2450
|
-
//
|
|
2451
|
-
//
|
|
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 3° 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
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
float
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
// the
|
|
2460
|
-
//
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
float
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
//
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
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
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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,
|
|
2547
|
-
float whitecap = uWhitecaps * cap * smoothstep(0.
|
|
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
|
-
|
|
4830
|
+
wind: 6.5,
|
|
4831
|
+
choppiness: .9,
|
|
4832
|
+
swell: .2,
|
|
3743
4833
|
swellWavelength: 52,
|
|
3744
|
-
whitecaps: .
|
|
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: .
|
|
3790
|
-
swellWavelength:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 };
|