incanto 0.21.0 → 0.22.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.
@@ -1,12 +1,12 @@
1
- import { f as Node } from "./loader-BZqOKfI2.js";
2
1
  import { t as registerCoreNodes } from "./register-nObreUQR.js";
3
2
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
3
  import { t as Rng } from "./rng-DP-SR7eg.js";
5
4
  import { i as getNodeSchema, l as registerNode } from "./registry-BVJ2HbCn.js";
6
5
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
7
- import { i as applyParticlePreset, o as effectiveOrder, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-CyUU7HVU.js";
6
+ import { i as applyParticlePreset, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-Bw7hB93B.js";
7
+ import { A as Water3D, B as colliderFootDrop, F as StaticBody3D, I as Node3D, M as CharacterBody3D, N as PhysicsBody3D, P as RigidBody3D, j as Area3D } from "./gameplay-Dib5mNFD.js";
8
8
  import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
9
- import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, Color, ConeGeometry, CubeCamera, CylinderGeometry, DataTexture, DepthTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, FloatType, Frustum, Group, HalfFloatType, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LinearMipmapLinearFilter, LoopOnce, LoopRepeat, MathUtils, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NormalBlending, Object3D, PerspectiveCamera, Plane, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, Sphere, SphereGeometry, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector2, Vector3, Vector4, VectorKeyframeTrack, WebGLCubeRenderTarget, WebGLRenderTarget } from "three";
9
+ import { AdditiveBlending, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CapsuleGeometry, Color, ConeGeometry, CylinderGeometry, DataTexture, DirectionalLight, DoubleSide, DynamicDrawUsage, Euler, Group, IcosahedronGeometry, ImageBitmapLoader, InstancedBufferAttribute, InstancedMesh, LinearFilter, LoopOnce, LoopRepeat, Matrix4, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshPhysicalMaterial, MeshStandardMaterial, NearestFilter, NoBlending, NormalBlending, PerspectiveCamera, PlaneGeometry, PointLight, Points, PointsMaterial, Quaternion, QuaternionKeyframeTrack, RGBADepthPacking, RGBAFormat, RepeatWrapping, SRGBColorSpace, ShaderChunk, ShaderMaterial, SphereGeometry, Texture, TextureLoader, UniformsLib, UniformsUtils, Vector3, Vector4, VectorKeyframeTrack } from "three";
10
10
  import { clone } from "three/addons/utils/SkeletonUtils.js";
11
11
  //#region src/3d/camera-rig.ts
12
12
  const QUARTER_PITCH = Math.atan(1 / Math.SQRT2);
@@ -151,269 +151,6 @@ function movementState(grounded, intensity) {
151
151
  return "fastRun";
152
152
  }
153
153
  //#endregion
154
- //#region src/3d/physics/collider-validate.ts
155
- /**
156
- * Hard-validate a 3D collider prop at LOAD time (see the 2D twin for rationale).
157
- * An EMPTY collider is allowed — a body without a collider is work in progress.
158
- */
159
- function validateCollider3D(collider, nodeName) {
160
- const shape = collider.shape;
161
- if (shape === void 0) return;
162
- if (shape === "box") {
163
- const size = collider.size;
164
- if (!Array.isArray(size) || size.length !== 3 || size.some((v) => typeof v !== "number")) throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}': box needs "size": [x, y, z].`);
165
- return;
166
- }
167
- if (shape === "sphere") {
168
- if (typeof collider.radius !== "number") throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}': sphere needs "radius".`);
169
- return;
170
- }
171
- if (shape === "capsule") {
172
- if (typeof collider.radius !== "number" || typeof collider.height !== "number") throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}': capsule needs "radius" and "height".`);
173
- return;
174
- }
175
- if (shape === "trimesh") {
176
- const vertices = collider.vertices;
177
- const indices = collider.indices;
178
- if (!Array.isArray(vertices) || vertices.length % 3 !== 0 || !Array.isArray(indices) || indices.length % 3 !== 0) throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}': trimesh needs "vertices" (flat xyz floats) and "indices" (triangle triplets).`);
179
- return;
180
- }
181
- if (shape === "heightfield") return;
182
- throw new IncantoError("BAD_FORMAT", `Collider on '${nodeName}': "shape" must be 'box', 'sphere', 'capsule', 'trimesh', or 'heightfield', got ${JSON.stringify(shape)}.`);
183
- }
184
- //#endregion
185
- //#region src/3d/nodes/node-3d.ts
186
- /**
187
- * Base of every 3D node: a JSON-prop transform backed by a `THREE.Object3D`.
188
- *
189
- * Props stay plain JSON (registry/serializer compatible); the renderer layer
190
- * pushes them onto the backing object once per frame (`_syncObject3D`).
191
- * Rotation is in DEGREES in JSON — friendlier for agents and humans.
192
- */
193
- var Node3D = class extends Node {
194
- static typeName = "Node3D";
195
- static props = {
196
- position: { default: [
197
- 0,
198
- 0,
199
- 0
200
- ] },
201
- rotation: { default: [
202
- 0,
203
- 0,
204
- 0
205
- ] },
206
- static: { default: false },
207
- scale: { default: [
208
- 1,
209
- 1,
210
- 1
211
- ] },
212
- visible: { default: true },
213
- renderOrder: { default: 0 },
214
- orderGroup: {
215
- default: "default",
216
- options: [
217
- "background",
218
- "terrain",
219
- "default",
220
- "characters",
221
- "effects",
222
- "overlay"
223
- ]
224
- }
225
- };
226
- position = [
227
- 0,
228
- 0,
229
- 0
230
- ];
231
- /** Euler XYZ in degrees. */
232
- rotation = [
233
- 0,
234
- 0,
235
- 0
236
- ];
237
- /**
238
- * This subtree never changes after load: the renderer syncs it ONCE and
239
- * skips it every frame after (transforms, materials, animations frozen).
240
- * The per-frame walk is O(nodes) — marking terrain/buildings/decor static
241
- * removes most of a big scene from it. Set `static = false` to resume
242
- * live syncing. Do NOT mark animated nodes (Water3D, Particles3D,
243
- * AnimatedSprite3D, ModelInstance3D with clips) or cameras static.
244
- */
245
- static = false;
246
- scale = [
247
- 1,
248
- 1,
249
- 1
250
- ];
251
- visible = true;
252
- /** Draw-order priority (higher = drawn later / on top). Only affects TRANSPARENT
253
- * materials — three sorts the transparent pass by renderOrder, then depth.
254
- * Pairs with named scene constants (e.g. `{"@const": "UI"}`) for sorting tiers. */
255
- renderOrder = 0;
256
- /** Named draw-order band; `renderOrder` is the fine offset inside it. */
257
- orderGroup = "default";
258
- /** What three actually sorts by: band base + renderOrder. */
259
- get effectiveRenderOrder() {
260
- return effectiveOrder(this.orderGroup, this.renderOrder);
261
- }
262
- _object3D = null;
263
- /**
264
- * @internal Fixed-step render interpolation. A physics body fills these with
265
- * its previous and current fixed-step LOCAL positions; `_syncObject3D(alpha)`
266
- * lerps the rendered transform between them so motion is smooth between fixed
267
- * steps. `null` means "not physics-driven" — render `position` directly.
268
- */
269
- _interpPrev = null;
270
- _interpCurr = null;
271
- /** @internal The backing three object (lazily created). */
272
- _ensureObject3D() {
273
- if (!this._object3D) {
274
- this._object3D = this._createObject3D();
275
- this._object3D.userData.incantoNode = this;
276
- }
277
- return this._object3D;
278
- }
279
- /** @internal Override point for subclasses (mesh, camera, light…). */
280
- _createObject3D() {
281
- return new Object3D();
282
- }
283
- /** @internal Push JSON props onto the backing object. Called every frame.
284
- * `alpha` (0..1) interpolates a physics body between its last two fixed-step
285
- * positions; non-physics nodes ignore it and render `position` directly. */
286
- _syncObject3D(alpha = 1) {
287
- const o = this._ensureObject3D();
288
- const prev = this._interpPrev;
289
- const curr = this._interpCurr;
290
- if (prev && curr) o.position.set((prev[0] ?? 0) + ((curr[0] ?? 0) - (prev[0] ?? 0)) * alpha, (prev[1] ?? 0) + ((curr[1] ?? 0) - (prev[1] ?? 0)) * alpha, (prev[2] ?? 0) + ((curr[2] ?? 0) - (prev[2] ?? 0)) * alpha);
291
- else o.position.set(this.position[0] ?? 0, this.position[1] ?? 0, this.position[2] ?? 0);
292
- o.rotation.set(MathUtils.degToRad(this.rotation[0] ?? 0), MathUtils.degToRad(this.rotation[1] ?? 0), MathUtils.degToRad(this.rotation[2] ?? 0));
293
- o.scale.set(this.scale[0] ?? 1, this.scale[1] ?? 1, this.scale[2] ?? 1);
294
- o.visible = this.visible;
295
- o.renderOrder = this.effectiveRenderOrder;
296
- }
297
- free() {
298
- super.free();
299
- this._object3D?.removeFromParent();
300
- this._object3D = null;
301
- this._interpPrev = null;
302
- this._interpCurr = null;
303
- }
304
- };
305
- //#endregion
306
- //#region src/3d/nodes/bodies-3d.ts
307
- /**
308
- * Shared base for physics-backed 3D nodes. Colliders are NODE PROPS:
309
- * `{shape:'box', size:[x,y,z]}` · `{shape:'sphere', radius}` ·
310
- * `{shape:'capsule', radius, height}` (meters, y-up).
311
- */
312
- var PhysicsBody3D = class extends Node3D {
313
- static props = { collider: { default: {} } };
314
- /** The unified collision model: every collider participant can emit these. */
315
- static signals = ["triggerEnter", "triggerExit"];
316
- _collider = {};
317
- /** @internal Physics resync flag — raised when `collider` is REPLACED. */
318
- _colliderChanged = false;
319
- /**
320
- * Collider shape prop. Change it by REPLACING the object
321
- * (`body.collider = { shape: 'box', size: [...] }`) — physics rebuilds the
322
- * body on replacement; in-place mutation of the old object is not watched.
323
- */
324
- get collider() {
325
- return this._collider;
326
- }
327
- set collider(value) {
328
- this._collider = value;
329
- this._colliderChanged = true;
330
- }
331
- /** Loader hook: bad collider shapes fail at LOAD, not at physics start. */
332
- static validateJson(node) {
333
- const body = node;
334
- validateCollider3D(body.collider, node.name);
335
- if (body.collider.shape === "heightfield" && !(body instanceof StaticBody3D)) throw new IncantoError("BAD_FORMAT", `Collider on '${node.name}': heightfield is static terrain — put it on a StaticBody3D (with a Terrain3D child), not a ${body.constructor.typeName}.`, { prop: "collider" });
336
- }
337
- /** @internal Set by Physics3D when the body is created. */
338
- _physics = null;
339
- };
340
- /** Immovable collider (ground, walls, platforms). */
341
- var StaticBody3D = class extends PhysicsBody3D {
342
- static typeName = "StaticBody3D";
343
- };
344
- /** Sensor volume emitting `triggerEnter(other)` / `triggerExit(other)`. */
345
- var Area3D = class extends PhysicsBody3D {
346
- static typeName = "Area3D";
347
- };
348
- /** Dynamic simulated body. */
349
- var RigidBody3D = class extends PhysicsBody3D {
350
- static typeName = "RigidBody3D";
351
- static props = {
352
- mass: { default: 1 },
353
- gravityScale: { default: 1 },
354
- fixedRotation: { default: false },
355
- friction: { default: .5 },
356
- restitution: { default: 0 },
357
- linearVelocity: { default: [
358
- 0,
359
- 0,
360
- 0
361
- ] }
362
- };
363
- mass = 1;
364
- gravityScale = 1;
365
- fixedRotation = false;
366
- friction = .5;
367
- restitution = 0;
368
- /** m/s, y-up. Read back every step; write to launch. */
369
- linearVelocity = [
370
- 0,
371
- 0,
372
- 0
373
- ];
374
- /** @internal set by Physics3D */
375
- _physics3d = null;
376
- /** World-space impulse (kg·m/s) — the floating-capsule controller's verb. */
377
- applyImpulse(impulse) {
378
- this._physics3d?.applyImpulse(this, impulse);
379
- }
380
- };
381
- /**
382
- * Kinematic capsule on Rapier's character controller. Gravity is NOT applied
383
- * automatically — integrate `velocity` yourself, then `moveAndSlide()` from
384
- * `fixedUpdate`.
385
- */
386
- var CharacterBody3D = class extends PhysicsBody3D {
387
- static typeName = "CharacterBody3D";
388
- static props = {
389
- velocity: { default: [
390
- 0,
391
- 0,
392
- 0
393
- ] },
394
- snapToGround: { default: true },
395
- slopeLimitDeg: { default: 45 }
396
- };
397
- /** m/s, y-up (up = +y). */
398
- velocity = [
399
- 0,
400
- 0,
401
- 0
402
- ];
403
- snapToGround = true;
404
- /** Max climbable slope angle. */
405
- slopeLimitDeg = 45;
406
- /** @internal Updated by Physics3D.moveAndSlide. */
407
- _grounded = false;
408
- moveAndSlide() {
409
- if (!this._physics) throw new IncantoError("TREE_VIOLATION", this.collider.shape === void 0 ? `CharacterBody3D '${this.name}' has no collider — set a collider prop (physics skips collider-less bodies).` : `CharacterBody3D '${this.name}' has no physics world — await enablePhysics3D(engine) first.`);
410
- this._physics.moveAndSlide(this);
411
- }
412
- isOnFloor() {
413
- return this._grounded;
414
- }
415
- };
416
- //#endregion
417
154
  //#region src/3d/nodes/joint-3d.ts
418
155
  /**
419
156
  * A physics joint linking its PARENT body to `target` (a node path to the
@@ -6684,121 +6421,6 @@ function softSprite() {
6684
6421
  return tex;
6685
6422
  }
6686
6423
  //#endregion
6687
- //#region src/3d/water/interaction.ts
6688
- /**
6689
- * Water3D character interaction — pure functions, no three, node-env testable.
6690
- *
6691
- * The node samples physics bodies each update, detects waterline crossings
6692
- * (`detectCrossings`), and converts them into ripple impulses in a FIXED-SIZE
6693
- * ring buffer that mirrors the shader's `uRipples[8]` uniform array
6694
- * (`pushRipple` / `ageRipples`). `rippleHeight` is the exact ring-wave shape
6695
- * the vertex shader evaluates — `quality: 'simple'` runs it on the CPU.
6696
- */
6697
- /** Ring-buffer capacity — MUST match `uRipples[8]` in the water shaders. */
6698
- const WATER_MAX_RIPPLES = 8;
6699
- /**
6700
- * Distance from a body's ORIGIN down to its collider's lowest point. Water
6701
- * interaction adds this to the waterline so a body counts as touching the water
6702
- * when its FEET dip below the surface — not only when its centre does (a wading
6703
- * character whose origin rides ~1 m above its feet would otherwise never splash).
6704
- * 0 for a missing/empty/unsized collider (origin == feet).
6705
- */
6706
- function colliderFootDrop(collider) {
6707
- if (!collider || typeof collider !== "object") return 0;
6708
- switch (collider.shape) {
6709
- case "capsule": return (collider.height ?? 0) / 2 + (collider.radius ?? 0);
6710
- case "sphere": return collider.radius ?? 0;
6711
- case "box": return (collider.size?.[1] ?? 0) / 2;
6712
- default: return 0;
6713
- }
6714
- }
6715
- /**
6716
- * Foam strength (0..1) for a body ENTERING the water, from its DOWNWARD speed:
6717
- * a gentle wade-in froths a little; a hard plunge froths fully. Clamped at
6718
- * `FOAM_FULL_SPEED`.
6719
- */
6720
- function entryFoam(fallSpeed) {
6721
- return .55 + .45 * (Math.min(Math.max(fallSpeed, 0), 12) / 12);
6722
- }
6723
- /**
6724
- * Diff body positions against the waterline. A body enters when `y` drops
6725
- * below its waterline and exits when it rises above `waterline + hysteresis`
6726
- * (the band stops surface-bobbing bodies from flickering entered/exited).
6727
- * Bodies missing from `samples` (despawned mid-water) are dropped WITHOUT an
6728
- * `exited` event — their splash position no longer exists.
6729
- */
6730
- function detectCrossings(prev, samples, hysteresis = 0) {
6731
- const entered = [];
6732
- const exited = [];
6733
- const inWater = /* @__PURE__ */ new Set();
6734
- for (const s of samples) {
6735
- const wasIn = prev.has(s.id);
6736
- if (wasIn ? s.y <= s.waterline + hysteresis : s.y < s.waterline) {
6737
- inWater.add(s.id);
6738
- if (!wasIn) entered.push(s);
6739
- } else if (wasIn) exited.push(s);
6740
- }
6741
- return {
6742
- entered,
6743
- exited,
6744
- inWater
6745
- };
6746
- }
6747
- /** Append a ripple at age 0; when the buffer is full, evict the OLDEST.
6748
- * `foam` (0..1) tags how much surface froth the shaders paint around it. */
6749
- function pushRipple(ripples, x, z, amp, foam = 0, max = 8) {
6750
- if (ripples.length >= max) {
6751
- let oldest = 0;
6752
- for (let i = 1; i < ripples.length; i++) if (ripples[i].age > ripples[oldest].age) oldest = i;
6753
- ripples.splice(oldest, 1);
6754
- }
6755
- ripples.push({
6756
- x,
6757
- z,
6758
- age: 0,
6759
- amp,
6760
- foam
6761
- });
6762
- }
6763
- /** Advance ages by `dt` and drop ripples decayed past `maxAge` (in place). */
6764
- function ageRipples(ripples, dt, maxAge = 3) {
6765
- for (let i = ripples.length - 1; i >= 0; i--) {
6766
- const r = ripples[i];
6767
- r.age += dt;
6768
- if (r.age >= maxAge) ripples.splice(i, 1);
6769
- }
6770
- }
6771
- /** The ring-wave height contribution of one ripple at world (x, z). */
6772
- function rippleHeight(x, z, ripple) {
6773
- const dx = x - ripple.x;
6774
- const dz = z - ripple.z;
6775
- const r = Math.sqrt(dx * dx + dz * dz);
6776
- return ripple.amp * Math.sin(6 * r - 8 * ripple.age) * Math.exp(-1.1 * ripple.age) * Math.exp(-.15 * r * r);
6777
- }
6778
- /** Sum of every live ripple's height at world (x, z) — the CPU water path. */
6779
- function ripplesHeightAt(x, z, ripples) {
6780
- let h = 0;
6781
- for (const r of ripples) h += rippleHeight(x, z, r);
6782
- return h;
6783
- }
6784
- /**
6785
- * Accumulate per-body bob timers for bodies floating IN water; returns the
6786
- * ids due for a gentle bob impulse this update (timer wraps, cadence keeps).
6787
- * Timers of bodies that left the water are forgotten.
6788
- */
6789
- function stepBobTimers(timers, inWater, dt, interval) {
6790
- const due = [];
6791
- for (const id of timers.keys()) if (!inWater.has(id)) timers.delete(id);
6792
- for (const id of inWater) {
6793
- const t = (timers.get(id) ?? 0) + dt;
6794
- if (t >= interval) {
6795
- due.push(id);
6796
- timers.set(id, t - interval);
6797
- } else timers.set(id, t);
6798
- }
6799
- return due;
6800
- }
6801
- //#endregion
6802
6424
  //#region src/3d/water/river.ts
6803
6425
  /** Dense Catmull-Rom taps per control segment before arc-length resampling. */
6804
6426
  const SPLINE_TAPS = 24;
@@ -7927,7 +7549,7 @@ var River3D = class River3D extends Node3D {
7927
7549
  while (top.parent) top = top.parent;
7928
7550
  const bodies = this._bodies;
7929
7551
  bodies.length = 0;
7930
- collectBodies$1(top, bodies);
7552
+ collectBodies(top, bodies);
7931
7553
  if (bodies.length === 0) return;
7932
7554
  const [wx, wy, wz] = this.world;
7933
7555
  for (const body of bodies) {
@@ -7938,7 +7560,7 @@ var River3D = class River3D extends Node3D {
7938
7560
  const feet = p[1] - colliderFootDrop(body.collider);
7939
7561
  if (feet > surfaceY + CURRENT_SURFACE_SLACK) continue;
7940
7562
  if (feet < surfaceY - this.depth - CURRENT_UNDER_SLACK) continue;
7941
- const vel = bodyVelocity$1(body);
7563
+ const vel = bodyVelocity(body);
7942
7564
  if (!vel) continue;
7943
7565
  const gain = Math.min(1, this.flowForce * CURRENT_DRAG * dt);
7944
7566
  const dvx = (hit.dirX * hit.speed - (vel[0] ?? 0)) * gain;
@@ -7958,12 +7580,12 @@ var River3D = class River3D extends Node3D {
7958
7580
  }
7959
7581
  };
7960
7582
  /** Bodies a current can carry: kinematic characters and dynamic rigid bodies. */
7961
- function collectBodies$1(node, out) {
7583
+ function collectBodies(node, out) {
7962
7584
  if (node instanceof CharacterBody3D || node instanceof RigidBody3D) out.push(node);
7963
- for (const child of node.children) collectBodies$1(child, out);
7585
+ for (const child of node.children) collectBodies(child, out);
7964
7586
  }
7965
7587
  /** A body's velocity vector — kinematic `velocity` or dynamic `linearVelocity`. */
7966
- function bodyVelocity$1(body) {
7588
+ function bodyVelocity(body) {
7967
7589
  return body.velocity ?? body.linearVelocity;
7968
7590
  }
7969
7591
  //#endregion
@@ -10197,9 +9819,9 @@ function normalizeTint(color) {
10197
9819
  const max = Math.max(tint.r, tint.g, tint.b);
10198
9820
  if (max > 0) tint.multiplyScalar(1 / max);
10199
9821
  else tint.setRGB(1, 1, 1);
10200
- return tint.lerp(WHITE$1, .18);
9822
+ return tint.lerp(WHITE, .18);
10201
9823
  }
10202
- const WHITE$1 = new Color(1, 1, 1);
9824
+ const WHITE = new Color(1, 1, 1);
10203
9825
  /** Bark tint for a customized trunkColor: keep the hue, normalize the
10204
9826
  * brightest channel to 1 — the color map keeps its own brightness. */
10205
9827
  function barkTint(color) {
@@ -11223,2814 +10845,6 @@ void main() {
11223
10845
  }
11224
10846
  `;
11225
10847
  //#endregion
11226
- //#region src/3d/frustum.ts
11227
- const scratchFrustum = new Frustum();
11228
- const scratchMatrix = new Matrix4();
11229
- const scratchSphere = new Sphere();
11230
- /**
11231
- * Is a world-space sphere inside the camera's view frustum?
11232
- *
11233
- * Used to skip per-frame work for things the camera can't see — e.g. Water3D's
11234
- * fancy scene pre-pass + reflection cube, which otherwise re-render the whole
11235
- * scene every frame for ponds that are off-screen. Uses shared scratch objects
11236
- * (single-threaded engine), so it allocates nothing per call.
11237
- */
11238
- function sphereInCameraFrustum(camera, center, radius) {
11239
- scratchMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
11240
- scratchFrustum.setFromProjectionMatrix(scratchMatrix);
11241
- scratchSphere.center.set(center.x, center.y, center.z);
11242
- scratchSphere.radius = radius;
11243
- return scratchFrustum.intersectsSphere(scratchSphere);
11244
- }
11245
- //#endregion
11246
- //#region src/3d/water/lake.ts
11247
- /**
11248
- * The Water3D 'simple' quality lake shader — the CHEAP good-looking path.
11249
- *
11250
- * 'fancy' water is commercial-grade but pays for it: a CubeCamera reflection
11251
- * plus a half-res scene depth/color pre-pass (and a depth-completion pass) —
11252
- * 2–3 extra full-scene renders every frame, which drop a pond to ~30 fps. The
11253
- * old 'simple' fallback was a plain lit MeshStandardMaterial over CPU-displaced
11254
- * sine waves: cheap, but flat and lifeless (the "허접" playtest complaint).
11255
- *
11256
- * This lake shader keeps the cheap budget — ZERO scene re-renders, no cube map,
11257
- * no depth texture — and still reads as real water by faking the three cues the
11258
- * eye actually needs:
11259
- *
11260
- * - Fresnel sky reflection: a procedural horizon→zenith SKY TINT (uSkyColor),
11261
- * Schlick-mixed in by view angle. Steep view = water color, grazing = sky.
11262
- * No environment to sample — a lake reflects mostly sky anyway.
11263
- * - Procedural ripple normals: two crossing animated simplex layers perturb the
11264
- * (CPU-computed) macro normal per pixel — the LIQUID micro-detail, texture-
11265
- * free, distance-faded (procedural noise has no mips → aliases at range).
11266
- * - Sun glint: a tight Blinn-Phong spark + soft sheen off the rippled normal,
11267
- * weighted toward the sun's azimuth path (uSunDirection/Color/Intensity).
11268
- * - Depth-free body: looking straight down reads the lighter surface tint
11269
- * (clearer shallows feel), grazing reads the darker deep base; grazing also
11270
- * reads more opaque/reflective. No depth buffer, all from the view angle.
11271
- * - Soft edge fade: the rectangular plane's rim fades to zero alpha, so the
11272
- * surface melts into the shore instead of poking out as a hard rectangle
11273
- * (the "물 삐져나옴" complaint).
11274
- *
11275
- * The CPU still displaces the coarse vertex grid (macro swell + interaction
11276
- * ring waves) and computes its normals — that path stays deterministic and
11277
- * headless-testable; this shader only adds the per-pixel look on top.
11278
- *
11279
- * Atmosphere stage (marked below): the fragment ends with three's
11280
- * tonemapping + colorspace + fog chunks in built-in-material order, and both
11281
- * stages carry the standard fog chunks, so the lake tone-maps, sRGB-encodes
11282
- * and fogs identically to the fancy water and the terrain.
11283
- */
11284
- const LAKE_VERT = `
11285
- precision highp float;
11286
-
11287
- // atmosphere: three's linear fog rides the standard chunks (compiled out
11288
- // unless the scene declares fog — USE_FOG comes from material.fog)
11289
- #include <fog_pars_vertex>
11290
-
11291
- varying vec3 vWorldPosition;
11292
- varying vec3 vNormalW;
11293
-
11294
- void main() {
11295
- vec4 modelPosition = modelMatrix * vec4(position, 1.0);
11296
- vWorldPosition = modelPosition.xyz;
11297
- // The CPU computeVertexNormals() macro normal, lifted to world space. Water
11298
- // is laid axis-aligned on XZ (no parent rotation expected), so the plain
11299
- // model rotation is exact enough for the fresnel/glint cues.
11300
- vNormalW = normalize(mat3(modelMatrix) * normal);
11301
-
11302
- vec4 mvPosition = viewMatrix * modelPosition;
11303
- gl_Position = projectionMatrix * mvPosition;
11304
-
11305
- // atmosphere: fog depth from the displaced surface position
11306
- #include <fog_vertex>
11307
- }
11308
- `;
11309
- const LAKE_FRAG = `
11310
- precision highp float;
11311
-
11312
- // atmosphere: standard fog chunk (compiled out without scene fog)
11313
- #include <fog_pars_fragment>
11314
-
11315
- uniform float uTime;
11316
- uniform float uOpacity;
11317
- uniform vec3 uColor; // deep water base (derived from the single hue)
11318
- uniform vec3 uSurfaceColor; // shallow/surface tint (the hue itself)
11319
- uniform vec3 uSkyColor; // fresnel reflection tint — NO cube map
11320
- uniform vec3 uSunDirection;
11321
- uniform vec3 uSunColor;
11322
- uniform float uSunIntensity;
11323
- uniform float uDetailStrength;
11324
- uniform float uFresnelPower;
11325
- uniform vec3 uScale; // half extents (x, _, z) for the edge fade
11326
- uniform vec3 uWaterCenter;
11327
-
11328
- // incanto: splash whitewater — the ripple buffer drives expanding surface foam
11329
- // (entry "풍덩" + wake "물살"); uSplashFoam = the Water3D.splash gate.
11330
- uniform vec4 uRipples[8];
11331
- uniform float uRippleFoam[8];
11332
- uniform float uSplashFoam;
11333
- uniform vec3 uFoamColor;
11334
-
11335
- varying vec3 vWorldPosition;
11336
- varying vec3 vNormalW;
11337
-
11338
- // Simplex 2D noise (Ian McEwan, Stefan Gustavson — https://github.com/stegu/webgl-noise, MIT)
11339
- vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
11340
-
11341
- float snoise(vec2 v) {
11342
- const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
11343
- vec2 i = floor(v + dot(v, C.yy));
11344
- vec2 x0 = v - i + dot(i, C.xx);
11345
- vec2 i1;
11346
- i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
11347
- vec4 x12 = x0.xyxy + C.xxzz;
11348
- x12.xy -= i1;
11349
- i = mod(i, 289.0);
11350
- vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
11351
- vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
11352
- m = m * m;
11353
- m = m * m;
11354
- vec3 x = 2.0 * fract(p * C.www) - 1.0;
11355
- vec3 h = abs(x) - 0.5;
11356
- vec3 ox = floor(x + 0.5);
11357
- vec3 a0 = x - ox;
11358
- m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
11359
- vec3 g;
11360
- g.x = a0.x * x0.x + h.x * x0.y;
11361
- g.yz = a0.yz * x12.xz + h.yz * x12.yw;
11362
- return 130.0 * dot(m, g);
11363
- }
11364
-
11365
- // finite-difference gradient of the simplex height field
11366
- vec2 noiseGrad(vec2 p) {
11367
- const float e = 0.06;
11368
- float h = snoise(p);
11369
- return vec2(snoise(p + vec2(e, 0.0)) - h, snoise(p + vec2(0.0, e)) - h) / e;
11370
- }
11371
-
11372
- // incanto: splash WHITEWATER. A clean radial disc/ring read as a fake CG stamp;
11373
- // real foam is irregular clumps that drift with the water and break apart at the
11374
- // edges. A soft COVERAGE envelope (an expanding patch fading over FOAM_MAX_AGE —
11375
- // the CPU twin rippleFoamAt, constants MIRRORED) THRESHOLDS a domain-warped
11376
- // fractal noise: dense coverage → broad froth, thinning coverage raises the
11377
- // threshold → it erodes into sparse bubbles. Warp + drift = advected, not stamped.
11378
- float lnoise(vec2 p) { return 0.5 + 0.5 * snoise(p); }
11379
- float foamFbm(vec2 p) {
11380
- float v = 0.0, a = 0.5;
11381
- for (int i = 0; i < 4; i++) { v += a * lnoise(p); p = p * 2.02 + 7.3; a *= 0.5; }
11382
- return v; // ~0..0.94
11383
- }
11384
-
11385
- float rippleFoam(vec2 p) {
11386
- if (uSplashFoam < 0.5) return 0.0;
11387
- float cover = 0.0;
11388
- for (int i = 0; i < 8; i++) {
11389
- float foam = uRippleFoam[i];
11390
- if (foam <= 0.0) continue;
11391
- vec4 r = uRipples[i];
11392
- float age = r.z;
11393
- float life = 1.0 - age / 1.6; // FOAM_MAX_AGE
11394
- if (life <= 0.0) continue;
11395
- float d = distance(p, r.xy);
11396
- float radius = 0.4 + 1.15 * age; // FOAM_R0 + FOAM_SPREAD * age
11397
- cover = max(cover, (1.0 - smoothstep(radius * 0.7, radius, d)) * foam * life);
11398
- }
11399
- if (cover <= 0.001) return 0.0;
11400
- // FINE frequencies (premium foam = many small bubbles, not coarse blobs)
11401
- vec2 flow = vec2(uTime * 0.05, uTime * -0.04);
11402
- vec2 warp = vec2(foamFbm(p * 1.1 + flow), foamFbm(p * 1.1 + 4.2 - flow)) - 0.5;
11403
- float pattern = foamFbm(p * 2.6 + warp * 1.4 + flow);
11404
- // raised floor + wide band = sparser + softer = subtler/more refined
11405
- float thresh = mix(0.36, 0.96, 1.0 - cover);
11406
- float foamHere = smoothstep(thresh, thresh + 0.26, pattern);
11407
- return clamp(foamHere * (0.4 + 0.5 * cover), 0.0, 1.0);
11408
- }
11409
-
11410
- // Two crossing animated ripple layers perturb the macro normal. Each fades out
11411
- // with distance (procedural noise has no mips — past its range it aliases into
11412
- // white speckle), so far water relaxes back to the smooth swell.
11413
- vec3 detailNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
11414
- float fadeMid = 1.0 - smoothstep(45.0, 170.0, camDist);
11415
- float fadeFine = 1.0 - smoothstep(14.0, 70.0, camDist);
11416
- // Three FINE isotropic ripple fields drifting in crossing directions — a
11417
- // calm lake's surface is many small wavelets, not a few big swells, so the
11418
- // frequencies are high (≈1.4 m / ≈0.8 m / ≈0.5 m) and the amplitudes small.
11419
- // Each gradient is multiplied by its own frequency: noiseGrad returns the
11420
- // slope in NOISE space, so without this the coarsest layer over-amplifies
11421
- // into the foil-blob look. Net perturbation stays a gentle few degrees.
11422
- vec2 dMid = normalize(vec2(0.72, 0.50));
11423
- vec2 dFine = normalize(vec2(-0.42, 0.91));
11424
- vec2 dMicro = normalize(vec2(0.34, -0.94));
11425
- vec2 slope =
11426
- noiseGrad(worldXZ * 0.70 + dMid * uTime * 0.14) * (0.70 * 0.085 * fadeMid)
11427
- + noiseGrad(worldXZ * 1.30 + dFine * uTime * 0.20) * (1.30 * 0.060 * fadeFine)
11428
- + noiseGrad(worldXZ * 2.10 - dMicro * uTime * 0.26) * (2.10 * 0.040 * fadeFine);
11429
- slope *= strength;
11430
- return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
11431
- }
11432
-
11433
- void main() {
11434
- vec3 viewDir = normalize(vWorldPosition - cameraPosition);
11435
- float camDist = length(vWorldPosition - cameraPosition);
11436
-
11437
- // Flatten the macro normal with distance so far ripples don't strobe into
11438
- // sky-reflection sequins against the bright horizon.
11439
- float geoFlat = 1.0 - 0.85 * smoothstep(140.0, 520.0, camDist);
11440
- vec3 geoN = normalize(mix(vec3(0.0, 1.0, 0.0), normalize(vNormalW), geoFlat));
11441
- vec3 normal = detailNormal(geoN, vWorldPosition.xz, uDetailStrength, camDist);
11442
-
11443
- // Schlick fresnel: 2% normal-incidence floor (water's real F0), 98% at
11444
- // grazing. Steep view → water color, grazing → sky tint.
11445
- float fresnel = 0.02 + 0.98 * pow(1.0 - clamp(dot(-viewDir, normal), 0.0, 1.0), uFresnelPower);
11446
-
11447
- // Depth-free body color from the view angle: straight down sees the lighter
11448
- // surface (clearer shallows feel), grazing sees the deeper base.
11449
- float viewUp = clamp(dot(-viewDir, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
11450
- vec3 body = mix(uColor, uSurfaceColor, viewUp);
11451
-
11452
- // Sky reflection WITHOUT a cube map: a soft horizon→zenith gradient sampled
11453
- // by the reflected ray. The horizon is paler/brighter (atmospheric haze),
11454
- // the zenith deeper — convincing for a lake, which reflects mostly sky.
11455
- vec3 refl = reflect(viewDir, normal);
11456
- float up = clamp(refl.y, 0.0, 1.0);
11457
- vec3 skyTint = mix(uSkyColor * 1.08, uSkyColor * 0.82, up);
11458
-
11459
- // cap the sky mix below a full mirror (that reads as white foil — the old
11460
- // goop look) but high enough that the grazing far field catches believable
11461
- // sky instead of going flat grey
11462
- vec3 color = mix(body, skyTint, clamp(fresnel, 0.0, 0.62));
11463
-
11464
- // Sun glint: a tight spark + soft sheen off the rippled normal, weighted to
11465
- // the sun's azimuth path so crests sparkle ALONG the light and the rest of
11466
- // the surface stays calm color (not an all-over glossy speckle).
11467
- vec3 sunL = normalize(uSunDirection);
11468
- vec3 halfway = normalize(sunL - viewDir); // -viewDir = surface→camera
11469
- float specAngle = max(dot(normal, halfway), 0.0);
11470
- vec2 toFrag = normalize(vWorldPosition.xz - cameraPosition.xz);
11471
- float sunPath = pow(clamp(dot(toFrag, normalize(sunL.xz)), 0.0, 1.0), 4.0);
11472
- float glintFade = 1.0 - smoothstep(70.0, 240.0, camDist);
11473
- float glint = pow(specAngle, 380.0) * (0.25 + 0.9 * sunPath) * glintFade;
11474
- float sheen = pow(specAngle, 36.0) * 0.05 * sunPath;
11475
- color += uSunColor * min(uSunIntensity * (glint * 0.7 + sheen), 0.8);
11476
-
11477
- // splash froth where bodies enter / wade — a PALE foam tint (not pure white,
11478
- // which floats on top as a jarring decal); the water still reads through
11479
- float splashFoam = rippleFoam(vWorldPosition.xz);
11480
- color = mix(color, mix(uFoamColor, vec3(1.0), 0.3), splashFoam * 0.58);
11481
-
11482
- // Soft edge fade — the rectangular rim melts into the shore. Normalized
11483
- // distance from the node center to its half extents; the last ~18% fades to
11484
- // zero alpha (no hard "물 삐져나옴" rectangle).
11485
- vec2 nrm = (vWorldPosition.xz - uWaterCenter.xz) / vec2(uScale.x, uScale.z);
11486
- float edge = 1.0 - smoothstep(0.82, 1.0, length(nrm));
11487
-
11488
- // Grazing angles look more opaque/reflective; straight-down looks clearer.
11489
- float alpha = mix(uOpacity * 0.7, min(uOpacity + 0.25, 1.0), fresnel);
11490
- alpha *= edge;
11491
- // foam reads even looking straight down — lift froth opacity, but only part
11492
- // way so the disturbed water still shows through (translucent froth)
11493
- alpha = max(alpha, splashFoam * 0.7 * edge);
11494
-
11495
- gl_FragColor = vec4(color, alpha);
11496
-
11497
- // atmosphere: join three's output pipeline — ACES tone map + working→output
11498
- // colorspace encode + linear fog, in the SAME order as the built-in
11499
- // materials so the lake fogs/grades identically to the terrain.
11500
- #include <tonemapping_fragment>
11501
- #include <colorspace_fragment>
11502
- #include <fog_fragment>
11503
- }
11504
- `;
11505
- //#endregion
11506
- //#region src/3d/water/caustics.ts
11507
- /**
11508
- * Underwater caustics — the dancing refracted-light pattern the water surface
11509
- * throws onto everything below it. The engine has no post-processing pipeline,
11510
- * so the renderer does this as ONE depth-aware composite pass, and ONLY while
11511
- * the camera is genuinely underwater (so normal above-water rendering — even
11512
- * standing at the water's edge — is completely untouched):
11513
- *
11514
- * 1. render the scene to an offscreen target (color + depth)
11515
- * 2. draw this fullscreen material, which reconstructs each pixel's WORLD
11516
- * position from the depth buffer and adds an animated caustic highlight
11517
- * wherever that world point sits below the water surface
11518
- *
11519
- * Because the pattern is keyed to reconstructed world XZ, it STICKS to the
11520
- * floor/walls/props (it isn't a flat screen overlay) and fades with depth +
11521
- * view distance so it melts into the underwater fog.
11522
- */
11523
- /** Fullscreen triangle/quad — the vertex stage ignores the camera entirely. */
11524
- const CAUSTICS_VERT = `
11525
- varying vec2 vUv;
11526
- void main() {
11527
- vUv = uv;
11528
- gl_Position = vec4(position.xy, 0.0, 1.0);
11529
- }
11530
- `;
11531
- /**
11532
- * The classic animated caustic pattern (layered moving cells), 0..1 — SHARED
11533
- * between the underwater composite and Water3D's above-water caustics (the
11534
- * refracted bottom shimmer), so both views of the same water agree.
11535
- */
11536
- const CAUSTIC_GLSL = `
11537
- float caustic(vec2 uv, float t) {
11538
- vec2 p = mod(uv * 6.28318, 6.28318) - 250.0;
11539
- vec2 i = p;
11540
- float c = 1.0;
11541
- float inten = 0.005;
11542
- for (int n = 0; n < 5; n++) {
11543
- float ti = t * (1.0 - (3.5 / float(n + 1)));
11544
- i = p + vec2(cos(ti - i.x) + sin(ti + i.y), sin(ti - i.y) + cos(ti + i.x));
11545
- c += 1.0 / length(vec2(p.x / (sin(i.x + ti) / inten), p.y / (cos(i.y + ti) / inten)));
11546
- }
11547
- c /= 5.0;
11548
- c = 1.17 - pow(c, 1.4);
11549
- return clamp(pow(abs(c), 8.0), 0.0, 1.0);
11550
- }
11551
- `;
11552
- const CAUSTICS_FRAG = `
11553
- precision highp float;
11554
- varying vec2 vUv;
11555
- uniform sampler2D tColor;
11556
- uniform sampler2D tDepth;
11557
- uniform mat4 uInvViewProj;
11558
- uniform vec3 uCameraPos;
11559
- uniform float uWaterLevel;
11560
- uniform float uTime;
11561
- uniform vec3 uCausticColor;
11562
- uniform float uCausticIntensity;
11563
- uniform float uCausticScale;
11564
- uniform float uMaxDist;
11565
-
11566
- ${CAUSTIC_GLSL}
11567
-
11568
- void main() {
11569
- vec4 color = texture2D(tColor, vUv);
11570
- float depth = texture2D(tDepth, vUv).x;
11571
- // depth == 1 is the far plane (sky/background) — nothing to light there
11572
- if (depth < 1.0) {
11573
- vec4 ndc = vec4(vUv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
11574
- vec4 world = uInvViewProj * ndc;
11575
- world.xyz /= world.w;
11576
- float below = uWaterLevel - world.y;
11577
- if (below > 0.0) {
11578
- float dist = distance(world.xyz, uCameraPos);
11579
- // caustics — fade with distance (melt into the fog) + a touch with depth
11580
- float distFade = 1.0 - clamp(dist / uMaxDist, 0.0, 1.0);
11581
- float depthFade = 1.0 - clamp(below / (uMaxDist * 0.5), 0.0, 0.6);
11582
- // two octaves at offset speeds = the shimmering interference of real caustics
11583
- float c =
11584
- caustic(world.xz * uCausticScale, uTime) * 0.65 +
11585
- caustic(world.xz * uCausticScale * 1.7 + 30.0, uTime * 0.8) * 0.35;
11586
- color.rgb += uCausticColor * (c * uCausticIntensity * distFade * distFade * depthFade);
11587
- }
11588
- }
11589
- gl_FragColor = color;
11590
- }
11591
- `;
11592
- /** Build the fullscreen composite mesh (a 2×2 clip-space quad). */
11593
- function createCausticsQuad() {
11594
- const material = new ShaderMaterial({
11595
- vertexShader: CAUSTICS_VERT,
11596
- fragmentShader: CAUSTICS_FRAG,
11597
- depthTest: false,
11598
- depthWrite: false,
11599
- uniforms: {
11600
- tColor: { value: null },
11601
- tDepth: { value: null },
11602
- uInvViewProj: { value: new Matrix4() },
11603
- uCameraPos: { value: new Vector3() },
11604
- uWaterLevel: { value: 0 },
11605
- uTime: { value: 0 },
11606
- uCausticColor: { value: new Color("#cdeeff") },
11607
- uCausticIntensity: { value: .55 },
11608
- uCausticScale: { value: .32 },
11609
- uMaxDist: { value: 22 }
11610
- }
11611
- });
11612
- const mesh = new Mesh(new PlaneGeometry(2, 2), material);
11613
- mesh.frustumCulled = false;
11614
- return {
11615
- mesh,
11616
- uniforms: material.uniforms
11617
- };
11618
- }
11619
- //#endregion
11620
- //#region src/3d/water/shaders.ts
11621
- /**
11622
- * The Water3D 'fancy' shaders — ported VERBATIM from
11623
- * vibe-starter-3d-environment `src/components/water/shader/water.{vert,frag}.glsl`
11624
- * (9-iteration simplex-2D FBM displacement with analytic finite-difference
11625
- * normals and an 85→98% edge-fade ring; trough/surface/peak elevation ramp,
11626
- * fresnel-mixed cube-map reflection, optional depth-texture shoreline foam
11627
- * with hard above-water discard). Incanto deviations, each marked inline:
11628
- *
11629
- * - `uWaterCenter` (NEW): the source normalized world positions by the raw
11630
- * scale, which only works for water sitting at the world origin — we
11631
- * subtract the node's world center so positioned/parented water keeps its
11632
- * edge fade and color ramp. `uScale` carries HALF extents (x=w/2, y=d/2,
11633
- * z=d/2) so the shader text stays unchanged.
11634
- * - `uRipples[8]` + `getRippleHeight` (NEW): character-interaction ring waves
11635
- * (see ./interaction.ts — constants mirrored there). Displacement AND
11636
- * normals include them via `getHeight`.
11637
- * - `uUseEnvironmentMap` gate: the source declared it but never read it; we
11638
- * multiply it into the fresnel mix so water without a live reflection
11639
- * (headless, `reflection: false`, first frame) shades flat instead of black.
11640
- * - Fresnel FIX: the source's `dot(viewDirection, normal)` (camera→surface vs
11641
- * up normal) is negative at every above-water angle, pinning fresnel at max
11642
- * — the surface was a constant 3/4 mirror, so the cube map smeared across
11643
- * everything. We dot the negated view direction (true Schlick shape) and
11644
- * clamp reflectivity to 0.75 so grazing angles keep some water colour.
11645
- * - Reflection sampling FIXES: no `.x` flip (only LOADED cube textures need
11646
- * it; CubeCamera targets sample with three's flipEnvMap=+1 convention), and
11647
- * rays are folded above the horizon (`max(abs(y), 0.1)`) because the lower
11648
- * hemisphere of a water-centered cube map is parallax garbage.
11649
- * - World-space `vNormal` FIX: the source ran the world-space analytic normal
11650
- * through `normalMatrix` (a VIEW-space transform) and then dotted it against
11651
- * world-space view/sun vectors — approximately right only for a level
11652
- * camera. We pass the world normal straight through; fresnel and the new
11653
- * sun glint stay put when the camera rolls.
11654
- *
11655
- * Water v2 additions (each marked `incanto v2` inline):
11656
- * - Sun specular: tight Blinn-Phong glint + broad soft sheen off the
11657
- * detail-perturbed normal vs `uSunDirection` — the sparkling light path.
11658
- * - Animated detail normals: two procedural high-frequency simplex gradient
11659
- * layers (different scales/directions/speeds, finite-differenced — no
11660
- * texture asset) perturb the analytic vertex normal, `uDetailStrength`.
11661
- * - Beer's-law absorption: `exp(-uAbsorption · waterViewDepth)` blends the
11662
- * refracted scene into the ramp color — clear turquoise shallows, dark
11663
- * depths. Depth comes from the always-on (when rendered) scene pre-pass,
11664
- * gated by `uUseSceneDepth` so headless/first-frame shading stays flat.
11665
- * - Screen-space refraction: the same pre-pass' color target (`uSceneColor`)
11666
- * sampled with a detail-normal offset — submerged geometry shimmers.
11667
- *
11668
- * Atmosphere additions (marked `incanto atmosphere`): the fragment ends with
11669
- * three's `tonemapping_fragment` + `colorspace_fragment` + `fog_fragment`
11670
- * chunks (the SAME output stage the built-in materials run), and both stages
11671
- * carry the standard fog pars/vertex chunks — so ACES tone mapping,
11672
- * sRGB output and `environment.fog` hit water exactly like the terrain.
11673
- */
11674
- const WATER_VERT = `
11675
- precision highp float;
11676
-
11677
- // incanto atmosphere: three's linear fog rides the standard chunks (compiled
11678
- // out unless the scene declares fog — USE_FOG comes from material.fog)
11679
- #include <fog_pars_vertex>
11680
-
11681
- const float WAVE_WORLD_REF = 128.0;
11682
-
11683
- uniform float uTime;
11684
-
11685
- uniform float uWavesAmplitude;
11686
- uniform float uWavesSpeed;
11687
- uniform float uWavesFrequency;
11688
- uniform float uWavesPersistence;
11689
- uniform float uWavesLacunarity;
11690
- uniform float uWavesIterations;
11691
- uniform vec3 uScale;
11692
- // incanto: world-anchor + interaction ripples (x, z, age, amplitude)
11693
- uniform vec3 uWaterCenter;
11694
- uniform vec4 uRipples[8];
11695
- // incanto swell: directional traveling wave trains (0 = off — legacy look).
11696
- // uSwellDir is a unit XZ vector; amplitude in meters; wavelength in meters.
11697
- uniform float uSwellAmplitude;
11698
- uniform vec2 uSwellDir;
11699
- uniform float uSwellWavelength;
11700
- // incanto GERSTNER: 0 = the legacy height-only swell, 1 = maximum trochoid
11701
- // pinch (crests narrow to a peak). Physical steepness, capped per train.
11702
- uniform float uSwellSteepness;
11703
- // incanto shore: the SWASH SURGE shares the shore-waves dial — a very long
11704
- // slow bulge marching shoreward; ±9 cm of water height walks the waterline
11705
- // meters up and down a gentle beach (the lapping runup).
11706
- uniform float uShoreWaves;
11707
- // incanto presets: the edge-fade ring exists to melt BIG water into terrain
11708
- // shores — inside an exact-walled basin (pool/pond) it carves a visible
11709
- // elliptical cliff instead, so those presets turn it off.
11710
- uniform float uEdgeFadeOn;
11711
-
11712
- varying vec3 vNormal;
11713
- varying vec3 vWorldPosition;
11714
- varying vec4 vScreenPosition;
11715
- // incanto GERSTNER: how far the surface has folded here (whitecap seed)
11716
- varying float vFold;
11717
-
11718
- // Simplex 3D Noise
11719
- // by Ian McEwan, Stefan Gustavson (https://github.com/stegu/webgl-noise)
11720
- //
11721
- vec4 permute(vec4 x) {
11722
- return mod(((x * 34.0) + 1.0) * x, 289.0);
11723
- }
11724
- vec4 taylorInvSqrt(vec4 r) {
11725
- return 1.79284291400159 - 0.85373472095314 * r;
11726
- }
11727
-
11728
- // Simplex 2D noise
11729
- //
11730
- vec3 permute(vec3 x) {
11731
- return mod(((x * 34.0) + 1.0) * x, 289.0);
11732
- }
11733
-
11734
- float snoise(vec2 v) {
11735
- const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
11736
- vec2 i = floor(v + dot(v, C.yy));
11737
- vec2 x0 = v - i + dot(i, C.xx);
11738
- vec2 i1;
11739
- i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
11740
- vec4 x12 = x0.xyxy + C.xxzz;
11741
- x12.xy -= i1;
11742
- i = mod(i, 289.0);
11743
- vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
11744
- vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
11745
- m = m * m;
11746
- m = m * m;
11747
- vec3 x = 2.0 * fract(p * C.www) - 1.0;
11748
- vec3 h = abs(x) - 0.5;
11749
- vec3 ox = floor(x + 0.5);
11750
- vec3 a0 = x - ox;
11751
- m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
11752
- vec3 g;
11753
- g.x = a0.x * x0.x + h.x * x0.y;
11754
- g.yz = a0.yz * x12.xz + h.yz * x12.yw;
11755
- return 130.0 * dot(m, g);
11756
- }
11757
-
11758
- // Helper function to calculate elevation at any point
11759
- float getElevation(float x, float z) {
11760
- // World-metre wavelength: the source normalized by the water's own size,
11761
- // which shrinks wavelengths on small pools into storm spikes. A fixed
11762
- // 128 m reference reproduces the source's look (256 m lake) at ANY size.
11763
- vec2 pos = vec2(x / WAVE_WORLD_REF, z / WAVE_WORLD_REF);
11764
-
11765
- float elevation = 0.0;
11766
- float amplitude = 1.0;
11767
- float frequency = uWavesFrequency;
11768
- vec2 p = pos.xy;
11769
-
11770
- for(float i = 0.0; i < uWavesIterations; i++) {
11771
- float noiseValue = snoise(p * frequency + uTime * uWavesSpeed);
11772
- elevation += amplitude * noiseValue;
11773
- amplitude *= uWavesPersistence;
11774
- frequency *= uWavesLacunarity;
11775
- }
11776
-
11777
- elevation *= uWavesAmplitude;
11778
-
11779
- return elevation;
11780
- }
11781
-
11782
- // incanto: expanding character-interaction rings — constants mirror
11783
- // RIPPLE_K/W/DECAY/SPREAD in src/3d/water/interaction.ts (keep in sync).
11784
- float getRippleHeight(vec2 p) {
11785
- float h = 0.0;
11786
- for (int i = 0; i < 8; i++) {
11787
- vec4 r = uRipples[i];
11788
- if (r.w <= 0.0) continue;
11789
- float d = distance(p, r.xy);
11790
- h += r.w * sin(6.0 * d - 8.0 * r.z) * exp(-1.1 * r.z) * exp(-0.15 * d * d);
11791
- }
11792
- return h;
11793
- }
11794
-
11795
- // incanto swell: one peaked traveling wave train. Deep-water dispersion
11796
- // (c = sqrt(g/k)) gives each wavelength its REAL speed, so short chop
11797
- // overtakes long swell exactly like the open sea. The pow-shaped profile
11798
- // sharpens crests and flattens troughs (a height-only Gerstner stand-in —
11799
- // no XZ pinch, so the analytic finite-difference normals stay exact).
11800
- float swellWave(vec2 p, vec2 dir, float lambda, float amp, float sharp) {
11801
- float k = 6.2831853 / max(lambda, 1.0);
11802
- float c = sqrt(9.8 / k);
11803
- float s = 0.5 + 0.5 * sin(dot(p, dir) * k - uTime * c * k);
11804
- return amp * (pow(s, sharp) * 2.0 - 1.0);
11805
- }
11806
-
11807
- // incanto swell: three trains — the primary + two shorter crossing sets
11808
- // (+25° / −19°) so crest lines stay broken and organic, never a zebra field.
11809
- float getSwell(vec2 p) {
11810
- if (uSwellAmplitude <= 0.0) return 0.0;
11811
- vec2 d1 = uSwellDir;
11812
- vec2 d2 = normalize(vec2(d1.x * 0.906 - d1.y * 0.423, d1.x * 0.423 + d1.y * 0.906));
11813
- vec2 d3 = normalize(vec2(d1.x * 0.946 + d1.y * 0.326, -d1.x * 0.326 + d1.y * 0.946));
11814
- return swellWave(p, d1, uSwellWavelength, uSwellAmplitude * 0.60, 2.6)
11815
- + swellWave(p, d2, uSwellWavelength * 0.53, uSwellAmplitude * 0.27, 2.2)
11816
- + swellWave(p, d3, uSwellWavelength * 0.31, uSwellAmplitude * 0.16, 1.8);
11817
- }
11818
-
11819
- // incanto GERSTNER: the horizontal half of a swell. A height field can only
11820
- // raise and lower water; a real wave train also DRAGS it toward the crest,
11821
- // which is what makes crests narrow and troughs broad — the single strongest
11822
- // tell of open ocean. The same derivatives give the JACOBIAN of the surface
11823
- // map, and where that collapses the water is folding over itself: whitecaps
11824
- // belong exactly there, not wherever the height happens to be high.
11825
- //
11826
- // Returns (pinch.x, pinch.z, fold) — fold is 1 where the surface folds.
11827
- vec3 getSwellPinch(vec2 p) {
11828
- if (uSwellAmplitude <= 0.0 || uSwellSteepness <= 0.0) return vec3(0.0);
11829
- vec2 d1 = uSwellDir;
11830
- vec2 d2 = normalize(vec2(d1.x * 0.906 - d1.y * 0.423, d1.x * 0.423 + d1.y * 0.906));
11831
- vec2 d3 = normalize(vec2(d1.x * 0.946 + d1.y * 0.326, -d1.x * 0.326 + d1.y * 0.946));
11832
- vec2 pinch = vec2(0.0);
11833
- float jxx = 1.0;
11834
- float jzz = 1.0;
11835
- float jxz = 0.0;
11836
- // (direction, wavelength, amplitude) — the same three trains as getSwell
11837
- for (int i = 0; i < 3; i++) {
11838
- vec2 d = i == 0 ? d1 : (i == 1 ? d2 : d3);
11839
- float lambda = uSwellWavelength * (i == 0 ? 1.0 : (i == 1 ? 0.53 : 0.31));
11840
- float amp = uSwellAmplitude * (i == 0 ? 0.60 : (i == 1 ? 0.27 : 0.16));
11841
- float k = 6.2831853 / max(lambda, 1.0);
11842
- float c = sqrt(9.8 / k);
11843
- float phase = dot(p, d) * k - uTime * c * k;
11844
- // steepness per train, capped so the trochoid never loops back on itself
11845
- float q = min(uSwellSteepness / max(k * amp * 3.0, 1e-4), 1.0);
11846
- pinch -= d * (q * amp * cos(phase));
11847
- float f = q * amp * k * sin(phase);
11848
- jxx -= d.x * d.x * f;
11849
- jzz -= d.y * d.y * f;
11850
- jxz -= d.x * d.y * f;
11851
- }
11852
- float jacobian = jxx * jzz - jxz * jxz;
11853
- return vec3(pinch, 1.0 - smoothstep(0.15, 0.75, jacobian));
11854
- }
11855
-
11856
- // incanto: waves + swell + ripples — used for displacement AND the normal
11857
- // samples so every layer catches the light like real waves.
11858
- // incanto shore: two incommensurate long surges — the waterline breathes in
11859
- // wave-sets instead of a metronome tide
11860
- float getSwash(vec2 p) {
11861
- if (uShoreWaves <= 0.0) return 0.0;
11862
- float along = dot(p, uSwellDir);
11863
- return uShoreWaves *
11864
- (0.06 * sin(along * 0.045 - uTime * 0.42) + 0.045 * sin(along * 0.11 - uTime * 0.71 + 1.3));
11865
- }
11866
-
11867
- float getHeight(float x, float z) {
11868
- return getElevation(x, z) + getSwell(vec2(x, z)) + getSwash(vec2(x, z)) +
11869
- getRippleHeight(vec2(x, z));
11870
- }
11871
-
11872
- void main() {
11873
- vec4 modelPosition = modelMatrix * vec4(position, 1.0);
11874
-
11875
- float elevation = getHeight(modelPosition.x, modelPosition.z);
11876
- modelPosition.y += elevation;
11877
- // Gerstner pinch: the height field is sampled at the UNDISPLACED position
11878
- // (so the analytic finite-difference normals below stay consistent), then
11879
- // the surface is dragged horizontally toward its crests. At the steepness
11880
- // this ships the normal error is far under the detail-normal layers.
11881
- vec3 pinch = getSwellPinch(modelPosition.xz);
11882
- modelPosition.xz += pinch.xy;
11883
- vFold = pinch.z;
11884
-
11885
- // Height adjustment to smooth water edges
11886
- // Lower water height as it approaches scale boundaries
11887
- // incanto: measured from the node's center, not the world origin
11888
- vec2 normalizedPos = (modelPosition.xz - uWaterCenter.xz) / vec2(uScale.x, uScale.z);
11889
- float distanceFromCenter = length(normalizedPos);
11890
-
11891
- // Gradually lower water height near edges
11892
- float edgeFadeStart = 0.85; // Start fade from 85% point
11893
- float edgeFadeEnd = 0.98; // Completely lowered at 98% point
11894
-
11895
- if (uEdgeFadeOn > 0.5 && distanceFromCenter >= edgeFadeStart) {
11896
- float fadeAmount = smoothstep(edgeFadeStart, edgeFadeEnd, distanceFromCenter);
11897
- float heightReduction = fadeAmount * 0.5; // Lower up to 0.5 units maximum
11898
- modelPosition.y -= heightReduction;
11899
- }
11900
-
11901
- // Adjust appropriate eps value according to scale
11902
- float eps = 0.001 * max(uScale.x, uScale.z);
11903
-
11904
- // Calculate normal using partial derivatives with scaled eps
11905
- vec3 tangent = normalize(vec3(eps, getHeight(modelPosition.x + eps, modelPosition.z) - elevation, 0.0));
11906
- vec3 bitangent = normalize(vec3(0.0, getHeight(modelPosition.x, modelPosition.z + eps) - elevation, eps));
11907
- vec3 objectNormal = normalize(cross(bitangent, tangent));
11908
-
11909
- // incanto: tangent/bitangent above are WORLD-space — pass the world normal
11910
- // straight through (the source's normalMatrix mapped it into VIEW space and
11911
- // then dotted it against world vectors in the fragment shader).
11912
- vNormal = objectNormal;
11913
- vWorldPosition = modelPosition.xyz;
11914
-
11915
- gl_Position = projectionMatrix * viewMatrix * modelPosition;
11916
- vScreenPosition = gl_Position;
11917
-
11918
- // incanto atmosphere: fog depth from the displaced surface position
11919
- vec4 mvPosition = viewMatrix * modelPosition;
11920
- #include <fog_vertex>
11921
- }
11922
- `;
11923
- const WATER_FRAG = `
11924
- precision highp float;
11925
-
11926
- // incanto atmosphere: standard fog chunk (compiled out without scene fog)
11927
- #include <fog_pars_fragment>
11928
-
11929
- uniform float uOpacity;
11930
-
11931
- uniform vec3 uTroughColor;
11932
- uniform vec3 uSurfaceColor;
11933
- uniform vec3 uPeakColor;
11934
-
11935
- uniform float uPeakThreshold;
11936
- uniform float uPeakTransition;
11937
- uniform float uTroughThreshold;
11938
- uniform float uTroughTransition;
11939
-
11940
- uniform float uFresnelScale;
11941
- uniform float uFresnelPower;
11942
- uniform vec3 uScale;
11943
- // incanto: world-anchor (the color ramp is relative to the waterline)
11944
- uniform vec3 uWaterCenter;
11945
- // incanto: 0 until the first cube render — the source declared but never read it
11946
- uniform float uUseEnvironmentMap;
11947
-
11948
- // incanto v2: sun specular (glint + sheen) off the detail-perturbed normal
11949
- uniform vec3 uSunDirection;
11950
- uniform vec3 uSunColor;
11951
- uniform float uSunIntensity;
11952
- // incanto v2: animated procedural detail-normal strength
11953
- uniform float uDetailStrength;
11954
- // incanto v2: Beer's-law absorption (per meter of water along the view ray)
11955
- uniform float uAbsorption;
11956
- // incanto v2: scene pre-pass gates — 0 headless / before the first render
11957
- uniform float uUseSceneDepth;
11958
- uniform float uUseRefraction;
11959
- // incanto v2: half-res scene color grab (the refracted underwater view).
11960
- // grab-clip: Water3D renders this pass clipped at the waterline, so it holds
11961
- // ONLY underwater geometry — refraction may sample it freely (the halo
11962
- // guards this freed us from lived here; see _onRender3D). uDepthTexture is
11963
- // NOT clipped (a depth-only completion pass restores the above-water scene),
11964
- // so depth always means the true nearest occluder — 'no sample' never gets
11965
- // mistaken for 'open water' (ref-26's shoreline seam).
11966
- uniform sampler2D uSceneColor;
11967
-
11968
- // Uniforms for foam effects
11969
- uniform float uEnableFoam;
11970
- uniform vec3 uFoamColor;
11971
- uniform float uFoamIntensity;
11972
- uniform float uFoamRange;
11973
- uniform float uFoamScale;
11974
- uniform sampler2D uDepthTexture;
11975
- uniform float uCameraNear;
11976
- uniform float uCameraFar;
11977
- uniform float uTime;
11978
-
11979
- // incanto: splash WHITEWATER — the ripple buffer (also bound in the vertex
11980
- // shader) drives expanding foam on the surface; uSplashFoam = Water3D.splash.
11981
- uniform vec4 uRipples[8];
11982
- uniform float uRippleFoam[8];
11983
- uniform float uSplashFoam;
11984
-
11985
- // incanto swell: open-water WHITECAPS — foam breaking on the highest crests,
11986
- // independent of the shoreline depth mask. uWaveEnvelope is the JS-computed
11987
- // total wave amplitude (FBM + swell) so the crest mask normalizes by the
11988
- // actual sea state, not the surface's extent. uSwellAmplitude gates the
11989
- // swell-only color work (crest banding + wave-face shading).
11990
- uniform float uWhitecaps;
11991
- uniform float uWaveEnvelope;
11992
- uniform float uSwellAmplitude;
11993
- // incanto shore: BREAKERS — foam fronts marching down the depth gradient
11994
- // toward the beach. Phase rides the VERTICAL water depth, so the fronts hug
11995
- // the shore's depth contours automatically and roll shoreward with time.
11996
- uniform float uShoreWaves;
11997
-
11998
- // incanto presets: above-water CAUSTICS — the dancing light web on the
11999
- // submerged bottom, seen THROUGH the surface (the pool/shallow-sea look).
12000
- // Intensity 0 = off. The pattern fn is shared with the underwater composite.
12001
- uniform float uCausticsAbove;
12002
- // incanto presets: per-preset reflection ceiling (the old hard 0.6) — lakes
12003
- // push toward mirror, pools stay glassy-clear.
12004
- uniform float uReflectivityMax;
12005
-
12006
- ${CAUSTIC_GLSL}
12007
-
12008
- // 🎨 Contact surface color customization uniforms
12009
- uniform vec3 uEdgeColor;
12010
- uniform float uEdgeIntensity;
12011
- uniform float uEdgeWidth;
12012
-
12013
- varying vec3 vNormal;
12014
- varying vec3 vWorldPosition;
12015
- varying vec4 vScreenPosition;
12016
- // incanto GERSTNER: how far the surface has folded here (whitecap seed)
12017
- varying float vFold;
12018
-
12019
- uniform samplerCube uEnvironmentMap;
12020
- // incanto MIRROR: a planar reflection rendered from the camera mirrored across
12021
- // the water plane. A water-centered cube map can only carry DISTANT content —
12022
- // the shoreline trees, the boat, the player standing at the edge are exactly
12023
- // what a still lake is supposed to show, and only a planar pass has them.
12024
- // uMirrorMatrix projects a world position into that render's UV, so the sample
12025
- // is self-consistent no matter where the fragment sits on the surface.
12026
- uniform sampler2D uMirrorMap;
12027
- uniform mat4 uMirrorMatrix;
12028
- uniform float uUseMirror;
12029
- uniform float uMirrorDistort;
12030
-
12031
- // Noise function (for foam patterns)
12032
- float hash(vec2 p) {
12033
- return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
12034
- }
12035
-
12036
- float noise(vec2 p) {
12037
- vec2 i = floor(p);
12038
- vec2 f = fract(p);
12039
- f = f * f * (3.0 - 2.0 * f);
12040
-
12041
- float a = hash(i);
12042
- float b = hash(i + vec2(1.0, 0.0));
12043
- float c = hash(i + vec2(0.0, 1.0));
12044
- float d = hash(i + vec2(1.0, 1.0));
12045
-
12046
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
12047
- }
12048
-
12049
- // incanto v2: the SAME simplex the vertex waves use (Ian McEwan, Stefan
12050
- // Gustavson — https://github.com/stegu/webgl-noise), here feeding the
12051
- // procedural detail normals so the package stays texture-free.
12052
- vec3 permute(vec3 x) {
12053
- return mod(((x * 34.0) + 1.0) * x, 289.0);
12054
- }
12055
-
12056
- float snoise(vec2 v) {
12057
- const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
12058
- vec2 i = floor(v + dot(v, C.yy));
12059
- vec2 x0 = v - i + dot(i, C.xx);
12060
- vec2 i1;
12061
- i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
12062
- vec4 x12 = x0.xyxy + C.xxzz;
12063
- x12.xy -= i1;
12064
- i = mod(i, 289.0);
12065
- vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
12066
- vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
12067
- m = m * m;
12068
- m = m * m;
12069
- vec3 x = 2.0 * fract(p * C.www) - 1.0;
12070
- vec3 h = abs(x) - 0.5;
12071
- vec3 ox = floor(x + 0.5);
12072
- vec3 a0 = x - ox;
12073
- m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
12074
- vec3 g;
12075
- g.x = a0.x * x0.x + h.x * x0.y;
12076
- g.yz = a0.yz * x12.xz + h.yz * x12.yw;
12077
- return 130.0 * dot(m, g);
12078
- }
12079
-
12080
- // refinement pass: Beer's law per CHANNEL — red is absorbed first, blue/green
12081
- // linger, so a sandy bottom turns turquoise long before it turns deep blue
12082
- // (the wide turquoise→blue gradient of a real tropical shallows). The scalar
12083
- // uAbsorption prop scales this fixed spectral shape.
12084
- const vec3 ABSORB_TINT = vec3(2.4, 0.7, 0.5);
12085
-
12086
- // incanto v2: finite-difference gradient of the simplex height field
12087
- vec2 noiseGrad(vec2 p) {
12088
- const float e = 0.06;
12089
- float h = snoise(p);
12090
- return vec2(snoise(p + vec2(e, 0.0)) - h, snoise(p + vec2(0.0, e)) - h) / e;
12091
- }
12092
-
12093
- // incanto v2: animated detail-normal layers — the high-frequency perturbation
12094
- // that makes the surface feel LIQUID up close (the vertex FBM is far too
12095
- // coarse to catch light per-pixel). Per-layer distance fades stay (procedural
12096
- // noise has no mips — past its range a layer aliases into white speckle).
12097
- //
12098
- // refinement pass 3: ANISOTROPIC wind-stretched ripple. The old isotropic
12099
- // simplex layers perturbed the normal in soft round BLOBS — under the sun
12100
- // they read as sticky slime / 액체괴물, never as water. Real wind ripple is
12101
- // (a) stretched ALONG the wind, (b) much finer than swell, and (c) crisper
12102
- // than smooth lumps. So: the noise domain is compressed along a wind
12103
- // direction and the two fine layers run in CROSSING directions (±~25° off
12104
- // the wind). dapple pass (ref-27): full ridge sharpening (1-|n|) at 2.8×
12105
- // stretch folded the field into LONG PARALLEL CREASES — fabric wrinkles,
12106
- // not water. Stretch comes down to 1.7×, the ridge transform is BLENDED
12107
- // ~half toward plain simplex (soft short crests, no continuous fold lines),
12108
- // the mid layer's frequency rises so features shorten, and the two crossing
12109
- // layers sit close in weight so no single direction dominates. One coarse
12110
- // smooth layer keeps long-swell coherence underneath. The line this walks:
12111
- // PR-J's isotropic goop ←→ the crease field this replaces.
12112
- // ============ the WATER-FEEL rewrite (the syrup fix) ============
12113
- // The old detail layers had two viscosity bugs that made every surface read
12114
- // as 꿀렁꿀렁한 gel instead of water:
12115
- // 1. WRONG SPEED: each ripple layer drifted ~10× slower than a real wave
12116
- // of its wavelength. Deep-water dispersion says a λ-meter wave travels
12117
- // at c = sqrt(gλ/2π) — a 40 cm ripple crosses ITSELF ~2.7× per second.
12118
- // Slow fine ripples are exactly what honey looks like.
12119
- // 2. PATTERN SLIDING: a translated noise field reads as one sticker being
12120
- // dragged (cellophane). Real ripple CHURNS — crests appear, interfere
12121
- // and die. The fine octaves are therefore INTERFERENCE PAIRS: the same
12122
- // wavelength read twice in slightly split directions and speeds; their
12123
- // sum morphs continuously instead of sliding.
12124
- // Four octaves, each advected at its REAL phase speed (in noise-space,
12125
- // phase = c/λ per second — short waves flicker fast, the tell of thin
12126
- // mobile water): λ 6.0 m → 0.51/s · 2.2 m → 0.84/s · 0.9 m → 1.31/s ·
12127
- // 0.38 m → 2.02/s.
12128
- const vec2 WIND_DIR = vec2(0.8192, 0.5735);
12129
-
12130
- vec2 octaveSlope(vec2 p, vec2 dir, float lambda, float t) {
12131
- // advect ALONG the octave's travel direction at the dispersion speed
12132
- float phase = sqrt(1.56 / lambda) * t; // c/λ = sqrt(g/(2πλ)) · … ≈ sqrt(1.56/λ)
12133
- return noiseGrad(p / lambda + dir * phase);
12134
- }
12135
- // interference pair: same wavelength, directions split ±14°, speeds split
12136
- // ±12% — the sum churns (crests are born and die) instead of sliding.
12137
- vec2 octavePair(vec2 p, vec2 dir, float lambda, float t) {
12138
- vec2 dA = normalize(dir + 0.25 * vec2(-dir.y, dir.x));
12139
- vec2 dB = normalize(dir - 0.25 * vec2(-dir.y, dir.x));
12140
- float phase = sqrt(1.56 / lambda) * t;
12141
- return noiseGrad(p / lambda + dA * (phase * 1.12))
12142
- + noiseGrad(p / lambda * 1.07 + dB * (phase * 0.88) + 17.0);
12143
- }
12144
-
12145
- vec3 detailNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
12146
- float fadeCoarse = 1.0 - smoothstep(90.0, 420.0, camDist);
12147
- float fadeMid = 1.0 - smoothstep(40.0, 170.0, camDist);
12148
- float fadeFine = 1.0 - smoothstep(18.0, 80.0, camDist);
12149
- float fadeMicro = 1.0 - smoothstep(8.0, 34.0, camDist);
12150
- vec2 dMid = normalize(vec2(0.62, 0.78));
12151
- vec2 slope =
12152
- octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse)
12153
- + octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.10 * fadeMid)
12154
- + octavePair(worldXZ, WIND_DIR, 0.9, uTime) * (0.055 * fadeFine)
12155
- + octavePair(worldXZ, dMid, 0.38, uTime) * (0.042 * fadeMicro);
12156
- slope *= strength * 3.0; // spectrum norm: default strength 0.26 ≈ calm-lively
12157
- return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
12158
- }
12159
-
12160
- // The GLOSS normal — what fresnel, the reflection and the refraction offset
12161
- // read. Only the two long octaves: a mirror the size of a pixel integrates
12162
- // micro-ripple away, so reflections stay smooth and ROUNDED (the glassy
12163
- // pool-reference surface); the fast fine churn lives in the sun glitter and
12164
- // the body shading, never as reflection speckle.
12165
- vec3 glossNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
12166
- float fadeCoarse = 1.0 - smoothstep(90.0, 420.0, camDist);
12167
- float fadeMid = 1.0 - smoothstep(40.0, 170.0, camDist);
12168
- vec2 dMid = normalize(vec2(0.62, 0.78));
12169
- vec2 slope =
12170
- octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse)
12171
- + octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.075 * fadeMid);
12172
- slope *= strength * 3.0;
12173
- return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
12174
- }
12175
-
12176
- // Linear depth conversion
12177
- float linearizeDepth(float depth, float near, float far) {
12178
- float z = depth * 2.0 - 1.0;
12179
- return (2.0 * near * far) / (far + near - z * (far - near));
12180
- }
12181
-
12182
- // incanto: splash WHITEWATER. The look that read as FAKE was a clean radial
12183
- // disc/ring × speckle. Real foam is irregular clumps + tendrils that drift with
12184
- // the water and break apart at the edges. So: a soft COVERAGE envelope (where
12185
- // foam can be — an expanding patch that fades over FOAM_MAX_AGE; this is the
12186
- // CPU twin rippleFoamAt, constants MIRRORED) THRESHOLDS a domain-warped
12187
- // fractal noise. Dense coverage → broad froth; thinning coverage (edges / old)
12188
- // raises the threshold → only the noise peaks survive → it erodes into sparse
12189
- // bubbles. The warp + slow drift make it look advected, not stamped.
12190
- float foamFbm(vec2 p) {
12191
- float v = 0.0, a = 0.5;
12192
- for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2.02 + 7.3; a *= 0.5; }
12193
- return v; // ~0..0.94
12194
- }
12195
-
12196
- float rippleFoam(vec2 p) {
12197
- if (uSplashFoam < 0.5) return 0.0;
12198
- float cover = 0.0;
12199
- for (int i = 0; i < 8; i++) {
12200
- float foam = uRippleFoam[i];
12201
- if (foam <= 0.0) continue;
12202
- vec4 r = uRipples[i];
12203
- float age = r.z;
12204
- float life = 1.0 - age / 1.6; // FOAM_MAX_AGE
12205
- if (life <= 0.0) continue;
12206
- float d = distance(p, r.xy);
12207
- float radius = 0.4 + 1.15 * age; // FOAM_R0 + FOAM_SPREAD * age
12208
- cover = max(cover, (1.0 - smoothstep(radius * 0.7, radius, d)) * foam * life);
12209
- }
12210
- if (cover <= 0.001) return 0.0;
12211
- // organic clumps: domain-warp the fbm (swirl) + slow drift = advected foam.
12212
- // FINE frequencies (premium foam is many small bubbles, not coarse blobs).
12213
- vec2 flow = vec2(uTime * 0.05, uTime * -0.04);
12214
- vec2 warp = vec2(foamFbm(p * 1.1 + flow), foamFbm(p * 1.1 + 4.2 - flow)) - 0.5;
12215
- float pattern = foamFbm(p * 2.6 + warp * 1.4 + flow);
12216
- // erode: high coverage → low threshold (denser foam); low coverage → high
12217
- // threshold (only the peaks survive → delicate lacy bubbles, soft edges).
12218
- // Raised floor + wide band = sparser + softer = subtler/more refined.
12219
- float thresh = mix(0.36, 0.96, 1.0 - cover);
12220
- float foamHere = smoothstep(thresh, thresh + 0.26, pattern);
12221
- return clamp(foamHere * (0.4 + 0.5 * cover), 0.0, 1.0);
12222
- }
12223
-
12224
- // Enhanced depth-based foam effect calculation
12225
- float calculateFoam() {
12226
- // incanto v2: foam rides the always-on scene depth pass — both gates
12227
- if (uEnableFoam < 0.5 || uUseSceneDepth < 0.5) return 0.0;
12228
-
12229
- // Convert screen coordinates to texture coordinates
12230
- vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
12231
-
12232
- // Sample scene depth value from depth texture
12233
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
12234
- float waterDepth = gl_FragCoord.z;
12235
-
12236
- // Convert to linear depth
12237
- float linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
12238
- float linearWaterDepth = linearizeDepth(waterDepth, uCameraNear, uCameraFar);
12239
-
12240
- // Calculate depth difference (distance between water and other objects)
12241
- float depthDiff = abs(linearSceneDepth - linearWaterDepth);
12242
-
12243
- // Generate foam - stronger in wider range
12244
- float foamFactor = 1.0 - smoothstep(0.0, uFoamRange, depthDiff);
12245
- foamFactor = pow(foamFactor, 0.5); // Softer decay
12246
-
12247
- // Generate strong foam patterns with multi-layer noise
12248
- vec2 foamUV = vWorldPosition.xz * uFoamScale + uTime * 0.2;
12249
- float foamNoise = 0.0;
12250
-
12251
- // Large pattern
12252
- foamNoise += noise(foamUV) * 0.6;
12253
- // Medium pattern
12254
- foamNoise += noise(foamUV * 3.0 + uTime * 0.5) * 0.4;
12255
- // Fine pattern
12256
- foamNoise += noise(foamUV * 8.0 + uTime * 1.0) * 0.3;
12257
- // Micro pattern
12258
- foamNoise += noise(foamUV * 20.0 + uTime * 1.5) * 0.2;
12259
-
12260
- // Normalize noise
12261
- foamNoise /= 1.5;
12262
-
12263
- // refinement pass: WISPY foam — a wide soft ramp instead of the old hard
12264
- // contrast boost, and the constant edge band cut way down so shallows read
12265
- // as noise-broken patches (the reference's channel foam), not a painted rim.
12266
- float foam = foamFactor * foamNoise * uFoamIntensity;
12267
- foam = smoothstep(0.12, 0.95, foam);
12268
-
12269
- // Additional foam layer - edge enhancement (kept faint, noise-broken — a
12270
- // constant term veiled whole shallow ponds in flat white)
12271
- float edgeFoam = foamFactor * foamFactor * 0.18 * (0.35 + 0.65 * foamNoise);
12272
- foam = max(foam, edgeFoam);
12273
-
12274
- // incanto v2: wave/ripple crests breaking NEAR shores kick up extra foam —
12275
- // the elevation ramp's peak factor masked by the shoreline depth factor.
12276
- float crest = smoothstep(uPeakThreshold, uPeakThreshold + uPeakTransition * 2.0,
12277
- (vWorldPosition.y - uWaterCenter.y) / uScale.y);
12278
- foam = max(foam, foamFactor * crest * (0.5 + 0.5 * foamNoise));
12279
-
12280
- return clamp(foam, 0.0, 1.0);
12281
- }
12282
-
12283
- // Calculate smooth color blending at contact surfaces
12284
- float calculateEdgeBlending() {
12285
- // Contact surface effects are part of foam effects (enableFoam is required)
12286
- if (uEnableFoam < 0.5 || uUseSceneDepth < 0.5 || uEdgeIntensity <= 0.0) return 0.0;
12287
-
12288
- vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
12289
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
12290
- float waterDepth = gl_FragCoord.z;
12291
-
12292
- float linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
12293
- float linearWaterDepth = linearizeDepth(waterDepth, uCameraNear, uCameraFar);
12294
-
12295
- float depthDiff = abs(linearSceneDepth - linearWaterDepth);
12296
-
12297
- // User-defined range for color blending at contact surfaces
12298
- float blendRange = uFoamRange * uEdgeWidth; // Use user-defined range
12299
- float blendFactor = 1.0 - smoothstep(0.0, blendRange, depthDiff);
12300
-
12301
- return blendFactor; // 0.0 (no effect) ~ 1.0 (maximum blending)
12302
- }
12303
-
12304
- void main() {
12305
- // 🔥 Fundamental solution: Handle overlapping parts with objects
12306
- float depthMask = 1.0; // Default: fully render
12307
-
12308
- // incanto v2: the scene depth pass is always-on for fancy when rendered —
12309
- // the mask (and the water view depth for Beer's law below) now ride
12310
- // uUseSceneDepth instead of the foam toggle.
12311
- vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
12312
- float linearWaterDepth = linearizeDepth(gl_FragCoord.z, uCameraNear, uCameraFar);
12313
- // incanto v2: meters of water along the view ray; -1 = depth unavailable
12314
- float waterViewDepth = -1.0;
12315
- // shoreline-seam fix: hoisted — the refraction block reconstructs the scene
12316
- // sample's world height from it (grab validity, see below)
12317
- float linearSceneDepth = uCameraFar;
12318
-
12319
- if (uUseSceneDepth > 0.5) {
12320
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
12321
- linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
12322
-
12323
- float depthDiff = linearWaterDepth - linearSceneDepth;
12324
- waterViewDepth = max(-depthDiff, 0.0);
12325
-
12326
- // waterline dither: on a near-flat beach one half-res depth texel spans a
12327
- // wide screen block at grazing view, so these thresholds cut staircase
12328
- // notches along the swash edge — jitter the boundary with world noise and
12329
- // the seam breaks organic. Two octaves at LOW amplitude: a flat slope
12330
- // multiplies vertical jitter into horizontal metres, so a single strong
12331
- // octave carved angular value-noise chunks (the diced-radish waterline bug).
12332
- float seamJitter =
12333
- (noise(vWorldPosition.xz * 3.1) * 0.65 + noise(vWorldPosition.xz * 8.7) * 0.35 - 0.5) * 0.035;
12334
-
12335
- // 🎯 Two-step approach
12336
- // Step 1: Discard completely overlapping parts
12337
- float hardDiscardThreshold = 0.1 + seamJitter;
12338
- if (depthDiff > hardDiscardThreshold) {
12339
- discard; // Completely remove definitively overlapping pixels
12340
- }
12341
-
12342
- // Step 2: Gradual transparency for border parts
12343
- float softDiscardStart = -0.05 + seamJitter; // Start fade slightly ahead
12344
- float softDiscardEnd = hardDiscardThreshold;
12345
-
12346
- if (depthDiff > softDiscardStart) {
12347
- depthMask = 1.0 - smoothstep(softDiscardStart, softDiscardEnd, depthDiff);
12348
- // At border: 0 (fully transparent) ~ 1 (fully opaque)
12349
- }
12350
- }
12351
-
12352
- // Calculate vector from camera to the vertex
12353
- vec3 viewDirection = normalize(vWorldPosition - cameraPosition);
12354
- // incanto v2: perturb the analytic vertex normal with the animated detail
12355
- // layers — fresnel, reflection, refraction and the sun glint all read it.
12356
- // refinement pass: the per-LAYER distance fades live inside detailNormal,
12357
- // and fresnel/reflection read a distance-SMOOTHED normal — a pixel at range
12358
- // integrates many ripples, so its fresnel must converge to the swell's, not
12359
- // flicker per ripple (the old per-pixel fresnel turned the whole mid-field
12360
- // into white sky-reflection sequins). The sun glint keeps the full detail
12361
- // normal: distant glitter is real and reads as sparkle, not noise.
12362
- float camDist = length(vWorldPosition - cameraPosition);
12363
- vec3 geoNormal = normalize(vNormal);
12364
- vec3 normal = detailNormal(geoNormal, vWorldPosition.xz, uDetailStrength, camDist);
12365
- // refinement pass 3: the GEO normal must flatten with distance too — the
12366
- // per-vertex FBM normal varies per pixel near the horizon (its features go
12367
- // subpixel), and against the bright sun-side sky every flicker becomes a
12368
- // white sequin. A pixel at range integrates whole wave trains: its normal
12369
- // converges to flat-up, so fresnel/reflection/streak read exactly that.
12370
- float geoFlat = 1.0 - 0.9 * smoothstep(150.0, 600.0, camDist);
12371
- vec3 geoSmooth = normalize(mix(vec3(0.0, 1.0, 0.0), geoNormal, geoFlat));
12372
- float fresnelDetail = 1.0 - 0.85 * smoothstep(20.0, 120.0, camDist);
12373
- // eztree-parity pass: hoisted — fresnel AND the refraction offset read it.
12374
- // crisp-water fix: NEAR the camera the reflection must read the FULL
12375
- // ripple spectrum (sky reflections shatter into crisp shards at arm's
12376
- // length — reading only the smooth octaves rendered soft lava-lamp blobs);
12377
- // with distance it hands over to the smooth gloss so the far field never
12378
- // speckles against the HDR sky.
12379
- vec3 gloss = glossNormal(geoNormal, vWorldPosition.xz, uDetailStrength, camDist);
12380
- vec3 crisp = mix(normal, gloss, smoothstep(6.0, 26.0, camDist));
12381
- vec3 fresnelNormal = normalize(mix(geoSmooth, crisp, fresnelDetail));
12382
- vec3 reflectedDirection = reflect(viewDirection, fresnelNormal);
12383
- // incanto: the source flipped .x here — correct for cube textures LOADED
12384
- // from images, but a left/right mirror for CubeCamera render targets
12385
- // (three samples those with flipEnvMap = +1). Verified live: a marker
12386
- // object's reflection appeared on the opposite side with the flip.
12387
- // incanto: fold reflection rays above the horizon — the lower hemisphere
12388
- // of a water-centered cube map is parallax garbage (the ground under and
12389
- // around the pool) and painted ink-black wavelet patches at grazing angles;
12390
- // the +y bias keeps samples off the smeared horizon seam. refinement pass:
12391
- // 0.1 → 0.15 — the physical sky's brightest haze hugs the horizon, and
12392
- // grazing water sampled it into a blown-white band; a touch higher samples
12393
- // believable pale blue instead.
12394
- reflectedDirection.y = max(abs(reflectedDirection.y), 0.15);
12395
-
12396
- // Sample environment map to get the reflected color
12397
- // refinement pass 3: soft-cap the HDR radiance — the physical sky's
12398
- // horizon haze runs ≫1 and a single bright reflection pixel reads as a
12399
- // white sequin after ACES; 2.5 still tone-maps near-white for real sky
12400
- // reflections without letting one texel blow out.
12401
- vec4 reflectionColor = textureCube(uEnvironmentMap, reflectedDirection);
12402
- reflectionColor.rgb = min(reflectionColor.rgb, vec3(2.5));
12403
-
12404
- // incanto MIRROR: where the planar pass has data it REPLACES the cube — it
12405
- // is the same sky plus everything the cube map is blind to. Rays that leave
12406
- // the screen have no mirror sample, so the border fade hands those back to
12407
- // the cube instead of clamping a stretched edge texel across the surface.
12408
- if (uUseMirror > 0.5) {
12409
- vec4 mirrorClip = uMirrorMatrix * vec4(vWorldPosition, 1.0);
12410
- vec2 mirrorUv = mirrorClip.xy / max(mirrorClip.w, 1e-4);
12411
- // the ripple tilt bends the reflected ray: offset the sample by how far
12412
- // the wavelet normal leans off flat, damped with distance so the far field
12413
- // (many wavelets per pixel) stays a clean mirror instead of a jitter field
12414
- vec2 lean = (fresnelNormal.xz - geoNormal.xz) * uMirrorDistort;
12415
- mirrorUv += lean * (1.0 - smoothstep(20.0, 90.0, camDist));
12416
- vec2 edge = smoothstep(vec2(0.0), vec2(0.06), mirrorUv) *
12417
- (1.0 - smoothstep(vec2(0.94), vec2(1.0), mirrorUv));
12418
- float valid = edge.x * edge.y * step(0.0, mirrorClip.w);
12419
- if (valid > 0.0) {
12420
- vec3 mirrored = min(texture2D(uMirrorMap, mirrorUv).rgb, vec3(2.5));
12421
- reflectionColor.rgb = mix(reflectionColor.rgb, mirrored, valid);
12422
- }
12423
- }
12424
-
12425
- // Calculate fresnel effect
12426
- // incanto: the source dotted the camera→surface direction against the
12427
- // up-facing normal — NEGATIVE at every above-water angle, so the clamp
12428
- // pinned fresnel at its maximum and the whole surface was a constant-ratio
12429
- // mirror. Negate it (surface→camera) for real angle-dependent fresnel:
12430
- // steep view = water color, grazing view = sky mirror.
12431
- // incanto atmosphere: full Schlick — the F0 floor (2%, water's real normal-
12432
- // incidence reflectance) keeps a whisper of sky at steep angles while the
12433
- // default exponent 5 stops the HDR sky from whitewashing the whole surface.
12434
- float fresnel = uFresnelScale * (0.02 + 0.98 * pow(1.0 - clamp(dot(-viewDirection, fresnelNormal), 0.0, 1.0), uFresnelPower));
12435
-
12436
- // Calculate scale-normalized height value
12437
- // incanto: relative to the waterline, not absolute world y
12438
- float normalizedElevation = (vWorldPosition.y - uWaterCenter.y) / uScale.y;
12439
-
12440
- // Calculate transition factors using smoothstep
12441
- float peakFactor = smoothstep(uPeakThreshold - uPeakTransition, uPeakThreshold + uPeakTransition, normalizedElevation);
12442
- float troughFactor = smoothstep(uTroughThreshold - uTroughTransition, uTroughThreshold + uTroughTransition, normalizedElevation);
12443
-
12444
- // Mix between trough and surface colors based on trough transition
12445
- vec3 mixedColor1 = mix(uTroughColor, uSurfaceColor, troughFactor);
12446
-
12447
- // Mix between surface and peak colors based on peak transition
12448
- vec3 mixedColor2 = mix(mixedColor1, uPeakColor, peakFactor);
12449
-
12450
- // incanto swell: the legacy ramp normalizes elevation by the surface's
12451
- // HALF-EXTENT — on a real ocean plane that's hundreds of meters, so the
12452
- // ramp never sees a wave. When swell is on, re-read the crest height
12453
- // against the actual sea state (uWaveEnvelope) and (a) lighten crest tops
12454
- // toward the peak color, (b) shade the wave FACES with a gentle N·L off
12455
- // the distance-smoothed geometry normal — the lit/shadow lanes are what
12456
- // make open-ocean swell READ at any distance. Both gated: swell 0 keeps
12457
- // the legacy look byte-identical.
12458
- if (uSwellAmplitude > 0.0) {
12459
- float crestBand = smoothstep(0.3, 0.9,
12460
- (vWorldPosition.y - uWaterCenter.y) / max(uWaveEnvelope, 0.001));
12461
- mixedColor2 = mix(mixedColor2, uPeakColor, crestBand * 0.45);
12462
- float faceShade = clamp(dot(normalize(mix(vec3(0.0, 1.0, 0.0), normalize(vNormal),
12463
- 1.0 - 0.9 * smoothstep(150.0, 600.0, length(vWorldPosition - cameraPosition)))),
12464
- normalize(uSunDirection)), 0.0, 1.0);
12465
- mixedColor2 *= 0.80 + 0.32 * faceShade;
12466
- }
12467
-
12468
- // incanto v2: Beer's-law depth absorption + screen-space refraction.
12469
- // Shallow water transmits the (refracted) scene almost untouched; depth
12470
- // exponentially absorbs it into the ramp color — clear turquoise edges,
12471
- // dark depths. Headless / first frame: waterViewDepth stays -1 → flat ramp.
12472
- vec3 bodyColor = mixedColor2;
12473
- float bodyOpacity = uOpacity;
12474
- if (waterViewDepth >= 0.0) {
12475
- // refinement pass: per-channel transmittance (see ABSORB_TINT) — the
12476
- // turquoise→deep-blue gradient comes from red dying first, not from a
12477
- // hand-painted band.
12478
- vec3 transmittance = exp(-uAbsorption * ABSORB_TINT * waterViewDepth);
12479
- if (uUseRefraction > 0.5) {
12480
- // eztree-parity pass: the offset reads ONLY the SMOOTH gloss normal —
12481
- // the ridge-sharpened detail normal flips sign across every crest line
12482
- // and combed the submerged scene into jaggy scratch streaks (빗살무늬);
12483
- // even a 15% share re-speckled bright submerged objects.
12484
- vec2 refrSlope = crisp.xz;
12485
- // thickness-scaled parallax: the bottom image's refraction displacement
12486
- // grows with the water column's depth (thin film ≈ no shift, deep water
12487
- // ≈ full shimmer) — zero AT an object's contact line, so a partially
12488
- // submerged object's image stays glued to the surface (the old fixed
12489
- // contact damp), and strongly reduced in knee-deep ponds, where the old
12490
- // distance-only offset flung the pale submerged column into a ragged
12491
- // star splat around its own base (ref-23's waterline blob).
12492
- float contactDamp = clamp(waterViewDepth * 0.5, 0.0, 1.0);
12493
- vec2 refractionOffset = refrSlope * contactDamp * (0.35 / max(linearWaterDepth, 4.0));
12494
- vec2 refractedUV = clamp(screenUV + refractionOffset, vec2(0.001), vec2(0.999));
12495
- // grab-clip: the grab COLOR holds ONLY underwater geometry (Water3D
12496
- // clips that pass at the waterline), so there is no above-water color
12497
- // to bleed and no halo to guard — the PR-K soft reject and PR-L probe
12498
- // ring (whose boundaries became ref-23's silhouette outlines) are gone.
12499
- // One cheap sanity tap remains: an offset landing on geometry in FRONT
12500
- // of this water fragment (the thin waterline band the clip slack keeps,
12501
- // grazing self-overlaps) snaps back to the straight grab pixel…
12502
- float refractedSceneDepth =
12503
- linearizeDepth(texture2D(uDepthTexture, refractedUV).r, uCameraNear, uCameraFar);
12504
- float offsetReject = smoothstep(0.0, 0.05, linearWaterDepth - refractedSceneDepth);
12505
- // …or on an ABOVE-water sample: the depth-completion pass restored its
12506
- // true depth, but the clipped grab has no color there (clear-color
12507
- // texel) — reconstruct its world height along the view ray (view depth
12508
- // is linear in ray distance: t = camDist · sceneDepth/waterDepth) and
12509
- // snap those offsets back too.
12510
- float refrT = camDist * (refractedSceneDepth / max(linearWaterDepth, 1e-4));
12511
- offsetReject = max(offsetReject, smoothstep(0.02, 0.25,
12512
- cameraPosition.y + viewDirection.y * refrT - uWaterCenter.y));
12513
- refractedUV = mix(refractedUV, screenUV, offsetReject);
12514
- // mip-biased grab read: the pre-pass target carries mips (see
12515
- // Water3D._onRender3D) — biasing ~1.75 levels samples a pre-blurred
12516
- // underwater scene. Real water blurs what it refracts; the crisp
12517
- // half-res grab pixel-speckled at every silhouette and ripple edge.
12518
- vec3 sceneColor = texture2D(uSceneColor, refractedUV, 1.75).rgb;
12519
- // incanto presets: above-water caustics — reconstruct the sampled
12520
- // bottom's world position along the view ray and light it with the
12521
- // shared caustic web. Needs a focusing column of water (a centimeters-
12522
- // deep swash film shows no web) and a close viewer: past ~30m each
12523
- // pixel integrates many caustic cells and the additive web averages
12524
- // into a solid white sheet over any flat shallow (aerial sandbar bug).
12525
- if (uCausticsAbove > 0.0) {
12526
- vec3 bottomWorld = cameraPosition + viewDirection * refrT;
12527
- float below = uWaterCenter.y - bottomWorld.y;
12528
- if (below > 0.05) {
12529
- float reach = smoothstep(0.05, 0.5, below) * (1.0 - smoothstep(0.2, 9.0, below));
12530
- float causticFade = 1.0 - smoothstep(28.0, 90.0, camDist);
12531
- float cw = caustic(bottomWorld.xz * 0.42, uTime * 0.7) * 0.65
12532
- + caustic(bottomWorld.xz * 0.72 + 30.0, uTime * 0.55) * 0.35;
12533
- sceneColor += vec3(0.87, 0.96, 1.0) * (cw * uCausticsAbove * reach * causticFade);
12534
- }
12535
- }
12536
- // opacity keeps its dial under in-shader compositing: it scales how
12537
- // strongly the water body hides the Beer-attenuated scene behind it —
12538
- // per channel, so the shallows tint instead of fading uniformly
12539
- vec3 body = clamp(uOpacity * (1.0 - transmittance) * 1.4, 0.0, 1.0);
12540
- // refinement pass 3: crystal shallows (photo reference) — where the
12541
- // water column is thin you SEE THE BOTTOM, ripple-distorted by the
12542
- // detail normals; the body color barely tints it. Clarity ramps the
12543
- // refracted scene up to ~88% inside the first ~1.5 m of water path and
12544
- // hands over to absorption beyond — depth still darkens the bottom.
12545
- float clarity = 0.88 * (1.0 - smoothstep(0.2, 1.5, waterViewDepth));
12546
- body *= 1.0 - clarity;
12547
- // shoreline-seam fallback: when the STRAIGHT sample itself sits above
12548
- // the waterline (beach rising behind a shoreline crest, a piercing
12549
- // object's dry half) the grab texel is clear-color, not the occluder —
12550
- // hand those pixels to the alpha path. The main framebuffer already
12551
- // shows the occluder behind this fragment, so a translucent ramp tint
12552
- // over it is the honest shallow-water look; compositing the clipped
12553
- // grab instead painted ref-26's background-colored band.
12554
- float sceneT = camDist * (linearSceneDepth / max(linearWaterDepth, 1e-4));
12555
- float sceneAbove = smoothstep(0.02, 0.25,
12556
- cameraPosition.y + viewDirection.y * sceneT - uWaterCenter.y);
12557
- float meanAbsorbed = 1.0 - dot(transmittance, vec3(0.299, 0.587, 0.114));
12558
- float alphaOpacity = mix(uOpacity * 0.45, max(uOpacity, 0.95), meanAbsorbed);
12559
- bodyColor = mix(mix(sceneColor, mixedColor2, body), mixedColor2, sceneAbove);
12560
- // in-shader compositing (opacity 1) for valid grab pixels; alpha blend
12561
- // over the main framebuffer for above-water-occluder pixels
12562
- bodyOpacity = mix(1.0, alphaOpacity, sceneAbove);
12563
- } else {
12564
- // no grab pass: alpha does the work — clear shallows, opaque depths
12565
- float meanAbsorbed = 1.0 - dot(transmittance, vec3(0.299, 0.587, 0.114));
12566
- bodyOpacity = mix(uOpacity * 0.45, max(uOpacity, 0.95), meanAbsorbed);
12567
- }
12568
- }
12569
-
12570
- // Mix the final color with the reflection color
12571
- // incanto: gated on uUseEnvironmentMap so an unbound cube map reads flat
12572
- // clamp so grazing angles keep some water colour instead of going pure
12573
- // mirror (dark scenes otherwise paint the near edge black). refinement
12574
- // pass: 0.75 → 0.6 — the reference sea stays teal even at range; an HDR
12575
- // sky at 3/4 mirror washed the whole far field toward white.
12576
- float reflectivity = clamp(fresnel, 0.0, uReflectivityMax) * uUseEnvironmentMap;
12577
- vec3 finalColor = mix(bodyColor, reflectionColor.rgb, reflectivity);
12578
-
12579
- // incanto v2: sun specular off the detail-perturbed normal.
12580
- // refinement pass 3: glints are SPARSE micro-facets. The old 320-power
12581
- // glint + everywhere-sheen flashed on half the ripples in view — the whole
12582
- // sea read as dense white speckle (글로시 슬라임). Now: extreme shininess
12583
- // (1400) so only facets aligned within a fraction of a degree flash, and
12584
- // both terms are weighted toward the sun's AZIMUTH path — crests sparkle
12585
- // along the light path, the rest of the surface stays broad calm color
12586
- // fields (the reference look). The sheen survives only as a soft halo on
12587
- // that path, near-zero elsewhere.
12588
- vec3 sunL = normalize(uSunDirection);
12589
- vec3 halfway = normalize(sunL - viewDirection); // -viewDirection = surface→camera
12590
- float specAngle = max(dot(normal, halfway), 0.0);
12591
- vec2 toFrag = normalize(vWorldPosition.xz - cameraPosition.xz);
12592
- float sunPath = pow(clamp(dot(toFrag, normalize(sunL.xz)), 0.0, 1.0), 5.0);
12593
- // micro-facet glints are a NEAR-field phenomenon on screen: past ~200 m a
12594
- // pixel integrates whole wave trains and per-pixel glints alias back into
12595
- // the old speckle — fade them out and hand the sun path to a SMOOTH streak
12596
- // off the distance-smoothed normal (a real sea's far sun path is a soft
12597
- // continuous band, not glitter).
12598
- // …and a VIEW-angle phenomenon: looking straight down (the photo-ref
12599
- // clarity framing) a real surface shows almost no sparkle — glints live at
12600
- // grazing angles. Weight by a Schlick-like grazing term so steep views
12601
- // stay calm, clear water instead of speckling.
12602
- float grazing = pow(1.0 - clamp(dot(-viewDirection, vec3(0.0, 1.0, 0.0)), 0.0, 1.0), 2.0);
12603
- // water-feel rewrite: NO artificial gate — the micro-ripple spectrum's
12604
- // fast churn makes each facet align with the sun for only a flash, so the
12605
- // glitter is naturally granular AND flickers at ripple speed (the old
12606
- // slow-noise gate was another piece of the syrup feel).
12607
- float glintFade = 1.0 - smoothstep(60.0, 240.0, camDist);
12608
- float glint = pow(specAngle, 900.0) * (0.15 + 0.85 * sunPath) * glintFade
12609
- * (0.1 + 0.9 * grazing);
12610
- float farAngle = max(dot(fresnelNormal, halfway), 0.0);
12611
- float streak = pow(farAngle, 240.0) * 0.45 * sunPath * (1.0 - glintFade);
12612
- float sheen = pow(specAngle, 64.0) * 0.05 * sunPath;
12613
- finalColor += uSunColor * min(uSunIntensity * (glint + streak + sheen), 0.85);
12614
-
12615
- // Calculate and apply foam effects (only when enableFoam is true)
12616
- float foamAmount = 0.0;
12617
- if (uEnableFoam > 0.5) {
12618
- foamAmount = calculateFoam();
12619
- }
12620
- // incanto shore: traveling BREAKERS. The leading edge is sharp (the
12621
- // collapsing face), the tail is a long noisy wash that dissolves seaward
12622
- // of the beach; the whole system lives in the 0.1–3.2 m depth band where
12623
- // real waves shoal and break. Because phase = f(vertical depth), a front
12624
- // is always parallel to the local shoreline — bays curve it, points wrap
12625
- // it, no per-scene wiring.
12626
- if (uShoreWaves > 0.0 && uUseSceneDepth > 0.5 && waterViewDepth >= 0.0) {
12627
- float sceneT2 = camDist * (linearSceneDepth / max(linearWaterDepth, 1e-4));
12628
- float bottomY = cameraPosition.y + viewDirection.y * sceneT2;
12629
- float vertDepth = max(uWaterCenter.y - bottomY, 0.0);
12630
- if (vertDepth > 0.02 && vertDepth < 3.4) {
12631
- // DITHER the depth before phasing: the half-res depth texture
12632
- // quantizes on gentle slopes, and un-dithered fronts render as hard
12633
- // parallel terraces with visible pixel columns.
12634
- float dither = (noise(vWorldPosition.xz * 1.7) - 0.5) * 0.35
12635
- + (noise(vWorldPosition.xz * 6.3 + 11.0) - 0.5) * 0.12;
12636
- float dd = max(vertDepth + dither, 0.0);
12637
- // ONE front per ~1.3 m of depth (the old 0.53 m stacked 3-4 terraces
12638
- // in view at once), soft leading edge, long dissolving wash
12639
- float phase = dd * 0.75 - uTime * 0.4;
12640
- float f = fract(phase);
12641
- float front = smoothstep(0.0, 0.14, f) * (1.0 - smoothstep(0.14, 0.85, f));
12642
- // waves arrive in SETS: a slow alongshore envelope gates whole fronts
12643
- // in and out so the surf breathes instead of ticking like a metronome
12644
- float sets = smoothstep(0.2, 0.75, noise(vWorldPosition.xz * 0.045 + uTime * vec2(0.025, 0.014)));
12645
- float breakup = noise(vWorldPosition.xz * 0.3 + uTime * vec2(0.06, 0.04));
12646
- float lace = noise(vWorldPosition.xz * 2.2 - uTime * vec2(0.18, 0.1));
12647
- front *= (0.45 + 0.55 * breakup) * (0.55 + 0.45 * lace) * (0.35 + 0.65 * sets);
12648
- // no fronts in the centimeters-deep SHEET-FLOW film (an enclosed flat
12649
- // fizzed solid white from the air) — breakers need ~knee depth to form
12650
- float zone = smoothstep(3.4, 1.2, vertDepth) * smoothstep(0.12, 0.38, vertDepth);
12651
- foamAmount = max(foamAmount, clamp(uShoreWaves * front * zone * 1.5, 0.0, 1.0));
12652
- // waterline LACE — fizzing bubbles where the runup meets sand. On a
12653
- // near-FLAT bar the 0–30 cm depth band is tens of meters wide, so an
12654
- // ungated lace carpeted whole flats in white — a slow patch gate keeps
12655
- // it as drifting foam streaks instead (real tidal-flat scum lines).
12656
- float lacePatch = smoothstep(0.4, 0.8, noise(vWorldPosition.xz * 0.5 + uTime * vec2(0.06, 0.045)));
12657
- float lace2 = smoothstep(0.24, 0.02, vertDepth) *
12658
- (0.35 + 0.65 * noise(vWorldPosition.xz * 3.1 + uTime * vec2(0.5, 0.35)));
12659
- foamAmount = max(foamAmount, clamp(uShoreWaves * lace2 * lacePatch * 0.4, 0.0, 1.0));
12660
- }
12661
- }
12662
- // incanto swell: whitecaps — foam eats the TOPS of the tallest crests in
12663
- // open water. The crest mask normalizes elevation by the real wave envelope
12664
- // (uScale.y is the surface's half-extent — useless as a sea-state yardstick
12665
- // on a big ocean plane), and a two-octave noise breaks the caps into
12666
- // patches that live and die with the crests. Distance-faded: far whitecaps
12667
- // subtend under a pixel and would alias into horizon speckle.
12668
- if (uWhitecaps > 0.0 && uWaveEnvelope > 0.001) {
12669
- float crestN = (vWorldPosition.y - uWaterCenter.y) / uWaveEnvelope;
12670
- float cap = smoothstep(0.5, 0.85, crestN);
12671
- vec2 capUV = vWorldPosition.xz * 0.3;
12672
- float capNoise = noise(capUV * 2.2 + uTime * vec2(0.10, 0.06)) * 0.65
12673
- + noise(capUV * 6.5 - uTime * vec2(0.05, 0.14)) * 0.35;
12674
- float capFade = 1.0 - smoothstep(220.0, 640.0, camDist);
12675
- // height says WHERE the crest is; the Jacobian says where it is actually
12676
- // breaking. Taking the max keeps the tuned height-based caps and adds the
12677
- // ones physics insists on — the pinched crest faces about to spill.
12678
- cap = max(cap, vFold);
12679
- float whitecap = uWhitecaps * cap * smoothstep(0.42, 0.78, capNoise) * capFade;
12680
- foamAmount = max(foamAmount, whitecap);
12681
- }
12682
- // incanto: splash/wake whitewater rides the ripple buffer, INDEPENDENT of the
12683
- // depth-foam gate so it works even on shallow ponds / before the first pre-pass
12684
- float splashFoam = rippleFoam(vWorldPosition.xz);
12685
-
12686
- // 🎨 Calculate contact surface color blending (part of foam effects)
12687
- if (uEnableFoam > 0.5 && uEdgeIntensity > 0.0) {
12688
- float edgeBlend = calculateEdgeBlending();
12689
-
12690
- // Blend water color with user-defined color at contact surfaces
12691
- vec3 blendedWaterColor = mix(finalColor, uEdgeColor, edgeBlend * uEdgeIntensity);
12692
-
12693
- // Slightly darken at contact surfaces (shadow effect)
12694
- float shadowIntensity = uEdgeIntensity * 0.5; // Link shadow intensity to user settings
12695
- float shadowFactor = 1.0 - edgeBlend * shadowIntensity;
12696
- finalColor = blendedWaterColor * shadowFactor;
12697
- }
12698
-
12699
- // Apply foam effects (only when foamAmount is greater than 0)
12700
- // refinement pass: low-contrast blend, no additive brightness kick — foam
12701
- // is bubbles ON the water, slightly lighter, never glowing white.
12702
- if (foamAmount > 0.0) {
12703
- finalColor = mix(finalColor, uFoamColor, foamAmount * 0.55);
12704
- }
12705
- // splash froth: pale foam tint (NOT pure white — that floats on top as a
12706
- // jarring decal), the water still reads through, so a plunge/wake looks like
12707
- // disturbed water rather than painted-on white
12708
- if (splashFoam > 0.0) {
12709
- vec3 froth = mix(uFoamColor, vec3(1.0), 0.3); // pale, not stark white
12710
- finalColor = mix(finalColor, froth, splashFoam * 0.58); // subtle, water reads through
12711
- }
12712
-
12713
- // 🎨 Adjust transparency at contact surfaces (part of foam effects)
12714
- // incanto v2: starts from bodyOpacity (Beer's-law depth-shaped alpha)
12715
- float baseOpacity = bodyOpacity;
12716
- if (uEnableFoam > 0.5 && uEdgeIntensity > 0.0) {
12717
- float edgeBlend = calculateEdgeBlending();
12718
- float transparencyEffect = uEdgeIntensity * 0.4; // Maximum 40% transparency increase
12719
- float edgeTransparency = mix(1.0, 1.0 - transparencyEffect, edgeBlend);
12720
- baseOpacity = bodyOpacity * edgeTransparency;
12721
- }
12722
-
12723
- // Make areas with foam more opaque — shoreline foam firm, splash froth gentler
12724
- // (a lighter opacity bump keeps the splash translucent, not a solid white cap)
12725
- float foamOpacity = baseOpacity;
12726
- if (foamAmount > 0.0) {
12727
- foamOpacity = mix(baseOpacity, min(baseOpacity + 0.5, 1.0), foamAmount);
12728
- }
12729
- if (splashFoam > 0.0) {
12730
- foamOpacity = max(foamOpacity, mix(baseOpacity, min(baseOpacity + 0.3, 1.0), splashFoam));
12731
- }
12732
-
12733
- // 🔥 Final fundamental solution: Apply depth mask
12734
- float finalOpacity = foamOpacity * depthMask;
12735
-
12736
- gl_FragColor = vec4(finalColor, finalOpacity);
12737
-
12738
- // incanto atmosphere: join three's output pipeline — ACES tone mapping +
12739
- // working→output colorspace encode + linear fog, in the SAME order as the
12740
- // built-in materials so water fogs/grades identically to the terrain.
12741
- #include <tonemapping_fragment>
12742
- #include <colorspace_fragment>
12743
- #include <fog_fragment>
12744
- }
12745
- `;
12746
- //#endregion
12747
- //#region src/3d/water/underwater.ts
12748
- /**
12749
- * True when the camera EYE sits below the water surface and inside its
12750
- * (margin-expanded) XZ footprint — i.e. genuinely underwater. The tie at exactly
12751
- * the surface height counts as above water (no flicker as you break the surface).
12752
- * The underwater look is deliberately gated to this (not "near" the surface), so
12753
- * wading with the camera above the water never tints the view. `marginXZ` lets it
12754
- * engage a touch past the rim so swimming to the edge doesn't pop.
12755
- */
12756
- function isCameraUnderwater(camX, camY, camZ, s, marginXZ = 0) {
12757
- if (camY >= s.centerY) return false;
12758
- return Math.abs(camX - s.centerX) <= s.halfW + marginXZ && Math.abs(camZ - s.centerZ) <= s.halfD + marginXZ;
12759
- }
12760
- /** Default visibility (fog far, meters) when the prop doesn't specify one. */
12761
- const DEFAULT_VISIBILITY = 22;
12762
- const DEFAULT_CAUSTICS = {
12763
- enabled: true,
12764
- color: "#cdeeff",
12765
- intensity: .55,
12766
- scale: .32,
12767
- speed: 1
12768
- };
12769
- /**
12770
- * Resolve the `underwater` prop (`false` | `true` | `{color?, visibility?,
12771
- * caustics?}`) into concrete values. With no explicit color the murk is DERIVED
12772
- * from the water's own hue — darkened and pulled a touch toward deep blue so
12773
- * being submerged reads as "inside THIS water", brighter water → brighter murk.
12774
- */
12775
- function resolveUnderwater(prop, waterColor) {
12776
- if (prop === false) return {
12777
- enabled: false,
12778
- color: deriveMurk(waterColor),
12779
- visibility: DEFAULT_VISIBILITY,
12780
- caustics: DEFAULT_CAUSTICS
12781
- };
12782
- const obj = prop && typeof prop === "object" ? prop : {};
12783
- return {
12784
- enabled: true,
12785
- color: obj.color ?? deriveMurk(waterColor),
12786
- visibility: obj.visibility ?? DEFAULT_VISIBILITY,
12787
- caustics: resolveCaustics(obj.caustics)
12788
- };
12789
- }
12790
- function resolveCaustics(prop) {
12791
- if (prop === false) return {
12792
- ...DEFAULT_CAUSTICS,
12793
- enabled: false
12794
- };
12795
- const obj = prop && typeof prop === "object" ? prop : {};
12796
- return {
12797
- enabled: true,
12798
- color: typeof obj.color === "string" ? obj.color : DEFAULT_CAUSTICS.color,
12799
- intensity: typeof obj.intensity === "number" ? obj.intensity : DEFAULT_CAUSTICS.intensity,
12800
- scale: typeof obj.scale === "number" ? obj.scale : DEFAULT_CAUSTICS.scale,
12801
- speed: typeof obj.speed === "number" ? obj.speed : DEFAULT_CAUSTICS.speed
12802
- };
12803
- }
12804
- /**
12805
- * Darken a hex color toward a deep blue-green — the look of light absorbed by a
12806
- * column of water (red dies first, blue lingers). Pure hex math so the parser
12807
- * stays three-free.
12808
- */
12809
- function deriveMurk(hex) {
12810
- const { r, g, b } = parseHex(hex);
12811
- const mix = (c, target, t) => Math.round(c * (1 - t) + target * t);
12812
- return toHex(Math.round(mix(r, 6, .45) * .7), Math.round(mix(g, 40, .4) * .78), Math.round(mix(b, 70, .35) * .82));
12813
- }
12814
- function parseHex(hex) {
12815
- const h = hex.replace("#", "");
12816
- const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
12817
- return {
12818
- r: Number.parseInt(n.slice(0, 2), 16) || 0,
12819
- g: Number.parseInt(n.slice(2, 4), 16) || 0,
12820
- b: Number.parseInt(n.slice(4, 6), 16) || 0
12821
- };
12822
- }
12823
- function toHex(r, g, b) {
12824
- const c = (v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0");
12825
- return `#${c(r)}${c(g)}${c(b)}`;
12826
- }
12827
- //#endregion
12828
- //#region src/3d/water/waves.ts
12829
- /**
12830
- * CPU twin of the Water3D vertex-shader wave field — pure math, no three.
12831
- *
12832
- * `waveElevation` ports the shader's 2D-simplex FBM (`getElevation`) exactly
12833
- * (same constants, same iteration scheme) so character-interaction code can
12834
- * sample the waterline a body is crossing WITHOUT reading the GPU back.
12835
- * `simpleWaveHeight` is the legacy crossed-sines displacement used by
12836
- * `quality: 'simple'`.
12837
- */
12838
- /** The ported shader's FBM constants (vibe-starter-3d-environment water). */
12839
- const FANCY_WAVE = {
12840
- frequency: 6.7,
12841
- persistence: .3,
12842
- lacunarity: 2.18,
12843
- iterations: 9,
12844
- speed: .5
12845
- };
12846
- /** The source shader's reference amplitude… */
12847
- const REFERENCE_AMPLITUDE = .525;
12848
- /** …which the legacy `waveHeight` default maps onto (0.08 → 0.525). */
12849
- const REFERENCE_WAVE_HEIGHT = .08;
12850
- /** Legacy crossed-sine constants (`quality: 'simple'`). */
12851
- const SIMPLE_WAVE = {
12852
- freqX: .7,
12853
- freqZ: .5,
12854
- cross: .6
12855
- };
12856
- /** GLSL `mod` (sign of divisor, unlike JS `%`). */
12857
- function glslMod(a, n) {
12858
- return a - Math.floor(a / n) * n;
12859
- }
12860
- function fract(x) {
12861
- return x - Math.floor(x);
12862
- }
12863
- /** GLSL `permute`: mod(((x*34)+1)*x, 289). */
12864
- function permute(x) {
12865
- return glslMod((x * 34 + 1) * x, 289);
12866
- }
12867
- const C_X = .211324865405187;
12868
- const C_Y = .366025403784439;
12869
- const C_Z = -.577350269189626;
12870
- const C_W = .024390243902439;
12871
- function snoise2(vx, vy) {
12872
- let ix = Math.floor(vx + (vx + vy) * C_Y);
12873
- let iy = Math.floor(vy + (vx + vy) * C_Y);
12874
- const x0x = vx - ix + (ix + iy) * C_X;
12875
- const x0y = vy - iy + (ix + iy) * C_X;
12876
- const i1x = x0x > x0y ? 1 : 0;
12877
- const i1y = x0x > x0y ? 0 : 1;
12878
- const x12x = x0x + C_X - i1x;
12879
- const x12y = x0y + C_X - i1y;
12880
- const x12z = x0x + C_Z;
12881
- const x12w = x0y + C_Z;
12882
- ix = glslMod(ix, 289);
12883
- iy = glslMod(iy, 289);
12884
- const p0 = permute(permute(iy) + ix);
12885
- const p1 = permute(permute(iy + i1y) + ix + i1x);
12886
- const p2 = permute(permute(iy + 1) + ix + 1);
12887
- let m0 = Math.max(.5 - (x0x * x0x + x0y * x0y), 0);
12888
- let m1 = Math.max(.5 - (x12x * x12x + x12y * x12y), 0);
12889
- let m2 = Math.max(.5 - (x12z * x12z + x12w * x12w), 0);
12890
- m0 = m0 * m0;
12891
- m0 = m0 * m0;
12892
- m1 = m1 * m1;
12893
- m1 = m1 * m1;
12894
- m2 = m2 * m2;
12895
- m2 = m2 * m2;
12896
- const g0x = 2 * fract(p0 * C_W) - 1;
12897
- const g1x = 2 * fract(p1 * C_W) - 1;
12898
- const g2x = 2 * fract(p2 * C_W) - 1;
12899
- const h0 = Math.abs(g0x) - .5;
12900
- const h1 = Math.abs(g1x) - .5;
12901
- const h2 = Math.abs(g2x) - .5;
12902
- const a0 = g0x - Math.floor(g0x + .5);
12903
- const a1 = g1x - Math.floor(g1x + .5);
12904
- const a2 = g2x - Math.floor(g2x + .5);
12905
- m0 *= 1.79284291400159 - .85373472095314 * (a0 * a0 + h0 * h0);
12906
- m1 *= 1.79284291400159 - .85373472095314 * (a1 * a1 + h1 * h1);
12907
- m2 *= 1.79284291400159 - .85373472095314 * (a2 * a2 + h2 * h2);
12908
- const g0 = a0 * x0x + h0 * x0y;
12909
- const g1 = a1 * x12x + h1 * x12y;
12910
- const g2 = a2 * x12z + h2 * x12w;
12911
- return 130 * (m0 * g0 + m1 * g1 + m2 * g2);
12912
- }
12913
- /**
12914
- * Wave elevation at world (x, z) at `time` — the shader's `getElevation`.
12915
- * Deterministic; bounded by `amplitude · (1 − persistence^n) / (1 − persistence)`.
12916
- */
12917
- function waveElevation(x, z, time, p) {
12918
- const norm = Math.max(p.scaleNorm, 1e-6);
12919
- const px = x / norm;
12920
- const pz = z / norm;
12921
- let elevation = 0;
12922
- let amplitude = 1;
12923
- let frequency = p.frequency;
12924
- const phase = time * p.speed;
12925
- for (let i = 0; i < p.iterations; i++) {
12926
- elevation += amplitude * snoise2(px * frequency + phase, pz * frequency + phase);
12927
- amplitude *= p.persistence;
12928
- frequency *= p.lacunarity;
12929
- }
12930
- return elevation * p.amplitude;
12931
- }
12932
- /** The legacy two-crossed-sines height at LOCAL (x, z) — `quality: 'simple'`. */
12933
- function simpleWaveHeight(x, z, time, waveHeight) {
12934
- return waveHeight * (Math.sin(x * SIMPLE_WAVE.freqX + time) + SIMPLE_WAVE.cross * Math.sin(z * SIMPLE_WAVE.freqZ + time * 1.3));
12935
- }
12936
- //#endregion
12937
- //#region src/3d/nodes/water-3d.ts
12938
- const WATER_QUALITIES = ["fancy", "simple"];
12939
- const COLOR_KEYS = [
12940
- "trough",
12941
- "surface",
12942
- "peak"
12943
- ];
12944
- const WATER_PRESET_NAMES = [
12945
- "custom",
12946
- "ocean",
12947
- "pool",
12948
- "lake",
12949
- "pond"
12950
- ];
12951
- /**
12952
- * The four curated water types — `preset: 'ocean' | 'pool' | 'lake' | 'pond'`
12953
- * is THE one-choice dial: each bundle sets every wave/color/clarity/caustics
12954
- * knob to a hand-tuned sea state. A preset value applies ONLY while the prop
12955
- * still sits at its schema default (same convention as the env-sun handoff),
12956
- * so any explicitly authored prop wins — pick the type, then override details
12957
- * to taste.
12958
- *
12959
- * - ocean: traveling swell + whitecaps, wide turquoise→deep-blue absorption
12960
- * - pool: near-flat crystal water, strong above-water caustics on the floor
12961
- * - lake: calm mirror — high reflectivity, murkier green-teal depths
12962
- * - pond: still, mossy, opaque quickly — the garden pond
12963
- */
12964
- const WATER_PRESETS = {
12965
- ocean: {
12966
- waveHeight: .045,
12967
- swell: .34,
12968
- swellWavelength: 52,
12969
- whitecaps: .15,
12970
- absorption: .17,
12971
- opacity: .85,
12972
- detailStrength: .17,
12973
- sunIntensity: .85,
12974
- colors: {
12975
- trough: "#175360",
12976
- surface: "#2f8792",
12977
- peak: "#9bd8d2"
12978
- },
12979
- color: "#2f8792",
12980
- causticsAbove: .3,
12981
- shoreWaves: .7,
12982
- reflectionInterval: 400,
12983
- _look: {
12984
- fresnelScale: 1.15,
12985
- reflectivityMax: .93,
12986
- foamRangeScale: .35,
12987
- foamIntensity: .55
12988
- }
12989
- },
12990
- pool: {
12991
- waveHeight: .006,
12992
- swell: 0,
12993
- whitecaps: 0,
12994
- absorption: .3,
12995
- opacity: .5,
12996
- detailStrength: .16,
12997
- sunIntensity: 1.25,
12998
- colors: {
12999
- trough: "#1e8dbe",
13000
- surface: "#3fb6dc",
13001
- peak: "#bdeef7"
13002
- },
13003
- color: "#3fb6dc",
13004
- causticsAbove: 1,
13005
- reflectionInterval: 600,
13006
- _look: {
13007
- fresnelScale: .85,
13008
- reflectivityMax: .5,
13009
- edgeFade: false
13010
- }
13011
- },
13012
- lake: {
13013
- waveHeight: .028,
13014
- swell: .14,
13015
- swellWavelength: 24,
13016
- whitecaps: 0,
13017
- absorption: .5,
13018
- opacity: .88,
13019
- detailStrength: .3,
13020
- colors: {
13021
- trough: "#12343a",
13022
- surface: "#25565c",
13023
- peak: "#7fb2ab"
13024
- },
13025
- color: "#25565c",
13026
- causticsAbove: .15,
13027
- reflectionInterval: 300,
13028
- _look: {
13029
- fresnelScale: 1.25,
13030
- reflectivityMax: .78
13031
- }
13032
- },
13033
- pond: {
13034
- waveHeight: .012,
13035
- swell: 0,
13036
- whitecaps: 0,
13037
- absorption: .55,
13038
- opacity: .88,
13039
- detailStrength: .2,
13040
- sunIntensity: .9,
13041
- colors: {
13042
- trough: "#24402a",
13043
- surface: "#436b4d",
13044
- peak: "#8fae8e"
13045
- },
13046
- color: "#436b4d",
13047
- causticsAbove: 0,
13048
- reflectionInterval: 800,
13049
- _look: {
13050
- fresnelScale: 1.1,
13051
- reflectivityMax: .68,
13052
- edgeFade: false
13053
- }
13054
- }
13055
- };
13056
- /** Refinement-pass palette — teal-leaning sea blues sampled off the reference
13057
- * calm sea (deep open water ≈ #2b7993, mid teal ≈ #16878a). The old ported
13058
- * palette (#1f5b8a/#52a0d0/#aee3ff) read as saturated pool blue. */
13059
- const FANCY_COLORS = {
13060
- trough: "#135a72",
13061
- surface: "#2a86a0",
13062
- peak: "#a5ddd6"
13063
- };
13064
- /** Color-ramp constants ported as-is; fresnel RETUNED for the atmosphere
13065
- * stage: the source's scale 1.5 / power 0.8 made the surface a ~75% mirror at
13066
- * every angle — invisible against its LDR backdrop, but a milk-white sea once
13067
- * the reflection cube carries the HDR physical sky. Schlick's real exponent
13068
- * (5) with unit scale keeps steep views water-colored and grazing views sky. */
13069
- const FANCY_LOOK = {
13070
- peakThreshold: .08,
13071
- peakTransition: .05,
13072
- troughThreshold: -.01,
13073
- troughTransition: .15,
13074
- fresnelScale: 1,
13075
- fresnelPower: 5
13076
- };
13077
- /** Foam/contact constants. Refinement-pass retune: `range` scales with the
13078
- * water's extent (see foamRangeFor) — SEAS get wide wispy channel foam like
13079
- * the reference, ponds keep a shoreline KISS (a flat 1.4 m range painted a
13080
- * 0.45 m-deep pond white wall-to-wall — the exact v2 regression, relearned).
13081
- * `intensity` drops and the fragment ramp softens — wispy patches, never a
13082
- * bright band. `edgeIntensity` 2 → 0.6: the contact line is a hint. */
13083
- const FOAM_LOOK = {
13084
- color: "#dcebe8",
13085
- intensity: .32,
13086
- rangeMin: .45,
13087
- rangeMax: 1.4,
13088
- scale: .35,
13089
- edgeColor: "#ffffff",
13090
- edgeIntensity: .6,
13091
- edgeWidth: .5
13092
- };
13093
- /** Foam depth-range in meters, scaled by the surface's smaller extent: a
13094
- * garden pond (≤ ~37 m) keeps the tight 0.45 m contact band, open water
13095
- * ramps to 1.4 m so foam spreads across whole shallow channels. */
13096
- function foamRangeFor(w, d) {
13097
- return Math.min(FOAM_LOOK.rangeMax, Math.max(FOAM_LOOK.rangeMin, Math.min(w, d) * .012));
13098
- }
13099
- /** `waveHeight` keeps its role as THE wave dial: the legacy default (0.08)
13100
- * reproduces the source's reference amplitude (0.525) exactly. */
13101
- const WAVE_WORLD_REF = 128;
13102
- const AMPLITUDE_PER_WAVE_HEIGHT = REFERENCE_AMPLITUDE / REFERENCE_WAVE_HEIGHT;
13103
- /** Cube-map reflection resolution (source: reflectionQuality = 256). */
13104
- const REFLECTION_RESOLUTION = 256;
13105
- /** Planar-mirror target size cap (per side) — half the drawing buffer, clamped. */
13106
- const MIRROR_PASS_MAX = 1024;
13107
- /** How far a wavelet's tilt slides the mirror sample, in UV per unit of normal lean. */
13108
- const MIRROR_DISTORT = .06;
13109
- /** Scene pre-pass (depth for absorption/foam + color grab for refraction):
13110
- * half the drawing buffer, clamped — ONE extra scene render per frame. */
13111
- const SCENE_PASS_MAX = 1024;
13112
- /** Grab-clip tolerance above the waterline (meters) — covers depth-buffer
13113
- * precision at the clip plane, nothing more. Deliberately NO wave-height
13114
- * slack: clipping at crest height instead kept each piercing object's
13115
- * [waterline..crest] slice in the grab, and that pale band re-bled through
13116
- * the mip-biased read and the ripple-wiggled offsets as silhouette scribbles
13117
- * — a miniature of the halo disease the clip exists to kill. The cost is
13118
- * crests refracting a hair short at an object's contact (invisible: the
13119
- * object's own deeper column fills the gap with near-identical color). */
13120
- const WATERLINE_CLIP_SLACK = .02;
13121
- /** Fancy vertex density: the FBM displaces per vertex, so density scales with
13122
- * extent (the source's formula degenerated to always-256; ours ramps to it). */
13123
- const FANCY_SEGMENTS_PER_M = 4;
13124
- const FANCY_SEGMENTS_MIN = 32;
13125
- const FANCY_SEGMENTS_MAX = 256;
13126
- /** Simple quality: coarse enough to animate every vertex on the CPU. */
13127
- const SIMPLE_SEGMENTS = 32;
13128
- /** Bodies must rise this far above the waterline to 'exit' (no surface flicker). */
13129
- const SURFACE_HYSTERESIS = .05;
13130
- /** Entry splash amplitude: base + per-(m/s of |vy|), capped. */
13131
- const ENTER_AMP_BASE = .08;
13132
- const ENTER_AMP_PER_VY = .02;
13133
- const ENTER_AMP_MAX = .18;
13134
- /** Leaving the water drags a smaller ring out with you. */
13135
- const EXIT_AMP = .04;
13136
- /** Bodies floating IN water bob a gentle ring every interval (seconds). */
13137
- const BOB_INTERVAL = 1.4;
13138
- const BOB_AMP = .04;
13139
- /** A body striding through water trails a WAKE: a ripple every this-many metres. */
13140
- const WAKE_STEP = .45;
13141
- const WAKE_AMP = .08;
13142
- /** Splash FOAM strengths (0..1) the shaders paint as surface whitewater.
13143
- * Entry foam scales with fall speed (see `entryFoam`); these tag the rest. */
13144
- const EXIT_FOAM = .4;
13145
- /** Wake foam tracks how fast you stride through the water (the "물살"). */
13146
- const WAKE_FOAM_BASE = .22;
13147
- const WAKE_FOAM_PER_SPEED = .09;
13148
- const WAKE_FOAM_MAX = .85;
13149
- /**
13150
- * A water surface on XZ. `quality: 'fancy'` (default) is a commercial-grade
13151
- * water shader — 9-iteration simplex FBM displacement with analytic normals,
13152
- * two animated procedural detail-normal layers (`detailStrength`), a sun
13153
- * specular glint+sheen (`sunDirection`/`sunColor`/`sunIntensity`), Beer's-law
13154
- * depth `absorption` (clear shallows → dark depths), screen-space
13155
- * `refraction` of the submerged scene, depth-texture shoreline `foam` (on by
13156
- * default), a trough/surface/peak color ramp and fresnel-mixed CubeCamera
13157
- * reflections (re-rendered every `reflectionInterval` ms). `quality:
13158
- * 'simple'` is the cheap CPU sine-wave material (low-end fallback; also the
13159
- * deterministic headless path).
13160
- *
13161
- * Per-frame GPU budget for fancy (beyond drawing the surface): ONE half-res
13162
- * scene pre-pass that feeds depth (absorption/foam/soft intersections) AND
13163
- * the refraction color grab; the cube reflection stays on its `1000 ms`
13164
- * throttle.
13165
- *
13166
- * Character interaction (`interaction: true`): each update the node scans the
13167
- * tree for CharacterBody3D/RigidBody3D, compares their world y against the
13168
- * waterline at their xz (wave offset included, CPU twin of the shader), emits
13169
- * `entered`/`exited` with the body node, and pushes ripple impulses into a
13170
- * fixed 8-slot ring buffer both shaders turn into expanding ring waves. The
13171
- * surface has no bottom — anything below the waterline inside the XZ
13172
- * footprint counts as in-water (pair with `Area3D` for volumetric gameplay).
13173
- *
13174
- * GPU-only features (reflection/foam) run through the renderer's
13175
- * `_onRender3D` hand-off — headless runs simply never invoke it.
13176
- */
13177
- var Water3D = class Water3D extends Node3D {
13178
- static typeName = "Water3D";
13179
- static signals = ["entered", "exited"];
13180
- static props = {
13181
- size: { default: [40, 40] },
13182
- color: { default: "#2a6fbe" },
13183
- opacity: { default: .8 },
13184
- renderOrder: { default: 1 },
13185
- waveHeight: { default: .04 },
13186
- waveSpeed: { default: 1 },
13187
- quality: {
13188
- default: "fancy",
13189
- options: WATER_QUALITIES
13190
- },
13191
- colors: { default: {} },
13192
- reflection: { default: true },
13193
- reflectionInterval: { default: 1e3 },
13194
- mirror: { default: false },
13195
- swellSteepness: { default: .35 },
13196
- foam: { default: true },
13197
- interaction: { default: true },
13198
- splash: { default: true },
13199
- sunDirection: { default: [
13200
- .5,
13201
- .8,
13202
- .3
13203
- ] },
13204
- sunColor: { default: "#fff5d6" },
13205
- sunIntensity: { default: 1 },
13206
- detailStrength: { default: .26 },
13207
- absorption: { default: .15 },
13208
- refraction: { default: true },
13209
- underwater: { default: true },
13210
- swell: { default: 0 },
13211
- swellDirectionDeg: { default: 0 },
13212
- swellWavelength: { default: 30 },
13213
- whitecaps: { default: 0 },
13214
- preset: {
13215
- default: "custom",
13216
- options: WATER_PRESET_NAMES
13217
- },
13218
- causticsAbove: { default: 0 },
13219
- shoreWaves: { default: 0 }
13220
- };
13221
- /** [width, depth] in meters (the surface lies on XZ). */
13222
- size = [40, 40];
13223
- /** Legacy single hue: simple-quality color; tints the fancy ramp when set. */
13224
- color = "#2a6fbe";
13225
- opacity = .8;
13226
- /** Draw after opaque geometry (overrides Node3D's 0 default). */
13227
- renderOrder = 1;
13228
- /** Wave intensity dial, meters-ish. 0 = mirror-flat; 0.04 (default) reads
13229
- * as a calm sea; 0.08 = the noisier ported-source look. */
13230
- waveHeight = .04;
13231
- waveSpeed = 1;
13232
- /** 'fancy' shader water (default) | 'simple' cheap CPU sine material. */
13233
- quality = "fancy";
13234
- /** Fancy ramp overrides: `{trough?, surface?, peak?}` hex strings. */
13235
- colors = {};
13236
- /** CubeCamera reflections (fancy only; needs a live renderer). */
13237
- reflection = true;
13238
- /**
13239
- * TRUE planar reflection: the scene re-rendered from the camera mirrored
13240
- * across this surface, sampled per fragment. A cube map can only carry
13241
- * distant content — the shoreline trees, the moored boat, the player at the
13242
- * water's edge only appear in the water with this on. Costs one extra scene
13243
- * render per frame, so it is off by default; a still lake earns it.
13244
- */
13245
- mirror = false;
13246
- /** ms between reflection (and foam depth) re-renders. */
13247
- reflectionInterval = 1e3;
13248
- /** Depth-texture shoreline foam — rides the always-on fancy scene pass. */
13249
- foam = true;
13250
- /** Bodies crossing the surface emit signals and raise ripples. */
13251
- interaction = true;
13252
- /** Splash WHITEWATER: the shaders paint expanding surface foam where bodies
13253
- * interact — a "풍덩" froth bloom on entry (scaled by fall speed) and a foam
13254
- * trail ("물살") behind a body wading/running through. Needs `interaction`. */
13255
- splash = true;
13256
- /** Fixed sun for the specular glint — match the scene's key light. */
13257
- sunDirection = [
13258
- .5,
13259
- .8,
13260
- .3
13261
- ];
13262
- /** Sun glint/sheen tint. */
13263
- sunColor = "#fff5d6";
13264
- /** Sun glint/sheen strength — 0 disables the specular entirely. */
13265
- sunIntensity = 1;
13266
- /** Animated detail-normal strength — the close-up liquid feel (0 = off). */
13267
- detailStrength = .26;
13268
- /** Beer's-law absorption per meter of water depth (0 = no depth tinting).
13269
- * Per-channel under the hood: red dies ~3× faster than blue, so shallows
13270
- * over a bright bottom read turquoise before fading to deep blue. */
13271
- absorption = .15;
13272
- /** Screen-space refraction of the submerged scene (fancy only). */
13273
- refraction = true;
13274
- /**
13275
- * Submerged-camera look: when the camera dips below this surface (inside its
13276
- * XZ footprint) the scene switches to an underwater fog + tint for that frame
13277
- * — the murky, limited-visibility feel of being underwater. `true` (default)
13278
- * derives the murk from `color`; `false` disables it; an object
13279
- * `{ color?, visibility? }` overrides the fog hue / view distance (meters).
13280
- */
13281
- underwater = true;
13282
- /**
13283
- * Directional traveling SWELL amplitude in meters (fancy only; 0 = off, the
13284
- * legacy isotropic look). Three peaked wave trains march across the surface
13285
- * at real deep-water speeds — THE open-ocean dial: 0.15 = lively lake chop,
13286
- * 0.4 = coastal sea, 0.8 = heavy weather. Crests feed the color ramp,
13287
- * `whitecaps` and the shore foam automatically.
13288
- */
13289
- swell = 0;
13290
- /** Compass direction the swell TRAVELS toward, degrees (0 = +z, 90 = +x). */
13291
- swellDirectionDeg = 0;
13292
- /** Primary swell wavelength in meters (the two crossing trains derive). */
13293
- swellWavelength = 30;
13294
- /**
13295
- * GERSTNER pinch on the swell: 0 keeps the legacy height-only trains, 1 is
13296
- * maximum trochoid (crests narrow to a peak, troughs go broad). It also
13297
- * decides where whitecaps break — the surface's Jacobian collapses on a
13298
- * folding crest, and that is where foam belongs.
13299
- */
13300
- swellSteepness = .35;
13301
- /** Open-water whitecap foam on the tallest crests, 0–1 (fancy only). Works
13302
- * best with `swell` — the caps ride the swell's crest lines. */
13303
- whitecaps = 0;
13304
- /**
13305
- * THE one-choice water type: 'ocean' | 'pool' | 'lake' | 'pond' bundles
13306
- * every wave/color/clarity/caustics knob into a hand-tuned sea state
13307
- * ('custom', the default, changes nothing). A preset value applies ONLY
13308
- * where a prop still sits at its schema default — set the type first, then
13309
- * override any detail prop to taste and your value wins.
13310
- */
13311
- preset = "custom";
13312
- /**
13313
- * Above-water CAUSTICS intensity (0 = off): the dancing light web on the
13314
- * submerged bottom, seen THROUGH the surface — the pool / tropical-shallows
13315
- * look. Needs `refraction` (fancy quality). Presets set it; override freely.
13316
- */
13317
- causticsAbove = 0;
13318
- /**
13319
- * Traveling SHORE BREAKERS, 0–1 (fancy + depth pre-pass only): foam fronts
13320
- * that roll down the depth gradient and break toward every beach — the
13321
- * fronts follow the shore's depth contours automatically. Presets: ocean
13322
- * ships 0.7; 0 (default) = off.
13323
- */
13324
- shoreWaves = 0;
13325
- /** Preset-layered prop read (see {@link WATER_PRESETS}): the preset value
13326
- * applies only while the prop sits at its schema default. */
13327
- rp(key) {
13328
- const bundle = WATER_PRESETS[this.preset];
13329
- const self = this;
13330
- if (bundle && key in bundle) {
13331
- const def = Water3D.props[key]?.default;
13332
- const cur = self[key];
13333
- if (typeof cur === "object" && cur !== null ? JSON.stringify(cur) === JSON.stringify(def) : cur === def) return bundle[key];
13334
- }
13335
- return self[key];
13336
- }
13337
- /** Per-preset internal look constants (fresnel curve, reflection ceiling). */
13338
- presetLook() {
13339
- const look = WATER_PRESETS[this.preset]?._look;
13340
- return {
13341
- fresnelScale: look?.fresnelScale ?? FANCY_LOOK.fresnelScale,
13342
- reflectivityMax: look?.reflectivityMax ?? .6,
13343
- edgeFade: look?.edgeFade ?? true,
13344
- foamRangeScale: look?.foamRangeScale ?? 1,
13345
- foamIntensity: look?.foamIntensity ?? FOAM_LOOK.intensity
13346
- };
13347
- }
13348
- /** Loader hook: degenerate planes and bad enums fail at LOAD, not at render. */
13349
- static validateJson(node) {
13350
- const w = node;
13351
- if (!((w.size[0] ?? 0) > 0) || !((w.size[1] ?? 0) > 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' size must be positive [width, depth] meters, got ${JSON.stringify(w.size)}.`, { prop: "size" });
13352
- if (!WATER_QUALITIES.includes(w.quality)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' quality must be one of [${WATER_QUALITIES.join(", ")}], got '${w.quality}'.`, {
13353
- prop: "quality",
13354
- validOptions: WATER_QUALITIES
13355
- });
13356
- for (const [key, value] of Object.entries(w.colors)) {
13357
- if (!COLOR_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' colors has unknown key '${key}'. Valid keys: [${COLOR_KEYS.join(", ")}].`, {
13358
- prop: "colors",
13359
- validOptions: COLOR_KEYS
13360
- });
13361
- if (typeof value !== "string") throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' colors.${key} must be a hex color string, got ${JSON.stringify(value)}.`, { prop: "colors" });
13362
- }
13363
- if (!(w.reflectionInterval > 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' reflectionInterval must be > 0 ms, got ${w.reflectionInterval}.`, { prop: "reflectionInterval" });
13364
- const sun = w.sunDirection;
13365
- 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" });
13366
- if (!(w.sunIntensity >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' sunIntensity must be >= 0, got ${w.sunIntensity}.`, { prop: "sunIntensity" });
13367
- if (!(w.detailStrength >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' detailStrength must be >= 0, got ${w.detailStrength}.`, { prop: "detailStrength" });
13368
- 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" });
13369
- 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" });
13370
- if (!(w.swellWavelength >= 2)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' swellWavelength must be >= 2 meters, got ${JSON.stringify(w.swellWavelength)}.`, { prop: "swellWavelength" });
13371
- if (!(w.whitecaps >= 0) || !(w.whitecaps <= 1)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' whitecaps must be in [0, 1], got ${JSON.stringify(w.whitecaps)}.`, { prop: "whitecaps" });
13372
- if (!WATER_PRESET_NAMES.includes(w.preset)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' preset must be one of [${WATER_PRESET_NAMES.join(", ")}], got '${w.preset}'.`, {
13373
- prop: "preset",
13374
- validOptions: WATER_PRESET_NAMES
13375
- });
13376
- if (!(w.shoreWaves >= 0) || !(w.shoreWaves <= 1)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' shoreWaves must be in [0, 1] (0 = off), got ${JSON.stringify(w.shoreWaves)}.`, { prop: "shoreWaves" });
13377
- if (!(w.causticsAbove >= 0) || !(w.causticsAbove <= 3)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' causticsAbove must be in [0, 3] (0 = off), got ${JSON.stringify(w.causticsAbove)}.`, { prop: "causticsAbove" });
13378
- const uw = w.underwater;
13379
- if (typeof uw !== "boolean") {
13380
- if (typeof uw !== "object" || uw === null || Array.isArray(uw)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' underwater must be true, false or an object ({ color?, visibility? }), got ${JSON.stringify(uw)}.`, { prop: "underwater" });
13381
- const o = uw;
13382
- if (o.color !== void 0 && typeof o.color !== "string") throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' underwater.color must be a hex color string, got ${JSON.stringify(o.color)}.`, { prop: "underwater" });
13383
- if (o.visibility !== void 0 && !(o.visibility > 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' underwater.visibility must be > 0 meters, got ${JSON.stringify(o.visibility)}.`, { prop: "underwater" });
13384
- }
13385
- }
13386
- time = 0;
13387
- surfaceKey = "";
13388
- /** @internal Live ripple ring buffer (≤8) — tests and tools read this. */
13389
- _ripples = [];
13390
- inWater = /* @__PURE__ */ new Set();
13391
- bobTimers = /* @__PURE__ */ new Map();
13392
- /** Last position a wake ripple was dropped, per in-water body (for the trail). */
13393
- wadeAnchors = /* @__PURE__ */ new Map();
13394
- _scratchA = {
13395
- x: 0,
13396
- y: 0,
13397
- z: 0
13398
- };
13399
- _scratchB = {
13400
- x: 0,
13401
- y: 0,
13402
- z: 0
13403
- };
13404
- _bodies = [];
13405
- cubeTarget = null;
13406
- cubeCamera = null;
13407
- /** Reflection near plane (max half extent) — recreate the camera on resize. */
13408
- reflectionNear = 0;
13409
- lastReflectionAt = 0;
13410
- /** Per-frame scene pre-pass: depth (absorption/foam) + color grab (refraction). */
13411
- scenePassTarget = null;
13412
- mirrorTarget = null;
13413
- mirrorCamera = new PerspectiveCamera();
13414
- mirrorMatrix = new Matrix4();
13415
- _createObject3D() {
13416
- return new Mesh();
13417
- }
13418
- update(dt) {
13419
- this.time += dt * this.waveSpeed;
13420
- ageRipples(this._ripples, dt);
13421
- if (this.interaction) this.stepInteraction(dt);
13422
- }
13423
- _syncObject3D() {
13424
- super._syncObject3D();
13425
- const mesh = this._ensureObject3D();
13426
- this.rebuildSurfaceIfNeeded(mesh);
13427
- if (this.quality === "simple") this.syncSimple(mesh);
13428
- else this.syncFancy(mesh);
13429
- }
13430
- /**
13431
- * @internal Renderer hand-off (see {@link RenderContext3D}): re-render the
13432
- * CubeCamera reflection every `reflectionInterval` ms, plus ONE per-frame
13433
- * half-res scene pre-pass whose depth texture drives Beer's-law absorption,
13434
- * shoreline foam and soft intersections, and whose color texture is the
13435
- * refraction grab — one extra render feeds all three.
13436
- */
13437
- _onRender3D(ctx) {
13438
- const mesh = this._ensureObject3D();
13439
- const material = mesh.material;
13440
- ctx.camera.updateWorldMatrix(true, false);
13441
- ctx.camera.getWorldPosition(underwaterCamScratch);
13442
- if (this.underwaterAt(underwaterCamScratch.x, underwaterCamScratch.y, underwaterCamScratch.z)) {
13443
- if (material.isShaderMaterial) {
13444
- const u = material.uniforms;
13445
- if (u.uUseSceneDepth) u.uUseSceneDepth.value = 0;
13446
- if (u.uUseRefraction) u.uUseRefraction.value = 0;
13447
- if (u.uUseMirror) u.uUseMirror.value = 0;
13448
- }
13449
- return;
13450
- }
13451
- if (this.quality !== "fancy" || !material.isShaderMaterial) return;
13452
- const halfDiagonal = Math.hypot((this.size[0] ?? 1) / 2, (this.size[1] ?? 1) / 2);
13453
- if (!sphereInCameraFrustum(ctx.camera, worldTranslationOf(this), halfDiagonal + 1)) return;
13454
- const u = material.uniforms;
13455
- const now = Date.now();
13456
- if (this.reflection) {
13457
- if (!this.cubeTarget) this.cubeTarget = new WebGLCubeRenderTarget(REFLECTION_RESOLUTION, {
13458
- generateMipmaps: true,
13459
- minFilter: LinearMipmapLinearFilter,
13460
- type: HalfFloatType
13461
- });
13462
- const near = Math.max((this.size[0] ?? 1) / 2, (this.size[1] ?? 1) / 2);
13463
- if (!this.cubeCamera || this.reflectionNear !== near) {
13464
- this.reflectionNear = near;
13465
- this.cubeCamera = new CubeCamera(near, 1e3, this.cubeTarget);
13466
- this.lastReflectionAt = 0;
13467
- }
13468
- if (this.lastReflectionAt === 0 || now - this.lastReflectionAt >= this.rp("reflectionInterval")) {
13469
- this.lastReflectionAt = now;
13470
- const center = worldTranslationOf(this);
13471
- const cam = this.cubeCamera;
13472
- cam.position.set(center.x, center.y + .1, center.z);
13473
- const wasVisible = mesh.visible;
13474
- mesh.visible = false;
13475
- cam.update(ctx.gl, ctx.scene);
13476
- mesh.visible = wasVisible;
13477
- }
13478
- if (u.uEnvironmentMap && u.uUseEnvironmentMap) {
13479
- u.uEnvironmentMap.value = this.cubeTarget.texture;
13480
- u.uUseEnvironmentMap.value = 1;
13481
- }
13482
- }
13483
- if (this.mirror && u.uMirrorMap && u.uMirrorMatrix && u.uUseMirror) {
13484
- ctx.gl.getDrawingBufferSize(drawingBufferScratch);
13485
- const mirrorW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), MIRROR_PASS_MAX));
13486
- const mirrorH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), MIRROR_PASS_MAX));
13487
- if (this.mirrorTarget && (this.mirrorTarget.width !== mirrorW || this.mirrorTarget.height !== mirrorH)) {
13488
- this.mirrorTarget.dispose();
13489
- this.mirrorTarget = null;
13490
- }
13491
- if (!this.mirrorTarget) this.mirrorTarget = new WebGLRenderTarget(mirrorW, mirrorH, {
13492
- depthBuffer: true,
13493
- stencilBuffer: false,
13494
- type: HalfFloatType
13495
- });
13496
- const surfaceY = worldTranslationOf(this).y;
13497
- const cam = ctx.camera;
13498
- cam.getWorldPosition(mirrorEyeScratch);
13499
- cam.getWorldQuaternion(mirrorQuatScratch);
13500
- mirrorTargetScratch.set(0, 0, -1).applyQuaternion(mirrorQuatScratch).add(mirrorEyeScratch);
13501
- mirrorUpScratch.set(0, 1, 0).applyQuaternion(mirrorQuatScratch);
13502
- mirrorEyeScratch.y = 2 * surfaceY - mirrorEyeScratch.y;
13503
- mirrorTargetScratch.y = 2 * surfaceY - mirrorTargetScratch.y;
13504
- mirrorUpScratch.y = -mirrorUpScratch.y;
13505
- const mirrorCam = this.mirrorCamera;
13506
- mirrorCam.fov = cam.fov ?? 50;
13507
- mirrorCam.aspect = cam.aspect ?? mirrorW / mirrorH;
13508
- mirrorCam.near = cam.near ?? .1;
13509
- mirrorCam.far = cam.far ?? 1e3;
13510
- mirrorCam.position.copy(mirrorEyeScratch);
13511
- mirrorCam.up.copy(mirrorUpScratch);
13512
- mirrorCam.lookAt(mirrorTargetScratch);
13513
- mirrorCam.updateMatrixWorld(true);
13514
- mirrorCam.updateProjectionMatrix();
13515
- this.mirrorMatrix.set(.5, 0, 0, .5, 0, .5, 0, .5, 0, 0, .5, .5, 0, 0, 0, 1);
13516
- this.mirrorMatrix.multiply(mirrorCam.projectionMatrix);
13517
- this.mirrorMatrix.multiply(mirrorCam.matrixWorldInverse);
13518
- const prevMirrorTarget = ctx.gl.getRenderTarget();
13519
- const prevMirrorClip = ctx.gl.clippingPlanes;
13520
- const wasMirrorVisible = mesh.visible;
13521
- mesh.visible = false;
13522
- mirrorClipPlane.constant = -(surfaceY - WATERLINE_CLIP_SLACK);
13523
- ctx.gl.clippingPlanes = mirrorClipPlanes;
13524
- ctx.gl.setRenderTarget(this.mirrorTarget);
13525
- ctx.gl.clear(true, true, false);
13526
- ctx.gl.render(ctx.scene, mirrorCam);
13527
- ctx.gl.setRenderTarget(prevMirrorTarget);
13528
- ctx.gl.clippingPlanes = prevMirrorClip;
13529
- mesh.visible = wasMirrorVisible;
13530
- u.uMirrorMap.value = this.mirrorTarget.texture;
13531
- u.uMirrorMatrix.value.copy(this.mirrorMatrix);
13532
- u.uUseMirror.value = 1;
13533
- } else if (u.uUseMirror) u.uUseMirror.value = 0;
13534
- ctx.gl.getDrawingBufferSize(drawingBufferScratch);
13535
- const passW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), SCENE_PASS_MAX));
13536
- const passH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), SCENE_PASS_MAX));
13537
- if (this.scenePassTarget && (this.scenePassTarget.width !== passW || this.scenePassTarget.height !== passH)) {
13538
- this.scenePassTarget.depthTexture?.dispose();
13539
- this.scenePassTarget.dispose();
13540
- this.scenePassTarget = null;
13541
- }
13542
- if (!this.scenePassTarget) {
13543
- const depthTexture = new DepthTexture(passW, passH);
13544
- depthTexture.type = FloatType;
13545
- this.scenePassTarget = new WebGLRenderTarget(passW, passH, {
13546
- depthTexture,
13547
- depthBuffer: true,
13548
- stencilBuffer: false,
13549
- generateMipmaps: true,
13550
- minFilter: LinearMipmapLinearFilter
13551
- });
13552
- }
13553
- const prevTarget = ctx.gl.getRenderTarget();
13554
- const prevAutoClear = ctx.gl.autoClear;
13555
- const prevClippingPlanes = ctx.gl.clippingPlanes;
13556
- const wasVisible = mesh.visible;
13557
- mesh.visible = false;
13558
- const waterY = worldTranslationOf(this).y;
13559
- grabClipPlane.constant = waterY + WATERLINE_CLIP_SLACK;
13560
- ctx.gl.clippingPlanes = grabClipPlanes;
13561
- ctx.gl.setRenderTarget(this.scenePassTarget);
13562
- ctx.gl.autoClear = true;
13563
- ctx.gl.clear(true, true, false);
13564
- ctx.gl.render(ctx.scene, ctx.camera);
13565
- depthOnlyClipPlane.constant = -(waterY - WATERLINE_CLIP_SLACK);
13566
- ctx.gl.clippingPlanes = depthOnlyClipPlanes;
13567
- const prevOverride = ctx.scene.overrideMaterial;
13568
- const prevBackground = ctx.scene.background;
13569
- ctx.scene.overrideMaterial = depthOnlyMaterial;
13570
- ctx.scene.background = null;
13571
- ctx.gl.autoClear = false;
13572
- ctx.gl.render(ctx.scene, ctx.camera);
13573
- ctx.scene.overrideMaterial = prevOverride;
13574
- ctx.scene.background = prevBackground;
13575
- ctx.gl.setRenderTarget(prevTarget);
13576
- ctx.gl.autoClear = prevAutoClear;
13577
- ctx.gl.clippingPlanes = prevClippingPlanes;
13578
- mesh.visible = wasVisible;
13579
- if (u.uDepthTexture && u.uSceneColor && u.uCameraNear && u.uCameraFar && u.uUseSceneDepth) {
13580
- u.uDepthTexture.value = this.scenePassTarget.depthTexture;
13581
- u.uSceneColor.value = this.scenePassTarget.texture;
13582
- u.uCameraNear.value = ctx.camera.near;
13583
- u.uCameraFar.value = ctx.camera.far;
13584
- u.uUseSceneDepth.value = 1;
13585
- if (u.uUseRefraction) u.uUseRefraction.value = this.refraction ? 1 : 0;
13586
- if (u.uEnableFoam) u.uEnableFoam.value = this.foam ? 1 : 0;
13587
- }
13588
- }
13589
- /**
13590
- * Camera-submersion query (the renderer asks every water node each frame): the
13591
- * resolved underwater look + this surface's Y when the camera EYE is below
13592
- * THIS surface inside its XZ footprint, else null. Deliberately gated to the
13593
- * eye being underwater (not merely near the surface) so wading with the camera
13594
- * above the water never tints the view. Cheap above water (geometric test first).
13595
- */
13596
- underwaterAt(camX, camY, camZ) {
13597
- if (this.underwater === false) return null;
13598
- const center = worldTranslationOf(this, this._scratchA);
13599
- if (!isCameraUnderwater(camX, camY, camZ, {
13600
- centerX: center.x,
13601
- centerY: center.y,
13602
- centerZ: center.z,
13603
- halfW: (this.size[0] ?? 1) / 2,
13604
- halfD: (this.size[1] ?? 1) / 2
13605
- }, UNDERWATER_MARGIN)) return null;
13606
- const cfg = resolveUnderwater(this.underwater, this.rp("color"));
13607
- return cfg.enabled ? {
13608
- ...cfg,
13609
- surfaceY: center.y
13610
- } : null;
13611
- }
13612
- free() {
13613
- const mesh = this._ensureObject3D();
13614
- mesh.geometry?.dispose();
13615
- mesh.material?.dispose?.();
13616
- this.cubeTarget?.dispose();
13617
- this.cubeTarget = null;
13618
- this.cubeCamera = null;
13619
- this.scenePassTarget?.depthTexture?.dispose();
13620
- this.scenePassTarget?.dispose();
13621
- this.scenePassTarget = null;
13622
- this.mirrorTarget?.dispose();
13623
- this.mirrorTarget = null;
13624
- super.free();
13625
- }
13626
- /** Swap geometry+material only when a surface-shaping prop changed. */
13627
- rebuildSurfaceIfNeeded(mesh) {
13628
- const key = JSON.stringify([this.size, this.quality]);
13629
- if (key === this.surfaceKey && mesh.geometry.getAttribute("position")) return;
13630
- this.surfaceKey = key;
13631
- mesh.geometry?.dispose();
13632
- mesh.material?.dispose?.();
13633
- const w = this.size[0] ?? 1;
13634
- const d = this.size[1] ?? 1;
13635
- if (this.quality === "simple") {
13636
- mesh.geometry = buildWaterGeometry(w, d, SIMPLE_SEGMENTS);
13637
- mesh.material = this.buildLakeMaterial();
13638
- } else {
13639
- mesh.geometry = buildWaterGeometry(w, d, Math.min(Math.max(Math.round(Math.max(w, d) * FANCY_SEGMENTS_PER_M), FANCY_SEGMENTS_MIN), FANCY_SEGMENTS_MAX));
13640
- mesh.material = this.buildFancyMaterial();
13641
- }
13642
- }
13643
- buildFancyMaterial() {
13644
- return new ShaderMaterial({
13645
- vertexShader: WATER_VERT,
13646
- fragmentShader: WATER_FRAG,
13647
- fog: true,
13648
- uniforms: {
13649
- fogColor: { value: new Color("#ffffff") },
13650
- fogNear: { value: 1 },
13651
- fogFar: { value: 1e3 },
13652
- fogDensity: { value: 25e-5 },
13653
- uTime: { value: 0 },
13654
- uOpacity: { value: this.rp("opacity") },
13655
- uEnvironmentMap: { value: null },
13656
- uUseEnvironmentMap: { value: 0 },
13657
- uMirrorMap: { value: null },
13658
- uMirrorMatrix: { value: new Matrix4() },
13659
- uUseMirror: { value: 0 },
13660
- uMirrorDistort: { value: MIRROR_DISTORT },
13661
- uWavesAmplitude: { value: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT },
13662
- uWavesSpeed: { value: FANCY_WAVE.speed },
13663
- uWavesFrequency: { value: FANCY_WAVE.frequency },
13664
- uWavesPersistence: { value: FANCY_WAVE.persistence },
13665
- uWavesLacunarity: { value: FANCY_WAVE.lacunarity },
13666
- uWavesIterations: { value: FANCY_WAVE.iterations },
13667
- uTroughColor: { value: new Color(FANCY_COLORS.trough) },
13668
- uSurfaceColor: { value: new Color(FANCY_COLORS.surface) },
13669
- uPeakColor: { value: new Color(FANCY_COLORS.peak) },
13670
- uPeakThreshold: { value: FANCY_LOOK.peakThreshold },
13671
- uPeakTransition: { value: FANCY_LOOK.peakTransition },
13672
- uTroughThreshold: { value: FANCY_LOOK.troughThreshold },
13673
- uTroughTransition: { value: FANCY_LOOK.troughTransition },
13674
- uFresnelScale: { value: FANCY_LOOK.fresnelScale },
13675
- uFresnelPower: { value: FANCY_LOOK.fresnelPower },
13676
- uScale: { value: new Vector3(1, 1, 1) },
13677
- uWaterCenter: { value: new Vector3() },
13678
- uRipples: { value: Array.from({ length: 8 }, () => new Vector4()) },
13679
- uRippleFoam: { value: Array.from({ length: 8 }, () => 0) },
13680
- uSplashFoam: { value: this.splash ? 1 : 0 },
13681
- uSunDirection: { value: new Vector3(.5, .8, .3) },
13682
- uSunColor: { value: new Color(this.sunColor) },
13683
- uSunIntensity: { value: this.rp("sunIntensity") },
13684
- uDetailStrength: { value: this.rp("detailStrength") },
13685
- uAbsorption: { value: this.rp("absorption") },
13686
- uUseSceneDepth: { value: 0 },
13687
- uUseRefraction: { value: 0 },
13688
- uSceneColor: { value: null },
13689
- uEnableFoam: { value: 0 },
13690
- uFoamColor: { value: new Color(FOAM_LOOK.color) },
13691
- uFoamIntensity: { value: FOAM_LOOK.intensity },
13692
- uFoamRange: { value: foamRangeFor(this.size[0] ?? 1, this.size[1] ?? 1) },
13693
- uFoamScale: { value: FOAM_LOOK.scale },
13694
- uDepthTexture: { value: null },
13695
- uCameraNear: { value: .1 },
13696
- uCameraFar: { value: 1e3 },
13697
- uEdgeColor: { value: new Color(FOAM_LOOK.edgeColor) },
13698
- uEdgeIntensity: { value: FOAM_LOOK.edgeIntensity },
13699
- uEdgeWidth: { value: FOAM_LOOK.edgeWidth },
13700
- uSwellAmplitude: { value: this.rp("swell") },
13701
- uSwellDir: { value: new Vector2(0, 1) },
13702
- uSwellWavelength: { value: this.rp("swellWavelength") },
13703
- uSwellSteepness: { value: this.rp("swellSteepness") },
13704
- uWhitecaps: { value: this.rp("whitecaps") },
13705
- uWaveEnvelope: { value: 0 },
13706
- uCausticsAbove: { value: this.rp("causticsAbove") },
13707
- uShoreWaves: { value: this.rp("shoreWaves") },
13708
- uReflectivityMax: { value: .6 },
13709
- uEdgeFadeOn: { value: 1 }
13710
- },
13711
- transparent: true,
13712
- depthTest: true,
13713
- depthWrite: true,
13714
- side: DoubleSide
13715
- });
13716
- }
13717
- /** The cheap lake ShaderMaterial — fresnel sky tint + ripple normals + sun
13718
- * glint + edge fade, NO scene re-renders (see lake.ts). */
13719
- buildLakeMaterial() {
13720
- return new ShaderMaterial({
13721
- vertexShader: LAKE_VERT,
13722
- fragmentShader: LAKE_FRAG,
13723
- fog: true,
13724
- uniforms: {
13725
- fogColor: { value: new Color("#ffffff") },
13726
- fogNear: { value: 1 },
13727
- fogFar: { value: 1e3 },
13728
- fogDensity: { value: 25e-5 },
13729
- uTime: { value: 0 },
13730
- uOpacity: { value: this.rp("opacity") },
13731
- uColor: { value: new Color(this.rp("color")) },
13732
- uSurfaceColor: { value: new Color(this.rp("color")) },
13733
- uSkyColor: { value: new Color(this.rp("color")) },
13734
- uSunDirection: { value: new Vector3(.5, .8, .3).normalize() },
13735
- uSunColor: { value: new Color(this.sunColor) },
13736
- uSunIntensity: { value: this.rp("sunIntensity") },
13737
- uDetailStrength: { value: this.rp("detailStrength") },
13738
- uFresnelPower: { value: FANCY_LOOK.fresnelPower },
13739
- uScale: { value: new Vector3(1, 1, 1) },
13740
- uWaterCenter: { value: new Vector3() },
13741
- uRipples: { value: Array.from({ length: 8 }, () => new Vector4()) },
13742
- uRippleFoam: { value: Array.from({ length: 8 }, () => 0) },
13743
- uSplashFoam: { value: this.splash ? 1 : 0 },
13744
- uFoamColor: { value: new Color(FOAM_LOOK.color) }
13745
- },
13746
- transparent: true,
13747
- depthTest: true,
13748
- depthWrite: false,
13749
- side: DoubleSide
13750
- });
13751
- }
13752
- syncFancy(mesh) {
13753
- const u = mesh.material.uniforms;
13754
- const w = this.size[0] ?? 1;
13755
- const d = this.size[1] ?? 1;
13756
- const center = worldTranslationOf(this);
13757
- if (u.uTime) u.uTime.value = this.time;
13758
- if (u.uOpacity) u.uOpacity.value = this.rp("opacity");
13759
- if (u.uWavesAmplitude) u.uWavesAmplitude.value = this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT;
13760
- (u.uScale?.value)?.set(w / 2, d / 2, d / 2);
13761
- (u.uWaterCenter?.value)?.set(center.x, center.y, center.z);
13762
- const palette = this.resolvePalette();
13763
- (u.uTroughColor?.value)?.copy(palette.trough);
13764
- (u.uSurfaceColor?.value)?.copy(palette.surface);
13765
- (u.uPeakColor?.value)?.copy(palette.peak);
13766
- (u.uSunDirection?.value)?.set(this.sunDirection[0] ?? 0, this.sunDirection[1] ?? 1, this.sunDirection[2] ?? 0).normalize();
13767
- (u.uSunColor?.value)?.set(this.sunColor);
13768
- if (u.uSunIntensity) u.uSunIntensity.value = this.rp("sunIntensity");
13769
- if (u.uDetailStrength) u.uDetailStrength.value = this.rp("detailStrength");
13770
- if (u.uAbsorption) u.uAbsorption.value = this.rp("absorption");
13771
- if (u.uSwellAmplitude) u.uSwellAmplitude.value = this.rp("swell");
13772
- if (u.uSwellWavelength) u.uSwellWavelength.value = this.rp("swellWavelength");
13773
- if (u.uSwellSteepness) u.uSwellSteepness.value = this.rp("swellSteepness");
13774
- if (u.uWhitecaps) u.uWhitecaps.value = this.rp("whitecaps");
13775
- if (u.uCausticsAbove) u.uCausticsAbove.value = this.rp("causticsAbove");
13776
- if (u.uShoreWaves) u.uShoreWaves.value = this.rp("shoreWaves");
13777
- const look = this.presetLook();
13778
- if (u.uFresnelScale) u.uFresnelScale.value = look.fresnelScale;
13779
- if (u.uReflectivityMax) u.uReflectivityMax.value = look.reflectivityMax;
13780
- if (u.uEdgeFadeOn) u.uEdgeFadeOn.value = look.edgeFade ? 1 : 0;
13781
- if (u.uFoamRange) u.uFoamRange.value = foamRangeFor(w, d) * look.foamRangeScale;
13782
- if (u.uFoamIntensity) u.uFoamIntensity.value = look.foamIntensity;
13783
- const swellRad = this.swellDirectionDeg * Math.PI / 180;
13784
- (u.uSwellDir?.value)?.set(Math.sin(swellRad), Math.cos(swellRad));
13785
- if (u.uWaveEnvelope) u.uWaveEnvelope.value = this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT * 1.5 + this.rp("swell") * 1.03;
13786
- if (!this.reflection && u.uUseEnvironmentMap) u.uUseEnvironmentMap.value = 0;
13787
- if (!this.foam && u.uEnableFoam) u.uEnableFoam.value = 0;
13788
- if (!this.refraction && u.uUseRefraction) u.uUseRefraction.value = 0;
13789
- if (u.uSplashFoam) u.uSplashFoam.value = this.splash ? 1 : 0;
13790
- const ripples = u.uRipples?.value;
13791
- const foam = u.uRippleFoam?.value;
13792
- if (ripples) for (let i = 0; i < ripples.length; i++) {
13793
- const r = this._ripples[i];
13794
- if (r) ripples[i].set(r.x, r.z, r.age, r.amp);
13795
- else ripples[i].set(0, 0, 0, 0);
13796
- if (foam) foam[i] = r ? r.foam ?? 0 : 0;
13797
- }
13798
- }
13799
- /**
13800
- * @internal Environment-sky sun hand-off (see {@link SunConsumer3D}): the
13801
- * sky's sun drives the glint ONLY while the `sunDirection` prop sits at its
13802
- * schema default — an explicitly authored prop always wins. Runs right
13803
- * after `_syncObject3D` (which wrote the prop value), overwriting the
13804
- * uniform for this frame.
13805
- */
13806
- _applySunDirection(dir) {
13807
- const def = Water3D.props.sunDirection?.default;
13808
- if (!this.sunDirection.every((v, i) => v === def[i])) return;
13809
- (this._ensureObject3D().material.uniforms?.uSunDirection?.value)?.set(dir[0], dir[1], dir[2]);
13810
- }
13811
- /** The fancy three-color ramp; a customized legacy `color` tints it. */
13812
- resolvePalette() {
13813
- const overrides = this.rp("colors");
13814
- const tint = this.rp("color");
13815
- const legacyTint = tint !== Water3D.props.color?.default ? tint : null;
13816
- const surface = overrides.surface ?? legacyTint ?? FANCY_COLORS.surface;
13817
- paletteScratch.surface.set(surface);
13818
- if (overrides.trough) paletteScratch.trough.set(overrides.trough);
13819
- else if (legacyTint) paletteScratch.trough.set(legacyTint).lerp(BLACK, .45);
13820
- else paletteScratch.trough.set(FANCY_COLORS.trough);
13821
- if (overrides.peak) paletteScratch.peak.set(overrides.peak);
13822
- else if (legacyTint) paletteScratch.peak.set(legacyTint).lerp(WHITE, .3);
13823
- else paletteScratch.peak.set(FANCY_COLORS.peak);
13824
- return paletteScratch;
13825
- }
13826
- syncSimple(mesh) {
13827
- const mat = mesh.material;
13828
- mat.opacity = this.rp("opacity");
13829
- const u = mat.uniforms;
13830
- const w = this.size[0] ?? 1;
13831
- const d = this.size[1] ?? 1;
13832
- const center = worldTranslationOf(this, this._scratchA);
13833
- if (u.uTime) u.uTime.value = this.time;
13834
- if (u.uOpacity) u.uOpacity.value = this.rp("opacity");
13835
- (u.uSurfaceColor?.value)?.set(this.rp("color")).lerp(WHITE, .12);
13836
- (u.uColor?.value)?.set(this.rp("color")).lerp(BLACK, .4);
13837
- (u.uSkyColor?.value)?.set(this.rp("color")).lerp(SKY_PALE, .82);
13838
- (u.uScale?.value)?.set(w / 2, d / 2, d / 2);
13839
- (u.uWaterCenter?.value)?.set(center.x, center.y, center.z);
13840
- (u.uSunDirection?.value)?.set(this.sunDirection[0] ?? 0, this.sunDirection[1] ?? 1, this.sunDirection[2] ?? 0).normalize();
13841
- (u.uSunColor?.value)?.set(this.sunColor);
13842
- if (u.uSunIntensity) u.uSunIntensity.value = this.rp("sunIntensity");
13843
- if (u.uDetailStrength) u.uDetailStrength.value = this.rp("detailStrength");
13844
- if (u.uSplashFoam) u.uSplashFoam.value = this.splash ? 1 : 0;
13845
- const uRip = u.uRipples?.value;
13846
- const uFoam = u.uRippleFoam?.value;
13847
- if (uRip) for (let i = 0; i < uRip.length; i++) {
13848
- const r = this._ripples[i];
13849
- if (r) uRip[i].set(r.x, r.z, r.age, r.amp);
13850
- else uRip[i].set(0, 0, 0, 0);
13851
- if (uFoam) uFoam[i] = r ? r.foam ?? 0 : 0;
13852
- }
13853
- const geo = mesh.geometry;
13854
- const pos = geo.getAttribute("position");
13855
- const norm = geo.getAttribute("normal");
13856
- const t = this.time;
13857
- const wh = this.rp("waveHeight");
13858
- const cx = center.x;
13859
- const cz = center.z;
13860
- const ripples = this._ripples;
13861
- const eps = .5;
13862
- for (let i = 0; i < pos.count; i++) {
13863
- const x = pos.getX(i);
13864
- const z = pos.getZ(i);
13865
- const h = simpleWaveHeight(x, z, t, wh) + ripplesHeightAt(x + cx, z + cz, ripples);
13866
- pos.setY(i, h);
13867
- const hX = simpleWaveHeight(x + eps, z, t, wh) + ripplesHeightAt(x + cx + eps, z + cz, ripples);
13868
- const hZ = simpleWaveHeight(x, z + eps, t, wh) + ripplesHeightAt(x + cx, z + cz + eps, ripples);
13869
- const nx = (h - hX) / eps;
13870
- const nz = (h - hZ) / eps;
13871
- const inv = 1 / Math.hypot(nx, 1, nz);
13872
- norm.setXYZ(i, nx * inv, inv, nz * inv);
13873
- }
13874
- pos.needsUpdate = true;
13875
- norm.needsUpdate = true;
13876
- }
13877
- stepInteraction(dt) {
13878
- let top = this;
13879
- while (top.parent) top = top.parent;
13880
- const bodies = this._bodies;
13881
- bodies.length = 0;
13882
- collectBodies(top, bodies);
13883
- const center = worldTranslationOf(this, this._scratchA);
13884
- const halfW = (this.size[0] ?? 0) / 2;
13885
- const halfD = (this.size[1] ?? 0) / 2;
13886
- const samples = [];
13887
- for (const body of bodies) {
13888
- const p = worldTranslationOf(body, this._scratchB);
13889
- if (Math.abs(p.x - center.x) > halfW || Math.abs(p.z - center.z) > halfD) continue;
13890
- const footDrop = colliderFootDrop(body.collider);
13891
- samples.push({
13892
- id: body,
13893
- x: p.x,
13894
- y: p.y,
13895
- z: p.z,
13896
- waterline: center.y + this.waveOffsetAt(p.x, p.z, center) + footDrop
13897
- });
13898
- }
13899
- const { entered, exited, inWater } = detectCrossings(this.inWater, samples, SURFACE_HYSTERESIS);
13900
- this.inWater = inWater;
13901
- const splashFoam = this.splash;
13902
- for (const s of entered) {
13903
- const vy = Math.abs(bodyVelocity(s.id)?.[1] ?? 0);
13904
- pushRipple(this._ripples, s.x, s.z, entrySplashAmp(s.id), splashFoam ? entryFoam(vy) : 0);
13905
- this.emit("entered", s.id);
13906
- }
13907
- for (const s of exited) {
13908
- pushRipple(this._ripples, s.x, s.z, EXIT_AMP, splashFoam ? EXIT_FOAM : 0);
13909
- this.emit("exited", s.id);
13910
- }
13911
- for (const body of stepBobTimers(this.bobTimers, inWater, dt, BOB_INTERVAL)) {
13912
- const p = worldTranslationOf(body, this._scratchB);
13913
- pushRipple(this._ripples, p.x, p.z, BOB_AMP);
13914
- }
13915
- for (const body of inWater) {
13916
- const p = worldTranslationOf(body, this._scratchB);
13917
- const a = this.wadeAnchors.get(body);
13918
- if (!a) {
13919
- this.wadeAnchors.set(body, {
13920
- x: p.x,
13921
- z: p.z
13922
- });
13923
- continue;
13924
- }
13925
- const dx = p.x - a.x;
13926
- const dz = p.z - a.z;
13927
- if (dx * dx + dz * dz >= WAKE_STEP * WAKE_STEP) {
13928
- const v = bodyVelocity(body);
13929
- const speed = Math.hypot(v?.[0] ?? 0, v?.[2] ?? 0);
13930
- const wakeFoam = splashFoam ? Math.min(WAKE_FOAM_BASE + WAKE_FOAM_PER_SPEED * speed, WAKE_FOAM_MAX) : 0;
13931
- pushRipple(this._ripples, p.x, p.z, WAKE_AMP, wakeFoam);
13932
- a.x = p.x;
13933
- a.z = p.z;
13934
- }
13935
- }
13936
- for (const body of this.wadeAnchors.keys()) if (!inWater.has(body)) this.wadeAnchors.delete(body);
13937
- }
13938
- /** Base wave height at world (x, z) — the CPU twin of the active shader.
13939
- * Ripples are excluded on purpose: a splash must not re-trigger itself. */
13940
- waveOffsetAt(x, z, center) {
13941
- if (this.quality === "simple") return simpleWaveHeight(x - center.x, z - center.z, this.time, this.rp("waveHeight"));
13942
- return waveElevation(x, z, this.time, {
13943
- amplitude: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT,
13944
- frequency: FANCY_WAVE.frequency,
13945
- persistence: FANCY_WAVE.persistence,
13946
- lacunarity: FANCY_WAVE.lacunarity,
13947
- iterations: FANCY_WAVE.iterations,
13948
- speed: FANCY_WAVE.speed,
13949
- scaleNorm: WAVE_WORLD_REF
13950
- });
13951
- }
13952
- };
13953
- /** Bodies that can splash: kinematic characters and dynamic rigid bodies. */
13954
- function collectBodies(node, out) {
13955
- if (node instanceof CharacterBody3D || node instanceof RigidBody3D) out.push(node);
13956
- for (const child of node.children) collectBodies(child, out);
13957
- }
13958
- /**
13959
- * World position approximation: the sum of `position` props up the Node3D
13960
- * ancestor chain. Ancestor ROTATION/SCALE are deliberately ignored — water
13961
- * interaction assumes world-aligned layouts (the overwhelmingly common case);
13962
- * it runs in update(), before the renderer refreshes any matrixWorld.
13963
- */
13964
- function worldTranslationOf(node, out = {
13965
- x: 0,
13966
- y: 0,
13967
- z: 0
13968
- }) {
13969
- let x = 0;
13970
- let y = 0;
13971
- let z = 0;
13972
- let current = node;
13973
- while (current) {
13974
- if (current instanceof Node3D) {
13975
- x += current.position[0] ?? 0;
13976
- y += current.position[1] ?? 0;
13977
- z += current.position[2] ?? 0;
13978
- }
13979
- current = current.parent;
13980
- }
13981
- out.x = x;
13982
- out.y = y;
13983
- out.z = z;
13984
- return out;
13985
- }
13986
- /** A body's velocity vector — kinematic `velocity` or dynamic `linearVelocity`. */
13987
- function bodyVelocity(body) {
13988
- return body.velocity ?? body.linearVelocity;
13989
- }
13990
- /** Entry splash amplitude — faster falls splash harder. */
13991
- function entrySplashAmp(body) {
13992
- const vy = Math.abs(bodyVelocity(body)?.[1] ?? 0);
13993
- return Math.min(ENTER_AMP_BASE + ENTER_AMP_PER_VY * vy, ENTER_AMP_MAX);
13994
- }
13995
- function buildWaterGeometry(width, depth, segments) {
13996
- const geo = new PlaneGeometry(width, depth, segments, segments);
13997
- geo.rotateX(-Math.PI / 2);
13998
- return geo;
13999
- }
14000
- const BLACK = new Color("#000000");
14001
- const WHITE = new Color("#ffffff");
14002
- /** Pale sky-blue the lake's single hue is lifted toward for its fresnel
14003
- * reflection tint (no cube map to sample — see lake.ts). */
14004
- const SKY_PALE = new Color("#cfe3f0");
14005
- const paletteScratch = {
14006
- trough: new Color(),
14007
- surface: new Color(),
14008
- peak: new Color()
14009
- };
14010
- const drawingBufferScratch = new Vector2();
14011
- /** Reused camera world-position scratch for the submersion test (zero alloc). */
14012
- const underwaterCamScratch = new Vector3();
14013
- /** XZ slack (m) so the underwater look persists a touch past the water's rim. */
14014
- const UNDERWATER_MARGIN = 3;
14015
- /** Keep-below clip plane for the scene pre-pass (constant set per render):
14016
- * normal (0,−1,0) keeps y < constant — see the grab-clip note in _onRender3D. */
14017
- const mirrorEyeScratch = new Vector3();
14018
- const mirrorTargetScratch = new Vector3();
14019
- const mirrorUpScratch = new Vector3();
14020
- const mirrorQuatScratch = new Quaternion();
14021
- /** Mirror pass: keep only what is ABOVE the waterline. */
14022
- const mirrorClipPlane = new Plane(new Vector3(0, 1, 0), 0);
14023
- const mirrorClipPlanes = [mirrorClipPlane];
14024
- const grabClipPlane = new Plane(new Vector3(0, -1, 0), 0);
14025
- const grabClipPlanes = [grabClipPlane];
14026
- /** Keep-above twin for the depth-completion pass: normal (0,1,0) keeps
14027
- * y > −constant — set per render to waterY − slack (overlapping the grab). */
14028
- const depthOnlyClipPlane = new Plane(new Vector3(0, 1, 0), 0);
14029
- const depthOnlyClipPlanes = [depthOnlyClipPlane];
14030
- /** Shared override for the depth-completion pass — writes depth, never color. */
14031
- const depthOnlyMaterial = new MeshDepthMaterial();
14032
- depthOnlyMaterial.colorWrite = false;
14033
- //#endregion
14034
10848
  //#region src/3d/register.ts
14035
10849
  /**
14036
10850
  * Register the 3D node taxonomy (and the core nodes). Call once in your game
@@ -14068,4 +10882,4 @@ function registerNodes3D() {
14068
10882
  registerNode(CharacterBody3D);
14069
10883
  }
14070
10884
  //#endregion
14071
- export { TERRAIN_THEMES as A, QUARTER_PITCH as B, CharacterController3D as C, Billboard3D as D, BoneAttachment3D as E, PhysicsBody3D as F, keyboardIntensity as H, RigidBody3D as I, StaticBody3D as L, Joint3D as M, Area3D as N, Terrain3D as O, CharacterBody3D as P, Node3D as R, FLOWER_VARIETIES as S, BoneLookAt3D as T, movementState as U, cameraRelative as V, rigPose as W, MeshInstance3D as _, VoxelGrid3D as a, Flowers3D as b, River3D as c, Particles3D as d, ModelInstance3D as f, InstancedMesh3D as g, OmniLight3D as h, VOXEL_PALETTE as i, terrainThemeLayers as j, DEFAULT_TERRAIN_TEXTURE_BASE as k, traceDownhillPath as l, DirectionalLight3D as m, Water3D as n, Tree3D as o, LoftMesh3D as p, createCausticsQuad as r, Trail3D as s, registerNodes3D as t, WATER_MAX_RIPPLES as u, Foliage3D as v, Camera3D as w, resolveFlowerDensity as x, DENSITY_PRESETS as y, validateCollider3D as z };
10885
+ export { QUARTER_PITCH as A, BoneAttachment3D as C, TERRAIN_THEMES as D, DEFAULT_TERRAIN_TEXTURE_BASE as E, keyboardIntensity as M, movementState as N, terrainThemeLayers as O, rigPose as P, BoneLookAt3D as S, Terrain3D as T, Flowers3D as _, Trail3D as a, CharacterController3D as b, Particles3D as c, DirectionalLight3D as d, OmniLight3D as f, DENSITY_PRESETS as g, Foliage3D as h, Tree3D as i, cameraRelative as j, Joint3D as k, ModelInstance3D as l, MeshInstance3D as m, VOXEL_PALETTE as n, River3D as o, InstancedMesh3D as p, VoxelGrid3D as r, traceDownhillPath as s, registerNodes3D as t, LoftMesh3D as u, resolveFlowerDensity as v, Billboard3D as w, Camera3D as x, FLOWER_VARIETIES as y };