incanto 0.20.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;
@@ -6910,15 +6532,17 @@ function buildRiverRings(opts) {
6910
6532
  const raw = bed[i];
6911
6533
  descending[i] = i === 0 ? raw : Math.min(raw, descending[i - 1]);
6912
6534
  }
6913
- const smoothed = descending.map((_, i) => {
6535
+ const keep = Math.max(.5, opts.depth * 1.5);
6536
+ const smoothed = descending.map((center, i) => {
6914
6537
  let sum = 0;
6915
6538
  let n = 0;
6916
6539
  for (let k = -3; k <= PROFILE_SMOOTH; k++) {
6917
- const j = Math.min(Math.max(i + k, 0), descending.length - 1);
6918
- sum += descending[j];
6540
+ const h = descending[Math.min(Math.max(i + k, 0), descending.length - 1)];
6541
+ if (Math.abs(h - center) > keep) continue;
6542
+ sum += h;
6919
6543
  n++;
6920
6544
  }
6921
- return sum / n;
6545
+ return n > 0 ? sum / n : center;
6922
6546
  });
6923
6547
  const profile = new Array(smoothed.length);
6924
6548
  for (let i = 0; i < smoothed.length; i++) {
@@ -7079,6 +6703,39 @@ function hitFrom(a, b, t, x, z, capped) {
7079
6703
  };
7080
6704
  }
7081
6705
  /**
6706
+ * Find the waterfalls in a course: maximal runs of steep stations, reported at
6707
+ * the station where the water lands. Callers hang spray on these, and the
6708
+ * shader aerates the curtain from the same grade the detector reads — one
6709
+ * source of truth for "this is falling, not flowing".
6710
+ */
6711
+ function detectFalls(rings, opts = {}) {
6712
+ const minGrade = opts.minGrade ?? .45;
6713
+ const minDrop = opts.minDrop ?? 1.2;
6714
+ const out = [];
6715
+ let start = -1;
6716
+ for (let i = 0; i <= rings.length; i++) {
6717
+ const steep = i < rings.length && rings[i].slope >= minGrade;
6718
+ if (steep && start < 0) start = i;
6719
+ if (steep || start < 0) continue;
6720
+ const lip = rings[Math.max(0, start - 1)];
6721
+ const base = rings[Math.min(rings.length - 1, i)];
6722
+ const drop = lip.surfaceY - base.surfaceY;
6723
+ if (drop >= minDrop) out.push({
6724
+ x: base.x,
6725
+ z: base.z,
6726
+ topY: lip.surfaceY,
6727
+ baseY: base.surfaceY,
6728
+ drop,
6729
+ halfWidth: base.halfWidth,
6730
+ dirX: base.tx,
6731
+ dirZ: base.tz,
6732
+ speed: base.speed
6733
+ });
6734
+ start = -1;
6735
+ }
6736
+ return out;
6737
+ }
6738
+ /**
7082
6739
  * Trace where water would actually run from a point: repeated steepest descent
7083
6740
  * with momentum, so the line follows a valley floor instead of rattling between
7084
6741
  * its walls. Ends when the ground goes flat (a pool), the trace leaves the play
@@ -7225,7 +6882,10 @@ void main() {
7225
6882
 
7226
6883
  // hydraulic jump: in a chute the wave train stands still in the world (phase
7227
6884
  // on the downstream coordinate, NEVER scrolled) and only breathes in time
7228
- float chute = smoothstep(0.035, 0.16, grade) * smoothstep(0.6, 2.4, speed);
6885
+ // standing waves belong to a RAPID: past ~20° the water has left the bed and
6886
+ // a wave train on a falling curtain reads as a rippling flag
6887
+ float chute = smoothstep(0.035, 0.16, grade) * smoothstep(0.6, 2.4, speed) *
6888
+ (1.0 - smoothstep(0.35, 0.7, grade));
7229
6889
  float standing =
7230
6890
  sin(aFlowUv.x * 1.9 + sin(uTime * 0.7) * 0.35) * 0.6 +
7231
6891
  sin(aFlowUv.x * 3.6 + sin(uTime * 0.9) * 0.5) * 0.4;
@@ -7329,8 +6989,11 @@ void main() {
7329
6989
  vec3 ripB = riverRipple(base - vec2(travel * pB, 0.0));
7330
6990
  vec3 ripple = mix(ripA, ripB, blend);
7331
6991
 
7332
- float streakA = riverStreak(base * 0.55 - vec2(travel * pA * 0.55, 0.0));
7333
- float streakB = riverStreak(base * 0.55 - vec2(travel * pB * 0.55, 0.0));
6992
+ // on a curtain the pattern stretches into strands: the same field, sampled
6993
+ // with the downstream axis squeezed hard, is what reads as falling water
6994
+ float streakScale = mix(0.55, 0.16, smoothstep(0.35, 0.9, grade));
6995
+ float streakA = riverStreak(base * streakScale - vec2(travel * pA * streakScale, 0.0));
6996
+ float streakB = riverStreak(base * streakScale - vec2(travel * pB * streakScale, 0.0));
7334
6997
  float streak = mix(streakA, streakB, blend);
7335
6998
 
7336
6999
  // ripples flatten out where the water thins to a film over the bed
@@ -7356,14 +7019,25 @@ void main() {
7356
7019
  // shallow is RELATIVE to this river: a hand's depth is a riffle in a brook
7357
7020
  // and nothing at all in a millrace, so the thresholds ride uMaxDepth
7358
7021
  float shallowT = column / max(uMaxDepth, 0.05);
7359
- float riffle = (1.0 - smoothstep(0.12, 0.42, shallowT)) * smoothstep(0.7, 2.2, speed);
7360
- float drive = clamp(rapids * 1.0 + bankShear * 0.35 + riffle * 0.5, 0.0, 1.3) * uFoam;
7022
+ // a riffle needs water to break OVER something — a centimetres-thin film on
7023
+ // a drying bar is a wet rock, and painting it white is what turns the tail of
7024
+ // a creek into a bright sheet
7025
+ float riffle =
7026
+ smoothstep(0.04, 0.13, column) *
7027
+ (1.0 - smoothstep(0.12, 0.42, shallowT)) *
7028
+ smoothstep(0.7, 2.2, speed);
7029
+ // FALLING, not flowing: past ~20° the sheet leaves the bed, shatters and
7030
+ // aerates. Aerated water is a white DIFFUSE body — it stops mirroring the
7031
+ // sky (that is the tell that separates a waterfall from a steep chute).
7032
+ float fall = smoothstep(0.35, 0.9, grade);
7033
+ float drive = clamp(rapids * 1.0 + bankShear * 0.35 + riffle * 0.5 + fall * 1.6, 0.0, 2.0) * uFoam;
7361
7034
  // COVERAGE is the thing to be strict about: whitewater is where the surface
7362
7035
  // has actually broken, and a river that fizzes everywhere reads as milk
7363
7036
  float foam = smoothstep(0.74 - 0.45 * drive, 1.02 - 0.24 * drive, streak) * min(drive, 1.0);
7364
7037
  // crest froth: the tops of standing waves in a real rapid always carry white
7365
7038
  foam = max(foam, smoothstep(0.68, 0.96, ripple.x) * smoothstep(0.4, 1.0, rapids) * uFoam * 0.5);
7366
- foam = clamp(foam, 0.0, 1.0);
7039
+ // froth can never be more present than the water carrying it
7040
+ foam = clamp(foam, 0.0, 1.0) * smoothstep(0.02, 0.14, column);
7367
7041
 
7368
7042
  // ── body ────────────────────────────────────────────────────────────────
7369
7043
  float depthT = clamp(column / max(uMaxDepth, 0.01), 0.0, 1.0);
@@ -7413,10 +7087,16 @@ void main() {
7413
7087
  spec *= 0.45 + 0.55 * glint * uSparkle;
7414
7088
  spec *= 1.0 - foam * 0.85; // froth is rough — it kills the mirror
7415
7089
 
7090
+ fresnel *= 1.0 - 0.85 * fall; // froth scatters; it does not reflect
7416
7091
  vec3 color = mix(body, sky, fresnel);
7417
7092
  color += uSunColor * spec * 1.6;
7418
7093
  color = mix(color, uFoamColor, foam * 0.88);
7419
7094
 
7095
+ // the LIP reads glassy-dark for a moment before the sheet breaks — the
7096
+ // contrast against the white curtain below is most of the drop's drama
7097
+ float lip = smoothstep(0.12, 0.32, grade) * (1.0 - smoothstep(0.32, 0.55, grade));
7098
+ color = mix(color, color * 0.72, lip * 0.6);
7099
+
7420
7100
  // ── edges ───────────────────────────────────────────────────────────────
7421
7101
  // the ribbon is built wider than the channel; what makes its outline is the
7422
7102
  // water running out, so bars and boulders cut it exactly like the ground does
@@ -7521,7 +7201,8 @@ var River3D = class River3D extends Node3D {
7521
7201
  sunColor: { default: "#fff6e0" },
7522
7202
  sunIntensity: { default: 1 },
7523
7203
  terrain: { default: "" },
7524
- flowForce: { default: 1 }
7204
+ flowForce: { default: 1 },
7205
+ spray: { default: 1 }
7525
7206
  };
7526
7207
  /** Centerline control points `[[x, z], …]` in the node's local frame. */
7527
7208
  path = [];
@@ -7557,9 +7238,17 @@ var River3D = class River3D extends Node3D {
7557
7238
  terrain = "";
7558
7239
  /** How hard the current sweeps bodies downstream (0 = visual only). */
7559
7240
  flowForce = 1;
7241
+ /**
7242
+ * Mist at the foot of every waterfall the course contains: the node finds
7243
+ * the drops itself (see {@link detectFalls}) and hangs a Particles3D plume
7244
+ * on each, sized by how far the water falls. 0 = off.
7245
+ */
7246
+ spray = 1;
7560
7247
  renderOrder = 1;
7561
7248
  time = 0;
7562
7249
  rings = [];
7250
+ falls = [];
7251
+ sprayKey = "";
7563
7252
  courseKey = "";
7564
7253
  bedAt = null;
7565
7254
  world = [
@@ -7585,6 +7274,7 @@ var River3D = class River3D extends Node3D {
7585
7274
  "foam",
7586
7275
  "ripples",
7587
7276
  "flowForce",
7277
+ "spray",
7588
7278
  "sunIntensity"
7589
7279
  ]) {
7590
7280
  const v = r[key];
@@ -7604,6 +7294,7 @@ var River3D = class River3D extends Node3D {
7604
7294
  update(dt) {
7605
7295
  this.time += dt;
7606
7296
  this.ensureCourse();
7297
+ this.syncSpray();
7607
7298
  if (this.flowForce > 0 && this.rings.length > 1) this.sweepBodies(dt);
7608
7299
  }
7609
7300
  _syncObject3D() {
@@ -7627,6 +7318,21 @@ var River3D = class River3D extends Node3D {
7627
7318
  surfaceY: hit.surfaceY + wy
7628
7319
  } : null;
7629
7320
  }
7321
+ /**
7322
+ * The waterfalls on this course — where the water stops flowing and starts
7323
+ * falling, with the drop height and the plunge point in WORLD coordinates.
7324
+ * Hang your own effects (sound, a rainbow, a hidden cave) on these.
7325
+ */
7326
+ fallsAt() {
7327
+ this.ensureCourse();
7328
+ const [wx, wy, wz] = this.world;
7329
+ return this.falls.map((f) => ({
7330
+ ...f,
7331
+ worldX: f.x + wx,
7332
+ worldY: f.baseY + wy,
7333
+ worldZ: f.z + wz
7334
+ }));
7335
+ }
7630
7336
  /** @internal The derived centerline stations (surface, grade, current). */
7631
7337
  _rings() {
7632
7338
  return this.rings;
@@ -7677,6 +7383,7 @@ var River3D = class River3D extends Node3D {
7677
7383
  this.bedAt = null;
7678
7384
  if (this.path.length < 2) {
7679
7385
  this.rings = [];
7386
+ this.falls = [];
7680
7387
  return true;
7681
7388
  }
7682
7389
  const [wx, wy, wz] = world;
@@ -7691,6 +7398,7 @@ var River3D = class River3D extends Node3D {
7691
7398
  step: Math.min(Math.max(narrow * STEP_PER_WIDTH, STEP_MIN), STEP_MAX),
7692
7399
  bedAt: this.bedAt
7693
7400
  });
7401
+ this.falls = detectFalls(this.rings);
7694
7402
  return true;
7695
7403
  }
7696
7404
  rebuildIfNeeded(mesh) {
@@ -7790,6 +7498,48 @@ var River3D = class River3D extends Node3D {
7790
7498
  set("uSparkle", this.ripples);
7791
7499
  }
7792
7500
  /**
7501
+ * Hang a mist plume on every fall. Runtime children (`__spray*`), rebuilt
7502
+ * only when the course or the dial changes — a plume is a Particles3D, so it
7503
+ * carries its own budget, culling and editor visibility for free.
7504
+ */
7505
+ syncSpray() {
7506
+ const key = this.spray > 0 ? `${this.spray}|${JSON.stringify(this.falls)}` : "";
7507
+ if (key === this.sprayKey) return;
7508
+ this.sprayKey = key;
7509
+ for (const child of [...this.children]) if (child.name.startsWith("__spray")) child.free();
7510
+ if (this.spray <= 0) return;
7511
+ this.falls.forEach((fall, i) => {
7512
+ const plume = new Particles3D(`__spray${i}`);
7513
+ plume.position = [
7514
+ fall.x + fall.dirX * .6,
7515
+ fall.baseY + .15,
7516
+ fall.z + fall.dirZ * .6
7517
+ ];
7518
+ const power = Math.min(1.6, .4 + fall.drop * .12) * this.spray;
7519
+ plume.rate = Math.round(Math.min(46, 10 + fall.drop * 3) * this.spray);
7520
+ plume.maxParticles = 160;
7521
+ plume.lifetime = [1, 2.1];
7522
+ plume.speed = [55 * power, 170 * power];
7523
+ plume.directionDeg = -90;
7524
+ plume.spreadDeg = 58;
7525
+ plume.gravity = [
7526
+ 0,
7527
+ -240,
7528
+ 0
7529
+ ];
7530
+ plume.drag = 1.2;
7531
+ plume.sizeStart = 16 * power;
7532
+ plume.sizeEnd = 80 * power;
7533
+ plume.colorStart = "#ffffff";
7534
+ plume.colorEnd = "#dfe9ec";
7535
+ plume.alphaStart = .2;
7536
+ plume.alphaEnd = 0;
7537
+ plume.blend = "normal";
7538
+ plume.depthTest = true;
7539
+ this.addChild(plume);
7540
+ });
7541
+ }
7542
+ /**
7793
7543
  * Drag every body floating in the channel toward the water's own velocity.
7794
7544
  * Drag rather than thrust: a raft accelerates until it matches the current
7795
7545
  * and then coasts, and a swimmer can still fight across a slow ford.
@@ -7799,7 +7549,7 @@ var River3D = class River3D extends Node3D {
7799
7549
  while (top.parent) top = top.parent;
7800
7550
  const bodies = this._bodies;
7801
7551
  bodies.length = 0;
7802
- collectBodies$1(top, bodies);
7552
+ collectBodies(top, bodies);
7803
7553
  if (bodies.length === 0) return;
7804
7554
  const [wx, wy, wz] = this.world;
7805
7555
  for (const body of bodies) {
@@ -7810,7 +7560,7 @@ var River3D = class River3D extends Node3D {
7810
7560
  const feet = p[1] - colliderFootDrop(body.collider);
7811
7561
  if (feet > surfaceY + CURRENT_SURFACE_SLACK) continue;
7812
7562
  if (feet < surfaceY - this.depth - CURRENT_UNDER_SLACK) continue;
7813
- const vel = bodyVelocity$1(body);
7563
+ const vel = bodyVelocity(body);
7814
7564
  if (!vel) continue;
7815
7565
  const gain = Math.min(1, this.flowForce * CURRENT_DRAG * dt);
7816
7566
  const dvx = (hit.dirX * hit.speed - (vel[0] ?? 0)) * gain;
@@ -7830,12 +7580,12 @@ var River3D = class River3D extends Node3D {
7830
7580
  }
7831
7581
  };
7832
7582
  /** Bodies a current can carry: kinematic characters and dynamic rigid bodies. */
7833
- function collectBodies$1(node, out) {
7583
+ function collectBodies(node, out) {
7834
7584
  if (node instanceof CharacterBody3D || node instanceof RigidBody3D) out.push(node);
7835
- for (const child of node.children) collectBodies$1(child, out);
7585
+ for (const child of node.children) collectBodies(child, out);
7836
7586
  }
7837
7587
  /** A body's velocity vector — kinematic `velocity` or dynamic `linearVelocity`. */
7838
- function bodyVelocity$1(body) {
7588
+ function bodyVelocity(body) {
7839
7589
  return body.velocity ?? body.linearVelocity;
7840
7590
  }
7841
7591
  //#endregion
@@ -10069,9 +9819,9 @@ function normalizeTint(color) {
10069
9819
  const max = Math.max(tint.r, tint.g, tint.b);
10070
9820
  if (max > 0) tint.multiplyScalar(1 / max);
10071
9821
  else tint.setRGB(1, 1, 1);
10072
- return tint.lerp(WHITE$1, .18);
9822
+ return tint.lerp(WHITE, .18);
10073
9823
  }
10074
- const WHITE$1 = new Color(1, 1, 1);
9824
+ const WHITE = new Color(1, 1, 1);
10075
9825
  /** Bark tint for a customized trunkColor: keep the hue, normalize the
10076
9826
  * brightest channel to 1 — the color map keeps its own brightness. */
10077
9827
  function barkTint(color) {
@@ -11095,2749 +10845,6 @@ void main() {
11095
10845
  }
11096
10846
  `;
11097
10847
  //#endregion
11098
- //#region src/3d/frustum.ts
11099
- const scratchFrustum = new Frustum();
11100
- const scratchMatrix = new Matrix4();
11101
- const scratchSphere = new Sphere();
11102
- /**
11103
- * Is a world-space sphere inside the camera's view frustum?
11104
- *
11105
- * Used to skip per-frame work for things the camera can't see — e.g. Water3D's
11106
- * fancy scene pre-pass + reflection cube, which otherwise re-render the whole
11107
- * scene every frame for ponds that are off-screen. Uses shared scratch objects
11108
- * (single-threaded engine), so it allocates nothing per call.
11109
- */
11110
- function sphereInCameraFrustum(camera, center, radius) {
11111
- scratchMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
11112
- scratchFrustum.setFromProjectionMatrix(scratchMatrix);
11113
- scratchSphere.center.set(center.x, center.y, center.z);
11114
- scratchSphere.radius = radius;
11115
- return scratchFrustum.intersectsSphere(scratchSphere);
11116
- }
11117
- //#endregion
11118
- //#region src/3d/water/lake.ts
11119
- /**
11120
- * The Water3D 'simple' quality lake shader — the CHEAP good-looking path.
11121
- *
11122
- * 'fancy' water is commercial-grade but pays for it: a CubeCamera reflection
11123
- * plus a half-res scene depth/color pre-pass (and a depth-completion pass) —
11124
- * 2–3 extra full-scene renders every frame, which drop a pond to ~30 fps. The
11125
- * old 'simple' fallback was a plain lit MeshStandardMaterial over CPU-displaced
11126
- * sine waves: cheap, but flat and lifeless (the "허접" playtest complaint).
11127
- *
11128
- * This lake shader keeps the cheap budget — ZERO scene re-renders, no cube map,
11129
- * no depth texture — and still reads as real water by faking the three cues the
11130
- * eye actually needs:
11131
- *
11132
- * - Fresnel sky reflection: a procedural horizon→zenith SKY TINT (uSkyColor),
11133
- * Schlick-mixed in by view angle. Steep view = water color, grazing = sky.
11134
- * No environment to sample — a lake reflects mostly sky anyway.
11135
- * - Procedural ripple normals: two crossing animated simplex layers perturb the
11136
- * (CPU-computed) macro normal per pixel — the LIQUID micro-detail, texture-
11137
- * free, distance-faded (procedural noise has no mips → aliases at range).
11138
- * - Sun glint: a tight Blinn-Phong spark + soft sheen off the rippled normal,
11139
- * weighted toward the sun's azimuth path (uSunDirection/Color/Intensity).
11140
- * - Depth-free body: looking straight down reads the lighter surface tint
11141
- * (clearer shallows feel), grazing reads the darker deep base; grazing also
11142
- * reads more opaque/reflective. No depth buffer, all from the view angle.
11143
- * - Soft edge fade: the rectangular plane's rim fades to zero alpha, so the
11144
- * surface melts into the shore instead of poking out as a hard rectangle
11145
- * (the "물 삐져나옴" complaint).
11146
- *
11147
- * The CPU still displaces the coarse vertex grid (macro swell + interaction
11148
- * ring waves) and computes its normals — that path stays deterministic and
11149
- * headless-testable; this shader only adds the per-pixel look on top.
11150
- *
11151
- * Atmosphere stage (marked below): the fragment ends with three's
11152
- * tonemapping + colorspace + fog chunks in built-in-material order, and both
11153
- * stages carry the standard fog chunks, so the lake tone-maps, sRGB-encodes
11154
- * and fogs identically to the fancy water and the terrain.
11155
- */
11156
- const LAKE_VERT = `
11157
- precision highp float;
11158
-
11159
- // atmosphere: three's linear fog rides the standard chunks (compiled out
11160
- // unless the scene declares fog — USE_FOG comes from material.fog)
11161
- #include <fog_pars_vertex>
11162
-
11163
- varying vec3 vWorldPosition;
11164
- varying vec3 vNormalW;
11165
-
11166
- void main() {
11167
- vec4 modelPosition = modelMatrix * vec4(position, 1.0);
11168
- vWorldPosition = modelPosition.xyz;
11169
- // The CPU computeVertexNormals() macro normal, lifted to world space. Water
11170
- // is laid axis-aligned on XZ (no parent rotation expected), so the plain
11171
- // model rotation is exact enough for the fresnel/glint cues.
11172
- vNormalW = normalize(mat3(modelMatrix) * normal);
11173
-
11174
- vec4 mvPosition = viewMatrix * modelPosition;
11175
- gl_Position = projectionMatrix * mvPosition;
11176
-
11177
- // atmosphere: fog depth from the displaced surface position
11178
- #include <fog_vertex>
11179
- }
11180
- `;
11181
- const LAKE_FRAG = `
11182
- precision highp float;
11183
-
11184
- // atmosphere: standard fog chunk (compiled out without scene fog)
11185
- #include <fog_pars_fragment>
11186
-
11187
- uniform float uTime;
11188
- uniform float uOpacity;
11189
- uniform vec3 uColor; // deep water base (derived from the single hue)
11190
- uniform vec3 uSurfaceColor; // shallow/surface tint (the hue itself)
11191
- uniform vec3 uSkyColor; // fresnel reflection tint — NO cube map
11192
- uniform vec3 uSunDirection;
11193
- uniform vec3 uSunColor;
11194
- uniform float uSunIntensity;
11195
- uniform float uDetailStrength;
11196
- uniform float uFresnelPower;
11197
- uniform vec3 uScale; // half extents (x, _, z) for the edge fade
11198
- uniform vec3 uWaterCenter;
11199
-
11200
- // incanto: splash whitewater — the ripple buffer drives expanding surface foam
11201
- // (entry "풍덩" + wake "물살"); uSplashFoam = the Water3D.splash gate.
11202
- uniform vec4 uRipples[8];
11203
- uniform float uRippleFoam[8];
11204
- uniform float uSplashFoam;
11205
- uniform vec3 uFoamColor;
11206
-
11207
- varying vec3 vWorldPosition;
11208
- varying vec3 vNormalW;
11209
-
11210
- // Simplex 2D noise (Ian McEwan, Stefan Gustavson — https://github.com/stegu/webgl-noise, MIT)
11211
- vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
11212
-
11213
- float snoise(vec2 v) {
11214
- const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
11215
- vec2 i = floor(v + dot(v, C.yy));
11216
- vec2 x0 = v - i + dot(i, C.xx);
11217
- vec2 i1;
11218
- i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
11219
- vec4 x12 = x0.xyxy + C.xxzz;
11220
- x12.xy -= i1;
11221
- i = mod(i, 289.0);
11222
- vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
11223
- vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
11224
- m = m * m;
11225
- m = m * m;
11226
- vec3 x = 2.0 * fract(p * C.www) - 1.0;
11227
- vec3 h = abs(x) - 0.5;
11228
- vec3 ox = floor(x + 0.5);
11229
- vec3 a0 = x - ox;
11230
- m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
11231
- vec3 g;
11232
- g.x = a0.x * x0.x + h.x * x0.y;
11233
- g.yz = a0.yz * x12.xz + h.yz * x12.yw;
11234
- return 130.0 * dot(m, g);
11235
- }
11236
-
11237
- // finite-difference gradient of the simplex height field
11238
- vec2 noiseGrad(vec2 p) {
11239
- const float e = 0.06;
11240
- float h = snoise(p);
11241
- return vec2(snoise(p + vec2(e, 0.0)) - h, snoise(p + vec2(0.0, e)) - h) / e;
11242
- }
11243
-
11244
- // incanto: splash WHITEWATER. A clean radial disc/ring read as a fake CG stamp;
11245
- // real foam is irregular clumps that drift with the water and break apart at the
11246
- // edges. A soft COVERAGE envelope (an expanding patch fading over FOAM_MAX_AGE —
11247
- // the CPU twin rippleFoamAt, constants MIRRORED) THRESHOLDS a domain-warped
11248
- // fractal noise: dense coverage → broad froth, thinning coverage raises the
11249
- // threshold → it erodes into sparse bubbles. Warp + drift = advected, not stamped.
11250
- float lnoise(vec2 p) { return 0.5 + 0.5 * snoise(p); }
11251
- float foamFbm(vec2 p) {
11252
- float v = 0.0, a = 0.5;
11253
- for (int i = 0; i < 4; i++) { v += a * lnoise(p); p = p * 2.02 + 7.3; a *= 0.5; }
11254
- return v; // ~0..0.94
11255
- }
11256
-
11257
- float rippleFoam(vec2 p) {
11258
- if (uSplashFoam < 0.5) return 0.0;
11259
- float cover = 0.0;
11260
- for (int i = 0; i < 8; i++) {
11261
- float foam = uRippleFoam[i];
11262
- if (foam <= 0.0) continue;
11263
- vec4 r = uRipples[i];
11264
- float age = r.z;
11265
- float life = 1.0 - age / 1.6; // FOAM_MAX_AGE
11266
- if (life <= 0.0) continue;
11267
- float d = distance(p, r.xy);
11268
- float radius = 0.4 + 1.15 * age; // FOAM_R0 + FOAM_SPREAD * age
11269
- cover = max(cover, (1.0 - smoothstep(radius * 0.7, radius, d)) * foam * life);
11270
- }
11271
- if (cover <= 0.001) return 0.0;
11272
- // FINE frequencies (premium foam = many small bubbles, not coarse blobs)
11273
- vec2 flow = vec2(uTime * 0.05, uTime * -0.04);
11274
- vec2 warp = vec2(foamFbm(p * 1.1 + flow), foamFbm(p * 1.1 + 4.2 - flow)) - 0.5;
11275
- float pattern = foamFbm(p * 2.6 + warp * 1.4 + flow);
11276
- // raised floor + wide band = sparser + softer = subtler/more refined
11277
- float thresh = mix(0.36, 0.96, 1.0 - cover);
11278
- float foamHere = smoothstep(thresh, thresh + 0.26, pattern);
11279
- return clamp(foamHere * (0.4 + 0.5 * cover), 0.0, 1.0);
11280
- }
11281
-
11282
- // Two crossing animated ripple layers perturb the macro normal. Each fades out
11283
- // with distance (procedural noise has no mips — past its range it aliases into
11284
- // white speckle), so far water relaxes back to the smooth swell.
11285
- vec3 detailNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
11286
- float fadeMid = 1.0 - smoothstep(45.0, 170.0, camDist);
11287
- float fadeFine = 1.0 - smoothstep(14.0, 70.0, camDist);
11288
- // Three FINE isotropic ripple fields drifting in crossing directions — a
11289
- // calm lake's surface is many small wavelets, not a few big swells, so the
11290
- // frequencies are high (≈1.4 m / ≈0.8 m / ≈0.5 m) and the amplitudes small.
11291
- // Each gradient is multiplied by its own frequency: noiseGrad returns the
11292
- // slope in NOISE space, so without this the coarsest layer over-amplifies
11293
- // into the foil-blob look. Net perturbation stays a gentle few degrees.
11294
- vec2 dMid = normalize(vec2(0.72, 0.50));
11295
- vec2 dFine = normalize(vec2(-0.42, 0.91));
11296
- vec2 dMicro = normalize(vec2(0.34, -0.94));
11297
- vec2 slope =
11298
- noiseGrad(worldXZ * 0.70 + dMid * uTime * 0.14) * (0.70 * 0.085 * fadeMid)
11299
- + noiseGrad(worldXZ * 1.30 + dFine * uTime * 0.20) * (1.30 * 0.060 * fadeFine)
11300
- + noiseGrad(worldXZ * 2.10 - dMicro * uTime * 0.26) * (2.10 * 0.040 * fadeFine);
11301
- slope *= strength;
11302
- return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
11303
- }
11304
-
11305
- void main() {
11306
- vec3 viewDir = normalize(vWorldPosition - cameraPosition);
11307
- float camDist = length(vWorldPosition - cameraPosition);
11308
-
11309
- // Flatten the macro normal with distance so far ripples don't strobe into
11310
- // sky-reflection sequins against the bright horizon.
11311
- float geoFlat = 1.0 - 0.85 * smoothstep(140.0, 520.0, camDist);
11312
- vec3 geoN = normalize(mix(vec3(0.0, 1.0, 0.0), normalize(vNormalW), geoFlat));
11313
- vec3 normal = detailNormal(geoN, vWorldPosition.xz, uDetailStrength, camDist);
11314
-
11315
- // Schlick fresnel: 2% normal-incidence floor (water's real F0), 98% at
11316
- // grazing. Steep view → water color, grazing → sky tint.
11317
- float fresnel = 0.02 + 0.98 * pow(1.0 - clamp(dot(-viewDir, normal), 0.0, 1.0), uFresnelPower);
11318
-
11319
- // Depth-free body color from the view angle: straight down sees the lighter
11320
- // surface (clearer shallows feel), grazing sees the deeper base.
11321
- float viewUp = clamp(dot(-viewDir, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
11322
- vec3 body = mix(uColor, uSurfaceColor, viewUp);
11323
-
11324
- // Sky reflection WITHOUT a cube map: a soft horizon→zenith gradient sampled
11325
- // by the reflected ray. The horizon is paler/brighter (atmospheric haze),
11326
- // the zenith deeper — convincing for a lake, which reflects mostly sky.
11327
- vec3 refl = reflect(viewDir, normal);
11328
- float up = clamp(refl.y, 0.0, 1.0);
11329
- vec3 skyTint = mix(uSkyColor * 1.08, uSkyColor * 0.82, up);
11330
-
11331
- // cap the sky mix below a full mirror (that reads as white foil — the old
11332
- // goop look) but high enough that the grazing far field catches believable
11333
- // sky instead of going flat grey
11334
- vec3 color = mix(body, skyTint, clamp(fresnel, 0.0, 0.62));
11335
-
11336
- // Sun glint: a tight spark + soft sheen off the rippled normal, weighted to
11337
- // the sun's azimuth path so crests sparkle ALONG the light and the rest of
11338
- // the surface stays calm color (not an all-over glossy speckle).
11339
- vec3 sunL = normalize(uSunDirection);
11340
- vec3 halfway = normalize(sunL - viewDir); // -viewDir = surface→camera
11341
- float specAngle = max(dot(normal, halfway), 0.0);
11342
- vec2 toFrag = normalize(vWorldPosition.xz - cameraPosition.xz);
11343
- float sunPath = pow(clamp(dot(toFrag, normalize(sunL.xz)), 0.0, 1.0), 4.0);
11344
- float glintFade = 1.0 - smoothstep(70.0, 240.0, camDist);
11345
- float glint = pow(specAngle, 380.0) * (0.25 + 0.9 * sunPath) * glintFade;
11346
- float sheen = pow(specAngle, 36.0) * 0.05 * sunPath;
11347
- color += uSunColor * min(uSunIntensity * (glint * 0.7 + sheen), 0.8);
11348
-
11349
- // splash froth where bodies enter / wade — a PALE foam tint (not pure white,
11350
- // which floats on top as a jarring decal); the water still reads through
11351
- float splashFoam = rippleFoam(vWorldPosition.xz);
11352
- color = mix(color, mix(uFoamColor, vec3(1.0), 0.3), splashFoam * 0.58);
11353
-
11354
- // Soft edge fade — the rectangular rim melts into the shore. Normalized
11355
- // distance from the node center to its half extents; the last ~18% fades to
11356
- // zero alpha (no hard "물 삐져나옴" rectangle).
11357
- vec2 nrm = (vWorldPosition.xz - uWaterCenter.xz) / vec2(uScale.x, uScale.z);
11358
- float edge = 1.0 - smoothstep(0.82, 1.0, length(nrm));
11359
-
11360
- // Grazing angles look more opaque/reflective; straight-down looks clearer.
11361
- float alpha = mix(uOpacity * 0.7, min(uOpacity + 0.25, 1.0), fresnel);
11362
- alpha *= edge;
11363
- // foam reads even looking straight down — lift froth opacity, but only part
11364
- // way so the disturbed water still shows through (translucent froth)
11365
- alpha = max(alpha, splashFoam * 0.7 * edge);
11366
-
11367
- gl_FragColor = vec4(color, alpha);
11368
-
11369
- // atmosphere: join three's output pipeline — ACES tone map + working→output
11370
- // colorspace encode + linear fog, in the SAME order as the built-in
11371
- // materials so the lake fogs/grades identically to the terrain.
11372
- #include <tonemapping_fragment>
11373
- #include <colorspace_fragment>
11374
- #include <fog_fragment>
11375
- }
11376
- `;
11377
- //#endregion
11378
- //#region src/3d/water/caustics.ts
11379
- /**
11380
- * Underwater caustics — the dancing refracted-light pattern the water surface
11381
- * throws onto everything below it. The engine has no post-processing pipeline,
11382
- * so the renderer does this as ONE depth-aware composite pass, and ONLY while
11383
- * the camera is genuinely underwater (so normal above-water rendering — even
11384
- * standing at the water's edge — is completely untouched):
11385
- *
11386
- * 1. render the scene to an offscreen target (color + depth)
11387
- * 2. draw this fullscreen material, which reconstructs each pixel's WORLD
11388
- * position from the depth buffer and adds an animated caustic highlight
11389
- * wherever that world point sits below the water surface
11390
- *
11391
- * Because the pattern is keyed to reconstructed world XZ, it STICKS to the
11392
- * floor/walls/props (it isn't a flat screen overlay) and fades with depth +
11393
- * view distance so it melts into the underwater fog.
11394
- */
11395
- /** Fullscreen triangle/quad — the vertex stage ignores the camera entirely. */
11396
- const CAUSTICS_VERT = `
11397
- varying vec2 vUv;
11398
- void main() {
11399
- vUv = uv;
11400
- gl_Position = vec4(position.xy, 0.0, 1.0);
11401
- }
11402
- `;
11403
- /**
11404
- * The classic animated caustic pattern (layered moving cells), 0..1 — SHARED
11405
- * between the underwater composite and Water3D's above-water caustics (the
11406
- * refracted bottom shimmer), so both views of the same water agree.
11407
- */
11408
- const CAUSTIC_GLSL = `
11409
- float caustic(vec2 uv, float t) {
11410
- vec2 p = mod(uv * 6.28318, 6.28318) - 250.0;
11411
- vec2 i = p;
11412
- float c = 1.0;
11413
- float inten = 0.005;
11414
- for (int n = 0; n < 5; n++) {
11415
- float ti = t * (1.0 - (3.5 / float(n + 1)));
11416
- i = p + vec2(cos(ti - i.x) + sin(ti + i.y), sin(ti - i.y) + cos(ti + i.x));
11417
- c += 1.0 / length(vec2(p.x / (sin(i.x + ti) / inten), p.y / (cos(i.y + ti) / inten)));
11418
- }
11419
- c /= 5.0;
11420
- c = 1.17 - pow(c, 1.4);
11421
- return clamp(pow(abs(c), 8.0), 0.0, 1.0);
11422
- }
11423
- `;
11424
- const CAUSTICS_FRAG = `
11425
- precision highp float;
11426
- varying vec2 vUv;
11427
- uniform sampler2D tColor;
11428
- uniform sampler2D tDepth;
11429
- uniform mat4 uInvViewProj;
11430
- uniform vec3 uCameraPos;
11431
- uniform float uWaterLevel;
11432
- uniform float uTime;
11433
- uniform vec3 uCausticColor;
11434
- uniform float uCausticIntensity;
11435
- uniform float uCausticScale;
11436
- uniform float uMaxDist;
11437
-
11438
- ${CAUSTIC_GLSL}
11439
-
11440
- void main() {
11441
- vec4 color = texture2D(tColor, vUv);
11442
- float depth = texture2D(tDepth, vUv).x;
11443
- // depth == 1 is the far plane (sky/background) — nothing to light there
11444
- if (depth < 1.0) {
11445
- vec4 ndc = vec4(vUv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
11446
- vec4 world = uInvViewProj * ndc;
11447
- world.xyz /= world.w;
11448
- float below = uWaterLevel - world.y;
11449
- if (below > 0.0) {
11450
- float dist = distance(world.xyz, uCameraPos);
11451
- // caustics — fade with distance (melt into the fog) + a touch with depth
11452
- float distFade = 1.0 - clamp(dist / uMaxDist, 0.0, 1.0);
11453
- float depthFade = 1.0 - clamp(below / (uMaxDist * 0.5), 0.0, 0.6);
11454
- // two octaves at offset speeds = the shimmering interference of real caustics
11455
- float c =
11456
- caustic(world.xz * uCausticScale, uTime) * 0.65 +
11457
- caustic(world.xz * uCausticScale * 1.7 + 30.0, uTime * 0.8) * 0.35;
11458
- color.rgb += uCausticColor * (c * uCausticIntensity * distFade * distFade * depthFade);
11459
- }
11460
- }
11461
- gl_FragColor = color;
11462
- }
11463
- `;
11464
- /** Build the fullscreen composite mesh (a 2×2 clip-space quad). */
11465
- function createCausticsQuad() {
11466
- const material = new ShaderMaterial({
11467
- vertexShader: CAUSTICS_VERT,
11468
- fragmentShader: CAUSTICS_FRAG,
11469
- depthTest: false,
11470
- depthWrite: false,
11471
- uniforms: {
11472
- tColor: { value: null },
11473
- tDepth: { value: null },
11474
- uInvViewProj: { value: new Matrix4() },
11475
- uCameraPos: { value: new Vector3() },
11476
- uWaterLevel: { value: 0 },
11477
- uTime: { value: 0 },
11478
- uCausticColor: { value: new Color("#cdeeff") },
11479
- uCausticIntensity: { value: .55 },
11480
- uCausticScale: { value: .32 },
11481
- uMaxDist: { value: 22 }
11482
- }
11483
- });
11484
- const mesh = new Mesh(new PlaneGeometry(2, 2), material);
11485
- mesh.frustumCulled = false;
11486
- return {
11487
- mesh,
11488
- uniforms: material.uniforms
11489
- };
11490
- }
11491
- //#endregion
11492
- //#region src/3d/water/shaders.ts
11493
- /**
11494
- * The Water3D 'fancy' shaders — ported VERBATIM from
11495
- * vibe-starter-3d-environment `src/components/water/shader/water.{vert,frag}.glsl`
11496
- * (9-iteration simplex-2D FBM displacement with analytic finite-difference
11497
- * normals and an 85→98% edge-fade ring; trough/surface/peak elevation ramp,
11498
- * fresnel-mixed cube-map reflection, optional depth-texture shoreline foam
11499
- * with hard above-water discard). Incanto deviations, each marked inline:
11500
- *
11501
- * - `uWaterCenter` (NEW): the source normalized world positions by the raw
11502
- * scale, which only works for water sitting at the world origin — we
11503
- * subtract the node's world center so positioned/parented water keeps its
11504
- * edge fade and color ramp. `uScale` carries HALF extents (x=w/2, y=d/2,
11505
- * z=d/2) so the shader text stays unchanged.
11506
- * - `uRipples[8]` + `getRippleHeight` (NEW): character-interaction ring waves
11507
- * (see ./interaction.ts — constants mirrored there). Displacement AND
11508
- * normals include them via `getHeight`.
11509
- * - `uUseEnvironmentMap` gate: the source declared it but never read it; we
11510
- * multiply it into the fresnel mix so water without a live reflection
11511
- * (headless, `reflection: false`, first frame) shades flat instead of black.
11512
- * - Fresnel FIX: the source's `dot(viewDirection, normal)` (camera→surface vs
11513
- * up normal) is negative at every above-water angle, pinning fresnel at max
11514
- * — the surface was a constant 3/4 mirror, so the cube map smeared across
11515
- * everything. We dot the negated view direction (true Schlick shape) and
11516
- * clamp reflectivity to 0.75 so grazing angles keep some water colour.
11517
- * - Reflection sampling FIXES: no `.x` flip (only LOADED cube textures need
11518
- * it; CubeCamera targets sample with three's flipEnvMap=+1 convention), and
11519
- * rays are folded above the horizon (`max(abs(y), 0.1)`) because the lower
11520
- * hemisphere of a water-centered cube map is parallax garbage.
11521
- * - World-space `vNormal` FIX: the source ran the world-space analytic normal
11522
- * through `normalMatrix` (a VIEW-space transform) and then dotted it against
11523
- * world-space view/sun vectors — approximately right only for a level
11524
- * camera. We pass the world normal straight through; fresnel and the new
11525
- * sun glint stay put when the camera rolls.
11526
- *
11527
- * Water v2 additions (each marked `incanto v2` inline):
11528
- * - Sun specular: tight Blinn-Phong glint + broad soft sheen off the
11529
- * detail-perturbed normal vs `uSunDirection` — the sparkling light path.
11530
- * - Animated detail normals: two procedural high-frequency simplex gradient
11531
- * layers (different scales/directions/speeds, finite-differenced — no
11532
- * texture asset) perturb the analytic vertex normal, `uDetailStrength`.
11533
- * - Beer's-law absorption: `exp(-uAbsorption · waterViewDepth)` blends the
11534
- * refracted scene into the ramp color — clear turquoise shallows, dark
11535
- * depths. Depth comes from the always-on (when rendered) scene pre-pass,
11536
- * gated by `uUseSceneDepth` so headless/first-frame shading stays flat.
11537
- * - Screen-space refraction: the same pre-pass' color target (`uSceneColor`)
11538
- * sampled with a detail-normal offset — submerged geometry shimmers.
11539
- *
11540
- * Atmosphere additions (marked `incanto atmosphere`): the fragment ends with
11541
- * three's `tonemapping_fragment` + `colorspace_fragment` + `fog_fragment`
11542
- * chunks (the SAME output stage the built-in materials run), and both stages
11543
- * carry the standard fog pars/vertex chunks — so ACES tone mapping,
11544
- * sRGB output and `environment.fog` hit water exactly like the terrain.
11545
- */
11546
- const WATER_VERT = `
11547
- precision highp float;
11548
-
11549
- // incanto atmosphere: three's linear fog rides the standard chunks (compiled
11550
- // out unless the scene declares fog — USE_FOG comes from material.fog)
11551
- #include <fog_pars_vertex>
11552
-
11553
- const float WAVE_WORLD_REF = 128.0;
11554
-
11555
- uniform float uTime;
11556
-
11557
- uniform float uWavesAmplitude;
11558
- uniform float uWavesSpeed;
11559
- uniform float uWavesFrequency;
11560
- uniform float uWavesPersistence;
11561
- uniform float uWavesLacunarity;
11562
- uniform float uWavesIterations;
11563
- uniform vec3 uScale;
11564
- // incanto: world-anchor + interaction ripples (x, z, age, amplitude)
11565
- uniform vec3 uWaterCenter;
11566
- uniform vec4 uRipples[8];
11567
- // incanto swell: directional traveling wave trains (0 = off — legacy look).
11568
- // uSwellDir is a unit XZ vector; amplitude in meters; wavelength in meters.
11569
- uniform float uSwellAmplitude;
11570
- uniform vec2 uSwellDir;
11571
- uniform float uSwellWavelength;
11572
- // incanto shore: the SWASH SURGE shares the shore-waves dial — a very long
11573
- // slow bulge marching shoreward; ±9 cm of water height walks the waterline
11574
- // meters up and down a gentle beach (the lapping runup).
11575
- uniform float uShoreWaves;
11576
- // incanto presets: the edge-fade ring exists to melt BIG water into terrain
11577
- // shores — inside an exact-walled basin (pool/pond) it carves a visible
11578
- // elliptical cliff instead, so those presets turn it off.
11579
- uniform float uEdgeFadeOn;
11580
-
11581
- varying vec3 vNormal;
11582
- varying vec3 vWorldPosition;
11583
- varying vec4 vScreenPosition;
11584
-
11585
- // Simplex 3D Noise
11586
- // by Ian McEwan, Stefan Gustavson (https://github.com/stegu/webgl-noise)
11587
- //
11588
- vec4 permute(vec4 x) {
11589
- return mod(((x * 34.0) + 1.0) * x, 289.0);
11590
- }
11591
- vec4 taylorInvSqrt(vec4 r) {
11592
- return 1.79284291400159 - 0.85373472095314 * r;
11593
- }
11594
-
11595
- // Simplex 2D noise
11596
- //
11597
- vec3 permute(vec3 x) {
11598
- return mod(((x * 34.0) + 1.0) * x, 289.0);
11599
- }
11600
-
11601
- float snoise(vec2 v) {
11602
- const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
11603
- vec2 i = floor(v + dot(v, C.yy));
11604
- vec2 x0 = v - i + dot(i, C.xx);
11605
- vec2 i1;
11606
- i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
11607
- vec4 x12 = x0.xyxy + C.xxzz;
11608
- x12.xy -= i1;
11609
- i = mod(i, 289.0);
11610
- vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
11611
- vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
11612
- m = m * m;
11613
- m = m * m;
11614
- vec3 x = 2.0 * fract(p * C.www) - 1.0;
11615
- vec3 h = abs(x) - 0.5;
11616
- vec3 ox = floor(x + 0.5);
11617
- vec3 a0 = x - ox;
11618
- m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
11619
- vec3 g;
11620
- g.x = a0.x * x0.x + h.x * x0.y;
11621
- g.yz = a0.yz * x12.xz + h.yz * x12.yw;
11622
- return 130.0 * dot(m, g);
11623
- }
11624
-
11625
- // Helper function to calculate elevation at any point
11626
- float getElevation(float x, float z) {
11627
- // World-metre wavelength: the source normalized by the water's own size,
11628
- // which shrinks wavelengths on small pools into storm spikes. A fixed
11629
- // 128 m reference reproduces the source's look (256 m lake) at ANY size.
11630
- vec2 pos = vec2(x / WAVE_WORLD_REF, z / WAVE_WORLD_REF);
11631
-
11632
- float elevation = 0.0;
11633
- float amplitude = 1.0;
11634
- float frequency = uWavesFrequency;
11635
- vec2 p = pos.xy;
11636
-
11637
- for(float i = 0.0; i < uWavesIterations; i++) {
11638
- float noiseValue = snoise(p * frequency + uTime * uWavesSpeed);
11639
- elevation += amplitude * noiseValue;
11640
- amplitude *= uWavesPersistence;
11641
- frequency *= uWavesLacunarity;
11642
- }
11643
-
11644
- elevation *= uWavesAmplitude;
11645
-
11646
- return elevation;
11647
- }
11648
-
11649
- // incanto: expanding character-interaction rings — constants mirror
11650
- // RIPPLE_K/W/DECAY/SPREAD in src/3d/water/interaction.ts (keep in sync).
11651
- float getRippleHeight(vec2 p) {
11652
- float h = 0.0;
11653
- for (int i = 0; i < 8; i++) {
11654
- vec4 r = uRipples[i];
11655
- if (r.w <= 0.0) continue;
11656
- float d = distance(p, r.xy);
11657
- h += r.w * sin(6.0 * d - 8.0 * r.z) * exp(-1.1 * r.z) * exp(-0.15 * d * d);
11658
- }
11659
- return h;
11660
- }
11661
-
11662
- // incanto swell: one peaked traveling wave train. Deep-water dispersion
11663
- // (c = sqrt(g/k)) gives each wavelength its REAL speed, so short chop
11664
- // overtakes long swell exactly like the open sea. The pow-shaped profile
11665
- // sharpens crests and flattens troughs (a height-only Gerstner stand-in —
11666
- // no XZ pinch, so the analytic finite-difference normals stay exact).
11667
- float swellWave(vec2 p, vec2 dir, float lambda, float amp, float sharp) {
11668
- float k = 6.2831853 / max(lambda, 1.0);
11669
- float c = sqrt(9.8 / k);
11670
- float s = 0.5 + 0.5 * sin(dot(p, dir) * k - uTime * c * k);
11671
- return amp * (pow(s, sharp) * 2.0 - 1.0);
11672
- }
11673
-
11674
- // incanto swell: three trains — the primary + two shorter crossing sets
11675
- // (+25° / −19°) so crest lines stay broken and organic, never a zebra field.
11676
- float getSwell(vec2 p) {
11677
- if (uSwellAmplitude <= 0.0) return 0.0;
11678
- vec2 d1 = uSwellDir;
11679
- vec2 d2 = normalize(vec2(d1.x * 0.906 - d1.y * 0.423, d1.x * 0.423 + d1.y * 0.906));
11680
- vec2 d3 = normalize(vec2(d1.x * 0.946 + d1.y * 0.326, -d1.x * 0.326 + d1.y * 0.946));
11681
- return swellWave(p, d1, uSwellWavelength, uSwellAmplitude * 0.60, 2.6)
11682
- + swellWave(p, d2, uSwellWavelength * 0.53, uSwellAmplitude * 0.27, 2.2)
11683
- + swellWave(p, d3, uSwellWavelength * 0.31, uSwellAmplitude * 0.16, 1.8);
11684
- }
11685
-
11686
- // incanto: waves + swell + ripples — used for displacement AND the normal
11687
- // samples so every layer catches the light like real waves.
11688
- // incanto shore: two incommensurate long surges — the waterline breathes in
11689
- // wave-sets instead of a metronome tide
11690
- float getSwash(vec2 p) {
11691
- if (uShoreWaves <= 0.0) return 0.0;
11692
- float along = dot(p, uSwellDir);
11693
- return uShoreWaves *
11694
- (0.06 * sin(along * 0.045 - uTime * 0.42) + 0.045 * sin(along * 0.11 - uTime * 0.71 + 1.3));
11695
- }
11696
-
11697
- float getHeight(float x, float z) {
11698
- return getElevation(x, z) + getSwell(vec2(x, z)) + getSwash(vec2(x, z)) +
11699
- getRippleHeight(vec2(x, z));
11700
- }
11701
-
11702
- void main() {
11703
- vec4 modelPosition = modelMatrix * vec4(position, 1.0);
11704
-
11705
- float elevation = getHeight(modelPosition.x, modelPosition.z);
11706
- modelPosition.y += elevation;
11707
-
11708
- // Height adjustment to smooth water edges
11709
- // Lower water height as it approaches scale boundaries
11710
- // incanto: measured from the node's center, not the world origin
11711
- vec2 normalizedPos = (modelPosition.xz - uWaterCenter.xz) / vec2(uScale.x, uScale.z);
11712
- float distanceFromCenter = length(normalizedPos);
11713
-
11714
- // Gradually lower water height near edges
11715
- float edgeFadeStart = 0.85; // Start fade from 85% point
11716
- float edgeFadeEnd = 0.98; // Completely lowered at 98% point
11717
-
11718
- if (uEdgeFadeOn > 0.5 && distanceFromCenter >= edgeFadeStart) {
11719
- float fadeAmount = smoothstep(edgeFadeStart, edgeFadeEnd, distanceFromCenter);
11720
- float heightReduction = fadeAmount * 0.5; // Lower up to 0.5 units maximum
11721
- modelPosition.y -= heightReduction;
11722
- }
11723
-
11724
- // Adjust appropriate eps value according to scale
11725
- float eps = 0.001 * max(uScale.x, uScale.z);
11726
-
11727
- // Calculate normal using partial derivatives with scaled eps
11728
- vec3 tangent = normalize(vec3(eps, getHeight(modelPosition.x + eps, modelPosition.z) - elevation, 0.0));
11729
- vec3 bitangent = normalize(vec3(0.0, getHeight(modelPosition.x, modelPosition.z + eps) - elevation, eps));
11730
- vec3 objectNormal = normalize(cross(bitangent, tangent));
11731
-
11732
- // incanto: tangent/bitangent above are WORLD-space — pass the world normal
11733
- // straight through (the source's normalMatrix mapped it into VIEW space and
11734
- // then dotted it against world vectors in the fragment shader).
11735
- vNormal = objectNormal;
11736
- vWorldPosition = modelPosition.xyz;
11737
-
11738
- gl_Position = projectionMatrix * viewMatrix * modelPosition;
11739
- vScreenPosition = gl_Position;
11740
-
11741
- // incanto atmosphere: fog depth from the displaced surface position
11742
- vec4 mvPosition = viewMatrix * modelPosition;
11743
- #include <fog_vertex>
11744
- }
11745
- `;
11746
- const WATER_FRAG = `
11747
- precision highp float;
11748
-
11749
- // incanto atmosphere: standard fog chunk (compiled out without scene fog)
11750
- #include <fog_pars_fragment>
11751
-
11752
- uniform float uOpacity;
11753
-
11754
- uniform vec3 uTroughColor;
11755
- uniform vec3 uSurfaceColor;
11756
- uniform vec3 uPeakColor;
11757
-
11758
- uniform float uPeakThreshold;
11759
- uniform float uPeakTransition;
11760
- uniform float uTroughThreshold;
11761
- uniform float uTroughTransition;
11762
-
11763
- uniform float uFresnelScale;
11764
- uniform float uFresnelPower;
11765
- uniform vec3 uScale;
11766
- // incanto: world-anchor (the color ramp is relative to the waterline)
11767
- uniform vec3 uWaterCenter;
11768
- // incanto: 0 until the first cube render — the source declared but never read it
11769
- uniform float uUseEnvironmentMap;
11770
-
11771
- // incanto v2: sun specular (glint + sheen) off the detail-perturbed normal
11772
- uniform vec3 uSunDirection;
11773
- uniform vec3 uSunColor;
11774
- uniform float uSunIntensity;
11775
- // incanto v2: animated procedural detail-normal strength
11776
- uniform float uDetailStrength;
11777
- // incanto v2: Beer's-law absorption (per meter of water along the view ray)
11778
- uniform float uAbsorption;
11779
- // incanto v2: scene pre-pass gates — 0 headless / before the first render
11780
- uniform float uUseSceneDepth;
11781
- uniform float uUseRefraction;
11782
- // incanto v2: half-res scene color grab (the refracted underwater view).
11783
- // grab-clip: Water3D renders this pass clipped at the waterline, so it holds
11784
- // ONLY underwater geometry — refraction may sample it freely (the halo
11785
- // guards this freed us from lived here; see _onRender3D). uDepthTexture is
11786
- // NOT clipped (a depth-only completion pass restores the above-water scene),
11787
- // so depth always means the true nearest occluder — 'no sample' never gets
11788
- // mistaken for 'open water' (ref-26's shoreline seam).
11789
- uniform sampler2D uSceneColor;
11790
-
11791
- // Uniforms for foam effects
11792
- uniform float uEnableFoam;
11793
- uniform vec3 uFoamColor;
11794
- uniform float uFoamIntensity;
11795
- uniform float uFoamRange;
11796
- uniform float uFoamScale;
11797
- uniform sampler2D uDepthTexture;
11798
- uniform float uCameraNear;
11799
- uniform float uCameraFar;
11800
- uniform float uTime;
11801
-
11802
- // incanto: splash WHITEWATER — the ripple buffer (also bound in the vertex
11803
- // shader) drives expanding foam on the surface; uSplashFoam = Water3D.splash.
11804
- uniform vec4 uRipples[8];
11805
- uniform float uRippleFoam[8];
11806
- uniform float uSplashFoam;
11807
-
11808
- // incanto swell: open-water WHITECAPS — foam breaking on the highest crests,
11809
- // independent of the shoreline depth mask. uWaveEnvelope is the JS-computed
11810
- // total wave amplitude (FBM + swell) so the crest mask normalizes by the
11811
- // actual sea state, not the surface's extent. uSwellAmplitude gates the
11812
- // swell-only color work (crest banding + wave-face shading).
11813
- uniform float uWhitecaps;
11814
- uniform float uWaveEnvelope;
11815
- uniform float uSwellAmplitude;
11816
- // incanto shore: BREAKERS — foam fronts marching down the depth gradient
11817
- // toward the beach. Phase rides the VERTICAL water depth, so the fronts hug
11818
- // the shore's depth contours automatically and roll shoreward with time.
11819
- uniform float uShoreWaves;
11820
-
11821
- // incanto presets: above-water CAUSTICS — the dancing light web on the
11822
- // submerged bottom, seen THROUGH the surface (the pool/shallow-sea look).
11823
- // Intensity 0 = off. The pattern fn is shared with the underwater composite.
11824
- uniform float uCausticsAbove;
11825
- // incanto presets: per-preset reflection ceiling (the old hard 0.6) — lakes
11826
- // push toward mirror, pools stay glassy-clear.
11827
- uniform float uReflectivityMax;
11828
-
11829
- ${CAUSTIC_GLSL}
11830
-
11831
- // 🎨 Contact surface color customization uniforms
11832
- uniform vec3 uEdgeColor;
11833
- uniform float uEdgeIntensity;
11834
- uniform float uEdgeWidth;
11835
-
11836
- varying vec3 vNormal;
11837
- varying vec3 vWorldPosition;
11838
- varying vec4 vScreenPosition;
11839
-
11840
- uniform samplerCube uEnvironmentMap;
11841
- // incanto MIRROR: a planar reflection rendered from the camera mirrored across
11842
- // the water plane. A water-centered cube map can only carry DISTANT content —
11843
- // the shoreline trees, the boat, the player standing at the edge are exactly
11844
- // what a still lake is supposed to show, and only a planar pass has them.
11845
- // uMirrorMatrix projects a world position into that render's UV, so the sample
11846
- // is self-consistent no matter where the fragment sits on the surface.
11847
- uniform sampler2D uMirrorMap;
11848
- uniform mat4 uMirrorMatrix;
11849
- uniform float uUseMirror;
11850
- uniform float uMirrorDistort;
11851
-
11852
- // Noise function (for foam patterns)
11853
- float hash(vec2 p) {
11854
- return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
11855
- }
11856
-
11857
- float noise(vec2 p) {
11858
- vec2 i = floor(p);
11859
- vec2 f = fract(p);
11860
- f = f * f * (3.0 - 2.0 * f);
11861
-
11862
- float a = hash(i);
11863
- float b = hash(i + vec2(1.0, 0.0));
11864
- float c = hash(i + vec2(0.0, 1.0));
11865
- float d = hash(i + vec2(1.0, 1.0));
11866
-
11867
- return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
11868
- }
11869
-
11870
- // incanto v2: the SAME simplex the vertex waves use (Ian McEwan, Stefan
11871
- // Gustavson — https://github.com/stegu/webgl-noise), here feeding the
11872
- // procedural detail normals so the package stays texture-free.
11873
- vec3 permute(vec3 x) {
11874
- return mod(((x * 34.0) + 1.0) * x, 289.0);
11875
- }
11876
-
11877
- float snoise(vec2 v) {
11878
- const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
11879
- vec2 i = floor(v + dot(v, C.yy));
11880
- vec2 x0 = v - i + dot(i, C.xx);
11881
- vec2 i1;
11882
- i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
11883
- vec4 x12 = x0.xyxy + C.xxzz;
11884
- x12.xy -= i1;
11885
- i = mod(i, 289.0);
11886
- vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
11887
- vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
11888
- m = m * m;
11889
- m = m * m;
11890
- vec3 x = 2.0 * fract(p * C.www) - 1.0;
11891
- vec3 h = abs(x) - 0.5;
11892
- vec3 ox = floor(x + 0.5);
11893
- vec3 a0 = x - ox;
11894
- m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
11895
- vec3 g;
11896
- g.x = a0.x * x0.x + h.x * x0.y;
11897
- g.yz = a0.yz * x12.xz + h.yz * x12.yw;
11898
- return 130.0 * dot(m, g);
11899
- }
11900
-
11901
- // refinement pass: Beer's law per CHANNEL — red is absorbed first, blue/green
11902
- // linger, so a sandy bottom turns turquoise long before it turns deep blue
11903
- // (the wide turquoise→blue gradient of a real tropical shallows). The scalar
11904
- // uAbsorption prop scales this fixed spectral shape.
11905
- const vec3 ABSORB_TINT = vec3(2.4, 0.7, 0.5);
11906
-
11907
- // incanto v2: finite-difference gradient of the simplex height field
11908
- vec2 noiseGrad(vec2 p) {
11909
- const float e = 0.06;
11910
- float h = snoise(p);
11911
- return vec2(snoise(p + vec2(e, 0.0)) - h, snoise(p + vec2(0.0, e)) - h) / e;
11912
- }
11913
-
11914
- // incanto v2: animated detail-normal layers — the high-frequency perturbation
11915
- // that makes the surface feel LIQUID up close (the vertex FBM is far too
11916
- // coarse to catch light per-pixel). Per-layer distance fades stay (procedural
11917
- // noise has no mips — past its range a layer aliases into white speckle).
11918
- //
11919
- // refinement pass 3: ANISOTROPIC wind-stretched ripple. The old isotropic
11920
- // simplex layers perturbed the normal in soft round BLOBS — under the sun
11921
- // they read as sticky slime / 액체괴물, never as water. Real wind ripple is
11922
- // (a) stretched ALONG the wind, (b) much finer than swell, and (c) crisper
11923
- // than smooth lumps. So: the noise domain is compressed along a wind
11924
- // direction and the two fine layers run in CROSSING directions (±~25° off
11925
- // the wind). dapple pass (ref-27): full ridge sharpening (1-|n|) at 2.8×
11926
- // stretch folded the field into LONG PARALLEL CREASES — fabric wrinkles,
11927
- // not water. Stretch comes down to 1.7×, the ridge transform is BLENDED
11928
- // ~half toward plain simplex (soft short crests, no continuous fold lines),
11929
- // the mid layer's frequency rises so features shorten, and the two crossing
11930
- // layers sit close in weight so no single direction dominates. One coarse
11931
- // smooth layer keeps long-swell coherence underneath. The line this walks:
11932
- // PR-J's isotropic goop ←→ the crease field this replaces.
11933
- // ============ the WATER-FEEL rewrite (the syrup fix) ============
11934
- // The old detail layers had two viscosity bugs that made every surface read
11935
- // as 꿀렁꿀렁한 gel instead of water:
11936
- // 1. WRONG SPEED: each ripple layer drifted ~10× slower than a real wave
11937
- // of its wavelength. Deep-water dispersion says a λ-meter wave travels
11938
- // at c = sqrt(gλ/2π) — a 40 cm ripple crosses ITSELF ~2.7× per second.
11939
- // Slow fine ripples are exactly what honey looks like.
11940
- // 2. PATTERN SLIDING: a translated noise field reads as one sticker being
11941
- // dragged (cellophane). Real ripple CHURNS — crests appear, interfere
11942
- // and die. The fine octaves are therefore INTERFERENCE PAIRS: the same
11943
- // wavelength read twice in slightly split directions and speeds; their
11944
- // sum morphs continuously instead of sliding.
11945
- // Four octaves, each advected at its REAL phase speed (in noise-space,
11946
- // phase = c/λ per second — short waves flicker fast, the tell of thin
11947
- // mobile water): λ 6.0 m → 0.51/s · 2.2 m → 0.84/s · 0.9 m → 1.31/s ·
11948
- // 0.38 m → 2.02/s.
11949
- const vec2 WIND_DIR = vec2(0.8192, 0.5735);
11950
-
11951
- vec2 octaveSlope(vec2 p, vec2 dir, float lambda, float t) {
11952
- // advect ALONG the octave's travel direction at the dispersion speed
11953
- float phase = sqrt(1.56 / lambda) * t; // c/λ = sqrt(g/(2πλ)) · … ≈ sqrt(1.56/λ)
11954
- return noiseGrad(p / lambda + dir * phase);
11955
- }
11956
- // interference pair: same wavelength, directions split ±14°, speeds split
11957
- // ±12% — the sum churns (crests are born and die) instead of sliding.
11958
- vec2 octavePair(vec2 p, vec2 dir, float lambda, float t) {
11959
- vec2 dA = normalize(dir + 0.25 * vec2(-dir.y, dir.x));
11960
- vec2 dB = normalize(dir - 0.25 * vec2(-dir.y, dir.x));
11961
- float phase = sqrt(1.56 / lambda) * t;
11962
- return noiseGrad(p / lambda + dA * (phase * 1.12))
11963
- + noiseGrad(p / lambda * 1.07 + dB * (phase * 0.88) + 17.0);
11964
- }
11965
-
11966
- vec3 detailNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
11967
- float fadeCoarse = 1.0 - smoothstep(90.0, 420.0, camDist);
11968
- float fadeMid = 1.0 - smoothstep(40.0, 170.0, camDist);
11969
- float fadeFine = 1.0 - smoothstep(18.0, 80.0, camDist);
11970
- float fadeMicro = 1.0 - smoothstep(8.0, 34.0, camDist);
11971
- vec2 dMid = normalize(vec2(0.62, 0.78));
11972
- vec2 slope =
11973
- octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse)
11974
- + octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.10 * fadeMid)
11975
- + octavePair(worldXZ, WIND_DIR, 0.9, uTime) * (0.055 * fadeFine)
11976
- + octavePair(worldXZ, dMid, 0.38, uTime) * (0.042 * fadeMicro);
11977
- slope *= strength * 3.0; // spectrum norm: default strength 0.26 ≈ calm-lively
11978
- return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
11979
- }
11980
-
11981
- // The GLOSS normal — what fresnel, the reflection and the refraction offset
11982
- // read. Only the two long octaves: a mirror the size of a pixel integrates
11983
- // micro-ripple away, so reflections stay smooth and ROUNDED (the glassy
11984
- // pool-reference surface); the fast fine churn lives in the sun glitter and
11985
- // the body shading, never as reflection speckle.
11986
- vec3 glossNormal(vec3 n, vec2 worldXZ, float strength, float camDist) {
11987
- float fadeCoarse = 1.0 - smoothstep(90.0, 420.0, camDist);
11988
- float fadeMid = 1.0 - smoothstep(40.0, 170.0, camDist);
11989
- vec2 dMid = normalize(vec2(0.62, 0.78));
11990
- vec2 slope =
11991
- octaveSlope(worldXZ, WIND_DIR, 6.0, uTime) * (0.085 * fadeCoarse)
11992
- + octaveSlope(worldXZ, dMid, 2.2, uTime) * (0.075 * fadeMid);
11993
- slope *= strength * 3.0;
11994
- return normalize(vec3(n.x - slope.x, n.y, n.z - slope.y));
11995
- }
11996
-
11997
- // Linear depth conversion
11998
- float linearizeDepth(float depth, float near, float far) {
11999
- float z = depth * 2.0 - 1.0;
12000
- return (2.0 * near * far) / (far + near - z * (far - near));
12001
- }
12002
-
12003
- // incanto: splash WHITEWATER. The look that read as FAKE was a clean radial
12004
- // disc/ring × speckle. Real foam is irregular clumps + tendrils that drift with
12005
- // the water and break apart at the edges. So: a soft COVERAGE envelope (where
12006
- // foam can be — an expanding patch that fades over FOAM_MAX_AGE; this is the
12007
- // CPU twin rippleFoamAt, constants MIRRORED) THRESHOLDS a domain-warped
12008
- // fractal noise. Dense coverage → broad froth; thinning coverage (edges / old)
12009
- // raises the threshold → only the noise peaks survive → it erodes into sparse
12010
- // bubbles. The warp + slow drift make it look advected, not stamped.
12011
- float foamFbm(vec2 p) {
12012
- float v = 0.0, a = 0.5;
12013
- for (int i = 0; i < 4; i++) { v += a * noise(p); p = p * 2.02 + 7.3; a *= 0.5; }
12014
- return v; // ~0..0.94
12015
- }
12016
-
12017
- float rippleFoam(vec2 p) {
12018
- if (uSplashFoam < 0.5) return 0.0;
12019
- float cover = 0.0;
12020
- for (int i = 0; i < 8; i++) {
12021
- float foam = uRippleFoam[i];
12022
- if (foam <= 0.0) continue;
12023
- vec4 r = uRipples[i];
12024
- float age = r.z;
12025
- float life = 1.0 - age / 1.6; // FOAM_MAX_AGE
12026
- if (life <= 0.0) continue;
12027
- float d = distance(p, r.xy);
12028
- float radius = 0.4 + 1.15 * age; // FOAM_R0 + FOAM_SPREAD * age
12029
- cover = max(cover, (1.0 - smoothstep(radius * 0.7, radius, d)) * foam * life);
12030
- }
12031
- if (cover <= 0.001) return 0.0;
12032
- // organic clumps: domain-warp the fbm (swirl) + slow drift = advected foam.
12033
- // FINE frequencies (premium foam is many small bubbles, not coarse blobs).
12034
- vec2 flow = vec2(uTime * 0.05, uTime * -0.04);
12035
- vec2 warp = vec2(foamFbm(p * 1.1 + flow), foamFbm(p * 1.1 + 4.2 - flow)) - 0.5;
12036
- float pattern = foamFbm(p * 2.6 + warp * 1.4 + flow);
12037
- // erode: high coverage → low threshold (denser foam); low coverage → high
12038
- // threshold (only the peaks survive → delicate lacy bubbles, soft edges).
12039
- // Raised floor + wide band = sparser + softer = subtler/more refined.
12040
- float thresh = mix(0.36, 0.96, 1.0 - cover);
12041
- float foamHere = smoothstep(thresh, thresh + 0.26, pattern);
12042
- return clamp(foamHere * (0.4 + 0.5 * cover), 0.0, 1.0);
12043
- }
12044
-
12045
- // Enhanced depth-based foam effect calculation
12046
- float calculateFoam() {
12047
- // incanto v2: foam rides the always-on scene depth pass — both gates
12048
- if (uEnableFoam < 0.5 || uUseSceneDepth < 0.5) return 0.0;
12049
-
12050
- // Convert screen coordinates to texture coordinates
12051
- vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
12052
-
12053
- // Sample scene depth value from depth texture
12054
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
12055
- float waterDepth = gl_FragCoord.z;
12056
-
12057
- // Convert to linear depth
12058
- float linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
12059
- float linearWaterDepth = linearizeDepth(waterDepth, uCameraNear, uCameraFar);
12060
-
12061
- // Calculate depth difference (distance between water and other objects)
12062
- float depthDiff = abs(linearSceneDepth - linearWaterDepth);
12063
-
12064
- // Generate foam - stronger in wider range
12065
- float foamFactor = 1.0 - smoothstep(0.0, uFoamRange, depthDiff);
12066
- foamFactor = pow(foamFactor, 0.5); // Softer decay
12067
-
12068
- // Generate strong foam patterns with multi-layer noise
12069
- vec2 foamUV = vWorldPosition.xz * uFoamScale + uTime * 0.2;
12070
- float foamNoise = 0.0;
12071
-
12072
- // Large pattern
12073
- foamNoise += noise(foamUV) * 0.6;
12074
- // Medium pattern
12075
- foamNoise += noise(foamUV * 3.0 + uTime * 0.5) * 0.4;
12076
- // Fine pattern
12077
- foamNoise += noise(foamUV * 8.0 + uTime * 1.0) * 0.3;
12078
- // Micro pattern
12079
- foamNoise += noise(foamUV * 20.0 + uTime * 1.5) * 0.2;
12080
-
12081
- // Normalize noise
12082
- foamNoise /= 1.5;
12083
-
12084
- // refinement pass: WISPY foam — a wide soft ramp instead of the old hard
12085
- // contrast boost, and the constant edge band cut way down so shallows read
12086
- // as noise-broken patches (the reference's channel foam), not a painted rim.
12087
- float foam = foamFactor * foamNoise * uFoamIntensity;
12088
- foam = smoothstep(0.12, 0.95, foam);
12089
-
12090
- // Additional foam layer - edge enhancement (kept faint, noise-broken — a
12091
- // constant term veiled whole shallow ponds in flat white)
12092
- float edgeFoam = foamFactor * foamFactor * 0.18 * (0.35 + 0.65 * foamNoise);
12093
- foam = max(foam, edgeFoam);
12094
-
12095
- // incanto v2: wave/ripple crests breaking NEAR shores kick up extra foam —
12096
- // the elevation ramp's peak factor masked by the shoreline depth factor.
12097
- float crest = smoothstep(uPeakThreshold, uPeakThreshold + uPeakTransition * 2.0,
12098
- (vWorldPosition.y - uWaterCenter.y) / uScale.y);
12099
- foam = max(foam, foamFactor * crest * (0.5 + 0.5 * foamNoise));
12100
-
12101
- return clamp(foam, 0.0, 1.0);
12102
- }
12103
-
12104
- // Calculate smooth color blending at contact surfaces
12105
- float calculateEdgeBlending() {
12106
- // Contact surface effects are part of foam effects (enableFoam is required)
12107
- if (uEnableFoam < 0.5 || uUseSceneDepth < 0.5 || uEdgeIntensity <= 0.0) return 0.0;
12108
-
12109
- vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
12110
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
12111
- float waterDepth = gl_FragCoord.z;
12112
-
12113
- float linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
12114
- float linearWaterDepth = linearizeDepth(waterDepth, uCameraNear, uCameraFar);
12115
-
12116
- float depthDiff = abs(linearSceneDepth - linearWaterDepth);
12117
-
12118
- // User-defined range for color blending at contact surfaces
12119
- float blendRange = uFoamRange * uEdgeWidth; // Use user-defined range
12120
- float blendFactor = 1.0 - smoothstep(0.0, blendRange, depthDiff);
12121
-
12122
- return blendFactor; // 0.0 (no effect) ~ 1.0 (maximum blending)
12123
- }
12124
-
12125
- void main() {
12126
- // 🔥 Fundamental solution: Handle overlapping parts with objects
12127
- float depthMask = 1.0; // Default: fully render
12128
-
12129
- // incanto v2: the scene depth pass is always-on for fancy when rendered —
12130
- // the mask (and the water view depth for Beer's law below) now ride
12131
- // uUseSceneDepth instead of the foam toggle.
12132
- vec2 screenUV = vScreenPosition.xy / vScreenPosition.w * 0.5 + 0.5;
12133
- float linearWaterDepth = linearizeDepth(gl_FragCoord.z, uCameraNear, uCameraFar);
12134
- // incanto v2: meters of water along the view ray; -1 = depth unavailable
12135
- float waterViewDepth = -1.0;
12136
- // shoreline-seam fix: hoisted — the refraction block reconstructs the scene
12137
- // sample's world height from it (grab validity, see below)
12138
- float linearSceneDepth = uCameraFar;
12139
-
12140
- if (uUseSceneDepth > 0.5) {
12141
- float sceneDepth = texture2D(uDepthTexture, screenUV).r;
12142
- linearSceneDepth = linearizeDepth(sceneDepth, uCameraNear, uCameraFar);
12143
-
12144
- float depthDiff = linearWaterDepth - linearSceneDepth;
12145
- waterViewDepth = max(-depthDiff, 0.0);
12146
-
12147
- // waterline dither: on a near-flat beach one half-res depth texel spans a
12148
- // wide screen block at grazing view, so these thresholds cut staircase
12149
- // notches along the swash edge — jitter the boundary with world noise and
12150
- // the seam breaks organic. Two octaves at LOW amplitude: a flat slope
12151
- // multiplies vertical jitter into horizontal metres, so a single strong
12152
- // octave carved angular value-noise chunks (the diced-radish waterline bug).
12153
- float seamJitter =
12154
- (noise(vWorldPosition.xz * 3.1) * 0.65 + noise(vWorldPosition.xz * 8.7) * 0.35 - 0.5) * 0.035;
12155
-
12156
- // 🎯 Two-step approach
12157
- // Step 1: Discard completely overlapping parts
12158
- float hardDiscardThreshold = 0.1 + seamJitter;
12159
- if (depthDiff > hardDiscardThreshold) {
12160
- discard; // Completely remove definitively overlapping pixels
12161
- }
12162
-
12163
- // Step 2: Gradual transparency for border parts
12164
- float softDiscardStart = -0.05 + seamJitter; // Start fade slightly ahead
12165
- float softDiscardEnd = hardDiscardThreshold;
12166
-
12167
- if (depthDiff > softDiscardStart) {
12168
- depthMask = 1.0 - smoothstep(softDiscardStart, softDiscardEnd, depthDiff);
12169
- // At border: 0 (fully transparent) ~ 1 (fully opaque)
12170
- }
12171
- }
12172
-
12173
- // Calculate vector from camera to the vertex
12174
- vec3 viewDirection = normalize(vWorldPosition - cameraPosition);
12175
- // incanto v2: perturb the analytic vertex normal with the animated detail
12176
- // layers — fresnel, reflection, refraction and the sun glint all read it.
12177
- // refinement pass: the per-LAYER distance fades live inside detailNormal,
12178
- // and fresnel/reflection read a distance-SMOOTHED normal — a pixel at range
12179
- // integrates many ripples, so its fresnel must converge to the swell's, not
12180
- // flicker per ripple (the old per-pixel fresnel turned the whole mid-field
12181
- // into white sky-reflection sequins). The sun glint keeps the full detail
12182
- // normal: distant glitter is real and reads as sparkle, not noise.
12183
- float camDist = length(vWorldPosition - cameraPosition);
12184
- vec3 geoNormal = normalize(vNormal);
12185
- vec3 normal = detailNormal(geoNormal, vWorldPosition.xz, uDetailStrength, camDist);
12186
- // refinement pass 3: the GEO normal must flatten with distance too — the
12187
- // per-vertex FBM normal varies per pixel near the horizon (its features go
12188
- // subpixel), and against the bright sun-side sky every flicker becomes a
12189
- // white sequin. A pixel at range integrates whole wave trains: its normal
12190
- // converges to flat-up, so fresnel/reflection/streak read exactly that.
12191
- float geoFlat = 1.0 - 0.9 * smoothstep(150.0, 600.0, camDist);
12192
- vec3 geoSmooth = normalize(mix(vec3(0.0, 1.0, 0.0), geoNormal, geoFlat));
12193
- float fresnelDetail = 1.0 - 0.85 * smoothstep(20.0, 120.0, camDist);
12194
- // eztree-parity pass: hoisted — fresnel AND the refraction offset read it.
12195
- // crisp-water fix: NEAR the camera the reflection must read the FULL
12196
- // ripple spectrum (sky reflections shatter into crisp shards at arm's
12197
- // length — reading only the smooth octaves rendered soft lava-lamp blobs);
12198
- // with distance it hands over to the smooth gloss so the far field never
12199
- // speckles against the HDR sky.
12200
- vec3 gloss = glossNormal(geoNormal, vWorldPosition.xz, uDetailStrength, camDist);
12201
- vec3 crisp = mix(normal, gloss, smoothstep(6.0, 26.0, camDist));
12202
- vec3 fresnelNormal = normalize(mix(geoSmooth, crisp, fresnelDetail));
12203
- vec3 reflectedDirection = reflect(viewDirection, fresnelNormal);
12204
- // incanto: the source flipped .x here — correct for cube textures LOADED
12205
- // from images, but a left/right mirror for CubeCamera render targets
12206
- // (three samples those with flipEnvMap = +1). Verified live: a marker
12207
- // object's reflection appeared on the opposite side with the flip.
12208
- // incanto: fold reflection rays above the horizon — the lower hemisphere
12209
- // of a water-centered cube map is parallax garbage (the ground under and
12210
- // around the pool) and painted ink-black wavelet patches at grazing angles;
12211
- // the +y bias keeps samples off the smeared horizon seam. refinement pass:
12212
- // 0.1 → 0.15 — the physical sky's brightest haze hugs the horizon, and
12213
- // grazing water sampled it into a blown-white band; a touch higher samples
12214
- // believable pale blue instead.
12215
- reflectedDirection.y = max(abs(reflectedDirection.y), 0.15);
12216
-
12217
- // Sample environment map to get the reflected color
12218
- // refinement pass 3: soft-cap the HDR radiance — the physical sky's
12219
- // horizon haze runs ≫1 and a single bright reflection pixel reads as a
12220
- // white sequin after ACES; 2.5 still tone-maps near-white for real sky
12221
- // reflections without letting one texel blow out.
12222
- vec4 reflectionColor = textureCube(uEnvironmentMap, reflectedDirection);
12223
- reflectionColor.rgb = min(reflectionColor.rgb, vec3(2.5));
12224
-
12225
- // incanto MIRROR: where the planar pass has data it REPLACES the cube — it
12226
- // is the same sky plus everything the cube map is blind to. Rays that leave
12227
- // the screen have no mirror sample, so the border fade hands those back to
12228
- // the cube instead of clamping a stretched edge texel across the surface.
12229
- if (uUseMirror > 0.5) {
12230
- vec4 mirrorClip = uMirrorMatrix * vec4(vWorldPosition, 1.0);
12231
- vec2 mirrorUv = mirrorClip.xy / max(mirrorClip.w, 1e-4);
12232
- // the ripple tilt bends the reflected ray: offset the sample by how far
12233
- // the wavelet normal leans off flat, damped with distance so the far field
12234
- // (many wavelets per pixel) stays a clean mirror instead of a jitter field
12235
- vec2 lean = (fresnelNormal.xz - geoNormal.xz) * uMirrorDistort;
12236
- mirrorUv += lean * (1.0 - smoothstep(20.0, 90.0, camDist));
12237
- vec2 edge = smoothstep(vec2(0.0), vec2(0.06), mirrorUv) *
12238
- (1.0 - smoothstep(vec2(0.94), vec2(1.0), mirrorUv));
12239
- float valid = edge.x * edge.y * step(0.0, mirrorClip.w);
12240
- if (valid > 0.0) {
12241
- vec3 mirrored = min(texture2D(uMirrorMap, mirrorUv).rgb, vec3(2.5));
12242
- reflectionColor.rgb = mix(reflectionColor.rgb, mirrored, valid);
12243
- }
12244
- }
12245
-
12246
- // Calculate fresnel effect
12247
- // incanto: the source dotted the camera→surface direction against the
12248
- // up-facing normal — NEGATIVE at every above-water angle, so the clamp
12249
- // pinned fresnel at its maximum and the whole surface was a constant-ratio
12250
- // mirror. Negate it (surface→camera) for real angle-dependent fresnel:
12251
- // steep view = water color, grazing view = sky mirror.
12252
- // incanto atmosphere: full Schlick — the F0 floor (2%, water's real normal-
12253
- // incidence reflectance) keeps a whisper of sky at steep angles while the
12254
- // default exponent 5 stops the HDR sky from whitewashing the whole surface.
12255
- float fresnel = uFresnelScale * (0.02 + 0.98 * pow(1.0 - clamp(dot(-viewDirection, fresnelNormal), 0.0, 1.0), uFresnelPower));
12256
-
12257
- // Calculate scale-normalized height value
12258
- // incanto: relative to the waterline, not absolute world y
12259
- float normalizedElevation = (vWorldPosition.y - uWaterCenter.y) / uScale.y;
12260
-
12261
- // Calculate transition factors using smoothstep
12262
- float peakFactor = smoothstep(uPeakThreshold - uPeakTransition, uPeakThreshold + uPeakTransition, normalizedElevation);
12263
- float troughFactor = smoothstep(uTroughThreshold - uTroughTransition, uTroughThreshold + uTroughTransition, normalizedElevation);
12264
-
12265
- // Mix between trough and surface colors based on trough transition
12266
- vec3 mixedColor1 = mix(uTroughColor, uSurfaceColor, troughFactor);
12267
-
12268
- // Mix between surface and peak colors based on peak transition
12269
- vec3 mixedColor2 = mix(mixedColor1, uPeakColor, peakFactor);
12270
-
12271
- // incanto swell: the legacy ramp normalizes elevation by the surface's
12272
- // HALF-EXTENT — on a real ocean plane that's hundreds of meters, so the
12273
- // ramp never sees a wave. When swell is on, re-read the crest height
12274
- // against the actual sea state (uWaveEnvelope) and (a) lighten crest tops
12275
- // toward the peak color, (b) shade the wave FACES with a gentle N·L off
12276
- // the distance-smoothed geometry normal — the lit/shadow lanes are what
12277
- // make open-ocean swell READ at any distance. Both gated: swell 0 keeps
12278
- // the legacy look byte-identical.
12279
- if (uSwellAmplitude > 0.0) {
12280
- float crestBand = smoothstep(0.3, 0.9,
12281
- (vWorldPosition.y - uWaterCenter.y) / max(uWaveEnvelope, 0.001));
12282
- mixedColor2 = mix(mixedColor2, uPeakColor, crestBand * 0.45);
12283
- float faceShade = clamp(dot(normalize(mix(vec3(0.0, 1.0, 0.0), normalize(vNormal),
12284
- 1.0 - 0.9 * smoothstep(150.0, 600.0, length(vWorldPosition - cameraPosition)))),
12285
- normalize(uSunDirection)), 0.0, 1.0);
12286
- mixedColor2 *= 0.80 + 0.32 * faceShade;
12287
- }
12288
-
12289
- // incanto v2: Beer's-law depth absorption + screen-space refraction.
12290
- // Shallow water transmits the (refracted) scene almost untouched; depth
12291
- // exponentially absorbs it into the ramp color — clear turquoise edges,
12292
- // dark depths. Headless / first frame: waterViewDepth stays -1 → flat ramp.
12293
- vec3 bodyColor = mixedColor2;
12294
- float bodyOpacity = uOpacity;
12295
- if (waterViewDepth >= 0.0) {
12296
- // refinement pass: per-channel transmittance (see ABSORB_TINT) — the
12297
- // turquoise→deep-blue gradient comes from red dying first, not from a
12298
- // hand-painted band.
12299
- vec3 transmittance = exp(-uAbsorption * ABSORB_TINT * waterViewDepth);
12300
- if (uUseRefraction > 0.5) {
12301
- // eztree-parity pass: the offset reads ONLY the SMOOTH gloss normal —
12302
- // the ridge-sharpened detail normal flips sign across every crest line
12303
- // and combed the submerged scene into jaggy scratch streaks (빗살무늬);
12304
- // even a 15% share re-speckled bright submerged objects.
12305
- vec2 refrSlope = crisp.xz;
12306
- // thickness-scaled parallax: the bottom image's refraction displacement
12307
- // grows with the water column's depth (thin film ≈ no shift, deep water
12308
- // ≈ full shimmer) — zero AT an object's contact line, so a partially
12309
- // submerged object's image stays glued to the surface (the old fixed
12310
- // contact damp), and strongly reduced in knee-deep ponds, where the old
12311
- // distance-only offset flung the pale submerged column into a ragged
12312
- // star splat around its own base (ref-23's waterline blob).
12313
- float contactDamp = clamp(waterViewDepth * 0.5, 0.0, 1.0);
12314
- vec2 refractionOffset = refrSlope * contactDamp * (0.35 / max(linearWaterDepth, 4.0));
12315
- vec2 refractedUV = clamp(screenUV + refractionOffset, vec2(0.001), vec2(0.999));
12316
- // grab-clip: the grab COLOR holds ONLY underwater geometry (Water3D
12317
- // clips that pass at the waterline), so there is no above-water color
12318
- // to bleed and no halo to guard — the PR-K soft reject and PR-L probe
12319
- // ring (whose boundaries became ref-23's silhouette outlines) are gone.
12320
- // One cheap sanity tap remains: an offset landing on geometry in FRONT
12321
- // of this water fragment (the thin waterline band the clip slack keeps,
12322
- // grazing self-overlaps) snaps back to the straight grab pixel…
12323
- float refractedSceneDepth =
12324
- linearizeDepth(texture2D(uDepthTexture, refractedUV).r, uCameraNear, uCameraFar);
12325
- float offsetReject = smoothstep(0.0, 0.05, linearWaterDepth - refractedSceneDepth);
12326
- // …or on an ABOVE-water sample: the depth-completion pass restored its
12327
- // true depth, but the clipped grab has no color there (clear-color
12328
- // texel) — reconstruct its world height along the view ray (view depth
12329
- // is linear in ray distance: t = camDist · sceneDepth/waterDepth) and
12330
- // snap those offsets back too.
12331
- float refrT = camDist * (refractedSceneDepth / max(linearWaterDepth, 1e-4));
12332
- offsetReject = max(offsetReject, smoothstep(0.02, 0.25,
12333
- cameraPosition.y + viewDirection.y * refrT - uWaterCenter.y));
12334
- refractedUV = mix(refractedUV, screenUV, offsetReject);
12335
- // mip-biased grab read: the pre-pass target carries mips (see
12336
- // Water3D._onRender3D) — biasing ~1.75 levels samples a pre-blurred
12337
- // underwater scene. Real water blurs what it refracts; the crisp
12338
- // half-res grab pixel-speckled at every silhouette and ripple edge.
12339
- vec3 sceneColor = texture2D(uSceneColor, refractedUV, 1.75).rgb;
12340
- // incanto presets: above-water caustics — reconstruct the sampled
12341
- // bottom's world position along the view ray and light it with the
12342
- // shared caustic web. Needs a focusing column of water (a centimeters-
12343
- // deep swash film shows no web) and a close viewer: past ~30m each
12344
- // pixel integrates many caustic cells and the additive web averages
12345
- // into a solid white sheet over any flat shallow (aerial sandbar bug).
12346
- if (uCausticsAbove > 0.0) {
12347
- vec3 bottomWorld = cameraPosition + viewDirection * refrT;
12348
- float below = uWaterCenter.y - bottomWorld.y;
12349
- if (below > 0.05) {
12350
- float reach = smoothstep(0.05, 0.5, below) * (1.0 - smoothstep(0.2, 9.0, below));
12351
- float causticFade = 1.0 - smoothstep(28.0, 90.0, camDist);
12352
- float cw = caustic(bottomWorld.xz * 0.42, uTime * 0.7) * 0.65
12353
- + caustic(bottomWorld.xz * 0.72 + 30.0, uTime * 0.55) * 0.35;
12354
- sceneColor += vec3(0.87, 0.96, 1.0) * (cw * uCausticsAbove * reach * causticFade);
12355
- }
12356
- }
12357
- // opacity keeps its dial under in-shader compositing: it scales how
12358
- // strongly the water body hides the Beer-attenuated scene behind it —
12359
- // per channel, so the shallows tint instead of fading uniformly
12360
- vec3 body = clamp(uOpacity * (1.0 - transmittance) * 1.4, 0.0, 1.0);
12361
- // refinement pass 3: crystal shallows (photo reference) — where the
12362
- // water column is thin you SEE THE BOTTOM, ripple-distorted by the
12363
- // detail normals; the body color barely tints it. Clarity ramps the
12364
- // refracted scene up to ~88% inside the first ~1.5 m of water path and
12365
- // hands over to absorption beyond — depth still darkens the bottom.
12366
- float clarity = 0.88 * (1.0 - smoothstep(0.2, 1.5, waterViewDepth));
12367
- body *= 1.0 - clarity;
12368
- // shoreline-seam fallback: when the STRAIGHT sample itself sits above
12369
- // the waterline (beach rising behind a shoreline crest, a piercing
12370
- // object's dry half) the grab texel is clear-color, not the occluder —
12371
- // hand those pixels to the alpha path. The main framebuffer already
12372
- // shows the occluder behind this fragment, so a translucent ramp tint
12373
- // over it is the honest shallow-water look; compositing the clipped
12374
- // grab instead painted ref-26's background-colored band.
12375
- float sceneT = camDist * (linearSceneDepth / max(linearWaterDepth, 1e-4));
12376
- float sceneAbove = smoothstep(0.02, 0.25,
12377
- cameraPosition.y + viewDirection.y * sceneT - uWaterCenter.y);
12378
- float meanAbsorbed = 1.0 - dot(transmittance, vec3(0.299, 0.587, 0.114));
12379
- float alphaOpacity = mix(uOpacity * 0.45, max(uOpacity, 0.95), meanAbsorbed);
12380
- bodyColor = mix(mix(sceneColor, mixedColor2, body), mixedColor2, sceneAbove);
12381
- // in-shader compositing (opacity 1) for valid grab pixels; alpha blend
12382
- // over the main framebuffer for above-water-occluder pixels
12383
- bodyOpacity = mix(1.0, alphaOpacity, sceneAbove);
12384
- } else {
12385
- // no grab pass: alpha does the work — clear shallows, opaque depths
12386
- float meanAbsorbed = 1.0 - dot(transmittance, vec3(0.299, 0.587, 0.114));
12387
- bodyOpacity = mix(uOpacity * 0.45, max(uOpacity, 0.95), meanAbsorbed);
12388
- }
12389
- }
12390
-
12391
- // Mix the final color with the reflection color
12392
- // incanto: gated on uUseEnvironmentMap so an unbound cube map reads flat
12393
- // clamp so grazing angles keep some water colour instead of going pure
12394
- // mirror (dark scenes otherwise paint the near edge black). refinement
12395
- // pass: 0.75 → 0.6 — the reference sea stays teal even at range; an HDR
12396
- // sky at 3/4 mirror washed the whole far field toward white.
12397
- float reflectivity = clamp(fresnel, 0.0, uReflectivityMax) * uUseEnvironmentMap;
12398
- vec3 finalColor = mix(bodyColor, reflectionColor.rgb, reflectivity);
12399
-
12400
- // incanto v2: sun specular off the detail-perturbed normal.
12401
- // refinement pass 3: glints are SPARSE micro-facets. The old 320-power
12402
- // glint + everywhere-sheen flashed on half the ripples in view — the whole
12403
- // sea read as dense white speckle (글로시 슬라임). Now: extreme shininess
12404
- // (1400) so only facets aligned within a fraction of a degree flash, and
12405
- // both terms are weighted toward the sun's AZIMUTH path — crests sparkle
12406
- // along the light path, the rest of the surface stays broad calm color
12407
- // fields (the reference look). The sheen survives only as a soft halo on
12408
- // that path, near-zero elsewhere.
12409
- vec3 sunL = normalize(uSunDirection);
12410
- vec3 halfway = normalize(sunL - viewDirection); // -viewDirection = surface→camera
12411
- float specAngle = max(dot(normal, halfway), 0.0);
12412
- vec2 toFrag = normalize(vWorldPosition.xz - cameraPosition.xz);
12413
- float sunPath = pow(clamp(dot(toFrag, normalize(sunL.xz)), 0.0, 1.0), 5.0);
12414
- // micro-facet glints are a NEAR-field phenomenon on screen: past ~200 m a
12415
- // pixel integrates whole wave trains and per-pixel glints alias back into
12416
- // the old speckle — fade them out and hand the sun path to a SMOOTH streak
12417
- // off the distance-smoothed normal (a real sea's far sun path is a soft
12418
- // continuous band, not glitter).
12419
- // …and a VIEW-angle phenomenon: looking straight down (the photo-ref
12420
- // clarity framing) a real surface shows almost no sparkle — glints live at
12421
- // grazing angles. Weight by a Schlick-like grazing term so steep views
12422
- // stay calm, clear water instead of speckling.
12423
- float grazing = pow(1.0 - clamp(dot(-viewDirection, vec3(0.0, 1.0, 0.0)), 0.0, 1.0), 2.0);
12424
- // water-feel rewrite: NO artificial gate — the micro-ripple spectrum's
12425
- // fast churn makes each facet align with the sun for only a flash, so the
12426
- // glitter is naturally granular AND flickers at ripple speed (the old
12427
- // slow-noise gate was another piece of the syrup feel).
12428
- float glintFade = 1.0 - smoothstep(60.0, 240.0, camDist);
12429
- float glint = pow(specAngle, 900.0) * (0.15 + 0.85 * sunPath) * glintFade
12430
- * (0.1 + 0.9 * grazing);
12431
- float farAngle = max(dot(fresnelNormal, halfway), 0.0);
12432
- float streak = pow(farAngle, 240.0) * 0.45 * sunPath * (1.0 - glintFade);
12433
- float sheen = pow(specAngle, 64.0) * 0.05 * sunPath;
12434
- finalColor += uSunColor * min(uSunIntensity * (glint + streak + sheen), 0.85);
12435
-
12436
- // Calculate and apply foam effects (only when enableFoam is true)
12437
- float foamAmount = 0.0;
12438
- if (uEnableFoam > 0.5) {
12439
- foamAmount = calculateFoam();
12440
- }
12441
- // incanto shore: traveling BREAKERS. The leading edge is sharp (the
12442
- // collapsing face), the tail is a long noisy wash that dissolves seaward
12443
- // of the beach; the whole system lives in the 0.1–3.2 m depth band where
12444
- // real waves shoal and break. Because phase = f(vertical depth), a front
12445
- // is always parallel to the local shoreline — bays curve it, points wrap
12446
- // it, no per-scene wiring.
12447
- if (uShoreWaves > 0.0 && uUseSceneDepth > 0.5 && waterViewDepth >= 0.0) {
12448
- float sceneT2 = camDist * (linearSceneDepth / max(linearWaterDepth, 1e-4));
12449
- float bottomY = cameraPosition.y + viewDirection.y * sceneT2;
12450
- float vertDepth = max(uWaterCenter.y - bottomY, 0.0);
12451
- if (vertDepth > 0.02 && vertDepth < 3.4) {
12452
- // DITHER the depth before phasing: the half-res depth texture
12453
- // quantizes on gentle slopes, and un-dithered fronts render as hard
12454
- // parallel terraces with visible pixel columns.
12455
- float dither = (noise(vWorldPosition.xz * 1.7) - 0.5) * 0.35
12456
- + (noise(vWorldPosition.xz * 6.3 + 11.0) - 0.5) * 0.12;
12457
- float dd = max(vertDepth + dither, 0.0);
12458
- // ONE front per ~1.3 m of depth (the old 0.53 m stacked 3-4 terraces
12459
- // in view at once), soft leading edge, long dissolving wash
12460
- float phase = dd * 0.75 - uTime * 0.4;
12461
- float f = fract(phase);
12462
- float front = smoothstep(0.0, 0.14, f) * (1.0 - smoothstep(0.14, 0.85, f));
12463
- // waves arrive in SETS: a slow alongshore envelope gates whole fronts
12464
- // in and out so the surf breathes instead of ticking like a metronome
12465
- float sets = smoothstep(0.2, 0.75, noise(vWorldPosition.xz * 0.045 + uTime * vec2(0.025, 0.014)));
12466
- float breakup = noise(vWorldPosition.xz * 0.3 + uTime * vec2(0.06, 0.04));
12467
- float lace = noise(vWorldPosition.xz * 2.2 - uTime * vec2(0.18, 0.1));
12468
- front *= (0.45 + 0.55 * breakup) * (0.55 + 0.45 * lace) * (0.35 + 0.65 * sets);
12469
- // no fronts in the centimeters-deep SHEET-FLOW film (an enclosed flat
12470
- // fizzed solid white from the air) — breakers need ~knee depth to form
12471
- float zone = smoothstep(3.4, 1.2, vertDepth) * smoothstep(0.12, 0.38, vertDepth);
12472
- foamAmount = max(foamAmount, clamp(uShoreWaves * front * zone * 1.5, 0.0, 1.0));
12473
- // waterline LACE — fizzing bubbles where the runup meets sand. On a
12474
- // near-FLAT bar the 0–30 cm depth band is tens of meters wide, so an
12475
- // ungated lace carpeted whole flats in white — a slow patch gate keeps
12476
- // it as drifting foam streaks instead (real tidal-flat scum lines).
12477
- float lacePatch = smoothstep(0.4, 0.8, noise(vWorldPosition.xz * 0.5 + uTime * vec2(0.06, 0.045)));
12478
- float lace2 = smoothstep(0.24, 0.02, vertDepth) *
12479
- (0.35 + 0.65 * noise(vWorldPosition.xz * 3.1 + uTime * vec2(0.5, 0.35)));
12480
- foamAmount = max(foamAmount, clamp(uShoreWaves * lace2 * lacePatch * 0.4, 0.0, 1.0));
12481
- }
12482
- }
12483
- // incanto swell: whitecaps — foam eats the TOPS of the tallest crests in
12484
- // open water. The crest mask normalizes elevation by the real wave envelope
12485
- // (uScale.y is the surface's half-extent — useless as a sea-state yardstick
12486
- // on a big ocean plane), and a two-octave noise breaks the caps into
12487
- // patches that live and die with the crests. Distance-faded: far whitecaps
12488
- // subtend under a pixel and would alias into horizon speckle.
12489
- if (uWhitecaps > 0.0 && uWaveEnvelope > 0.001) {
12490
- float crestN = (vWorldPosition.y - uWaterCenter.y) / uWaveEnvelope;
12491
- float cap = smoothstep(0.5, 0.85, crestN);
12492
- vec2 capUV = vWorldPosition.xz * 0.3;
12493
- float capNoise = noise(capUV * 2.2 + uTime * vec2(0.10, 0.06)) * 0.65
12494
- + noise(capUV * 6.5 - uTime * vec2(0.05, 0.14)) * 0.35;
12495
- float capFade = 1.0 - smoothstep(220.0, 640.0, camDist);
12496
- float whitecap = uWhitecaps * cap * smoothstep(0.42, 0.78, capNoise) * capFade;
12497
- foamAmount = max(foamAmount, whitecap);
12498
- }
12499
- // incanto: splash/wake whitewater rides the ripple buffer, INDEPENDENT of the
12500
- // depth-foam gate so it works even on shallow ponds / before the first pre-pass
12501
- float splashFoam = rippleFoam(vWorldPosition.xz);
12502
-
12503
- // 🎨 Calculate contact surface color blending (part of foam effects)
12504
- if (uEnableFoam > 0.5 && uEdgeIntensity > 0.0) {
12505
- float edgeBlend = calculateEdgeBlending();
12506
-
12507
- // Blend water color with user-defined color at contact surfaces
12508
- vec3 blendedWaterColor = mix(finalColor, uEdgeColor, edgeBlend * uEdgeIntensity);
12509
-
12510
- // Slightly darken at contact surfaces (shadow effect)
12511
- float shadowIntensity = uEdgeIntensity * 0.5; // Link shadow intensity to user settings
12512
- float shadowFactor = 1.0 - edgeBlend * shadowIntensity;
12513
- finalColor = blendedWaterColor * shadowFactor;
12514
- }
12515
-
12516
- // Apply foam effects (only when foamAmount is greater than 0)
12517
- // refinement pass: low-contrast blend, no additive brightness kick — foam
12518
- // is bubbles ON the water, slightly lighter, never glowing white.
12519
- if (foamAmount > 0.0) {
12520
- finalColor = mix(finalColor, uFoamColor, foamAmount * 0.55);
12521
- }
12522
- // splash froth: pale foam tint (NOT pure white — that floats on top as a
12523
- // jarring decal), the water still reads through, so a plunge/wake looks like
12524
- // disturbed water rather than painted-on white
12525
- if (splashFoam > 0.0) {
12526
- vec3 froth = mix(uFoamColor, vec3(1.0), 0.3); // pale, not stark white
12527
- finalColor = mix(finalColor, froth, splashFoam * 0.58); // subtle, water reads through
12528
- }
12529
-
12530
- // 🎨 Adjust transparency at contact surfaces (part of foam effects)
12531
- // incanto v2: starts from bodyOpacity (Beer's-law depth-shaped alpha)
12532
- float baseOpacity = bodyOpacity;
12533
- if (uEnableFoam > 0.5 && uEdgeIntensity > 0.0) {
12534
- float edgeBlend = calculateEdgeBlending();
12535
- float transparencyEffect = uEdgeIntensity * 0.4; // Maximum 40% transparency increase
12536
- float edgeTransparency = mix(1.0, 1.0 - transparencyEffect, edgeBlend);
12537
- baseOpacity = bodyOpacity * edgeTransparency;
12538
- }
12539
-
12540
- // Make areas with foam more opaque — shoreline foam firm, splash froth gentler
12541
- // (a lighter opacity bump keeps the splash translucent, not a solid white cap)
12542
- float foamOpacity = baseOpacity;
12543
- if (foamAmount > 0.0) {
12544
- foamOpacity = mix(baseOpacity, min(baseOpacity + 0.5, 1.0), foamAmount);
12545
- }
12546
- if (splashFoam > 0.0) {
12547
- foamOpacity = max(foamOpacity, mix(baseOpacity, min(baseOpacity + 0.3, 1.0), splashFoam));
12548
- }
12549
-
12550
- // 🔥 Final fundamental solution: Apply depth mask
12551
- float finalOpacity = foamOpacity * depthMask;
12552
-
12553
- gl_FragColor = vec4(finalColor, finalOpacity);
12554
-
12555
- // incanto atmosphere: join three's output pipeline — ACES tone mapping +
12556
- // working→output colorspace encode + linear fog, in the SAME order as the
12557
- // built-in materials so water fogs/grades identically to the terrain.
12558
- #include <tonemapping_fragment>
12559
- #include <colorspace_fragment>
12560
- #include <fog_fragment>
12561
- }
12562
- `;
12563
- //#endregion
12564
- //#region src/3d/water/underwater.ts
12565
- /**
12566
- * True when the camera EYE sits below the water surface and inside its
12567
- * (margin-expanded) XZ footprint — i.e. genuinely underwater. The tie at exactly
12568
- * the surface height counts as above water (no flicker as you break the surface).
12569
- * The underwater look is deliberately gated to this (not "near" the surface), so
12570
- * wading with the camera above the water never tints the view. `marginXZ` lets it
12571
- * engage a touch past the rim so swimming to the edge doesn't pop.
12572
- */
12573
- function isCameraUnderwater(camX, camY, camZ, s, marginXZ = 0) {
12574
- if (camY >= s.centerY) return false;
12575
- return Math.abs(camX - s.centerX) <= s.halfW + marginXZ && Math.abs(camZ - s.centerZ) <= s.halfD + marginXZ;
12576
- }
12577
- /** Default visibility (fog far, meters) when the prop doesn't specify one. */
12578
- const DEFAULT_VISIBILITY = 22;
12579
- const DEFAULT_CAUSTICS = {
12580
- enabled: true,
12581
- color: "#cdeeff",
12582
- intensity: .55,
12583
- scale: .32,
12584
- speed: 1
12585
- };
12586
- /**
12587
- * Resolve the `underwater` prop (`false` | `true` | `{color?, visibility?,
12588
- * caustics?}`) into concrete values. With no explicit color the murk is DERIVED
12589
- * from the water's own hue — darkened and pulled a touch toward deep blue so
12590
- * being submerged reads as "inside THIS water", brighter water → brighter murk.
12591
- */
12592
- function resolveUnderwater(prop, waterColor) {
12593
- if (prop === false) return {
12594
- enabled: false,
12595
- color: deriveMurk(waterColor),
12596
- visibility: DEFAULT_VISIBILITY,
12597
- caustics: DEFAULT_CAUSTICS
12598
- };
12599
- const obj = prop && typeof prop === "object" ? prop : {};
12600
- return {
12601
- enabled: true,
12602
- color: obj.color ?? deriveMurk(waterColor),
12603
- visibility: obj.visibility ?? DEFAULT_VISIBILITY,
12604
- caustics: resolveCaustics(obj.caustics)
12605
- };
12606
- }
12607
- function resolveCaustics(prop) {
12608
- if (prop === false) return {
12609
- ...DEFAULT_CAUSTICS,
12610
- enabled: false
12611
- };
12612
- const obj = prop && typeof prop === "object" ? prop : {};
12613
- return {
12614
- enabled: true,
12615
- color: typeof obj.color === "string" ? obj.color : DEFAULT_CAUSTICS.color,
12616
- intensity: typeof obj.intensity === "number" ? obj.intensity : DEFAULT_CAUSTICS.intensity,
12617
- scale: typeof obj.scale === "number" ? obj.scale : DEFAULT_CAUSTICS.scale,
12618
- speed: typeof obj.speed === "number" ? obj.speed : DEFAULT_CAUSTICS.speed
12619
- };
12620
- }
12621
- /**
12622
- * Darken a hex color toward a deep blue-green — the look of light absorbed by a
12623
- * column of water (red dies first, blue lingers). Pure hex math so the parser
12624
- * stays three-free.
12625
- */
12626
- function deriveMurk(hex) {
12627
- const { r, g, b } = parseHex(hex);
12628
- const mix = (c, target, t) => Math.round(c * (1 - t) + target * t);
12629
- return toHex(Math.round(mix(r, 6, .45) * .7), Math.round(mix(g, 40, .4) * .78), Math.round(mix(b, 70, .35) * .82));
12630
- }
12631
- function parseHex(hex) {
12632
- const h = hex.replace("#", "");
12633
- const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
12634
- return {
12635
- r: Number.parseInt(n.slice(0, 2), 16) || 0,
12636
- g: Number.parseInt(n.slice(2, 4), 16) || 0,
12637
- b: Number.parseInt(n.slice(4, 6), 16) || 0
12638
- };
12639
- }
12640
- function toHex(r, g, b) {
12641
- const c = (v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0");
12642
- return `#${c(r)}${c(g)}${c(b)}`;
12643
- }
12644
- //#endregion
12645
- //#region src/3d/water/waves.ts
12646
- /**
12647
- * CPU twin of the Water3D vertex-shader wave field — pure math, no three.
12648
- *
12649
- * `waveElevation` ports the shader's 2D-simplex FBM (`getElevation`) exactly
12650
- * (same constants, same iteration scheme) so character-interaction code can
12651
- * sample the waterline a body is crossing WITHOUT reading the GPU back.
12652
- * `simpleWaveHeight` is the legacy crossed-sines displacement used by
12653
- * `quality: 'simple'`.
12654
- */
12655
- /** The ported shader's FBM constants (vibe-starter-3d-environment water). */
12656
- const FANCY_WAVE = {
12657
- frequency: 6.7,
12658
- persistence: .3,
12659
- lacunarity: 2.18,
12660
- iterations: 9,
12661
- speed: .5
12662
- };
12663
- /** The source shader's reference amplitude… */
12664
- const REFERENCE_AMPLITUDE = .525;
12665
- /** …which the legacy `waveHeight` default maps onto (0.08 → 0.525). */
12666
- const REFERENCE_WAVE_HEIGHT = .08;
12667
- /** Legacy crossed-sine constants (`quality: 'simple'`). */
12668
- const SIMPLE_WAVE = {
12669
- freqX: .7,
12670
- freqZ: .5,
12671
- cross: .6
12672
- };
12673
- /** GLSL `mod` (sign of divisor, unlike JS `%`). */
12674
- function glslMod(a, n) {
12675
- return a - Math.floor(a / n) * n;
12676
- }
12677
- function fract(x) {
12678
- return x - Math.floor(x);
12679
- }
12680
- /** GLSL `permute`: mod(((x*34)+1)*x, 289). */
12681
- function permute(x) {
12682
- return glslMod((x * 34 + 1) * x, 289);
12683
- }
12684
- const C_X = .211324865405187;
12685
- const C_Y = .366025403784439;
12686
- const C_Z = -.577350269189626;
12687
- const C_W = .024390243902439;
12688
- function snoise2(vx, vy) {
12689
- let ix = Math.floor(vx + (vx + vy) * C_Y);
12690
- let iy = Math.floor(vy + (vx + vy) * C_Y);
12691
- const x0x = vx - ix + (ix + iy) * C_X;
12692
- const x0y = vy - iy + (ix + iy) * C_X;
12693
- const i1x = x0x > x0y ? 1 : 0;
12694
- const i1y = x0x > x0y ? 0 : 1;
12695
- const x12x = x0x + C_X - i1x;
12696
- const x12y = x0y + C_X - i1y;
12697
- const x12z = x0x + C_Z;
12698
- const x12w = x0y + C_Z;
12699
- ix = glslMod(ix, 289);
12700
- iy = glslMod(iy, 289);
12701
- const p0 = permute(permute(iy) + ix);
12702
- const p1 = permute(permute(iy + i1y) + ix + i1x);
12703
- const p2 = permute(permute(iy + 1) + ix + 1);
12704
- let m0 = Math.max(.5 - (x0x * x0x + x0y * x0y), 0);
12705
- let m1 = Math.max(.5 - (x12x * x12x + x12y * x12y), 0);
12706
- let m2 = Math.max(.5 - (x12z * x12z + x12w * x12w), 0);
12707
- m0 = m0 * m0;
12708
- m0 = m0 * m0;
12709
- m1 = m1 * m1;
12710
- m1 = m1 * m1;
12711
- m2 = m2 * m2;
12712
- m2 = m2 * m2;
12713
- const g0x = 2 * fract(p0 * C_W) - 1;
12714
- const g1x = 2 * fract(p1 * C_W) - 1;
12715
- const g2x = 2 * fract(p2 * C_W) - 1;
12716
- const h0 = Math.abs(g0x) - .5;
12717
- const h1 = Math.abs(g1x) - .5;
12718
- const h2 = Math.abs(g2x) - .5;
12719
- const a0 = g0x - Math.floor(g0x + .5);
12720
- const a1 = g1x - Math.floor(g1x + .5);
12721
- const a2 = g2x - Math.floor(g2x + .5);
12722
- m0 *= 1.79284291400159 - .85373472095314 * (a0 * a0 + h0 * h0);
12723
- m1 *= 1.79284291400159 - .85373472095314 * (a1 * a1 + h1 * h1);
12724
- m2 *= 1.79284291400159 - .85373472095314 * (a2 * a2 + h2 * h2);
12725
- const g0 = a0 * x0x + h0 * x0y;
12726
- const g1 = a1 * x12x + h1 * x12y;
12727
- const g2 = a2 * x12z + h2 * x12w;
12728
- return 130 * (m0 * g0 + m1 * g1 + m2 * g2);
12729
- }
12730
- /**
12731
- * Wave elevation at world (x, z) at `time` — the shader's `getElevation`.
12732
- * Deterministic; bounded by `amplitude · (1 − persistence^n) / (1 − persistence)`.
12733
- */
12734
- function waveElevation(x, z, time, p) {
12735
- const norm = Math.max(p.scaleNorm, 1e-6);
12736
- const px = x / norm;
12737
- const pz = z / norm;
12738
- let elevation = 0;
12739
- let amplitude = 1;
12740
- let frequency = p.frequency;
12741
- const phase = time * p.speed;
12742
- for (let i = 0; i < p.iterations; i++) {
12743
- elevation += amplitude * snoise2(px * frequency + phase, pz * frequency + phase);
12744
- amplitude *= p.persistence;
12745
- frequency *= p.lacunarity;
12746
- }
12747
- return elevation * p.amplitude;
12748
- }
12749
- /** The legacy two-crossed-sines height at LOCAL (x, z) — `quality: 'simple'`. */
12750
- function simpleWaveHeight(x, z, time, waveHeight) {
12751
- return waveHeight * (Math.sin(x * SIMPLE_WAVE.freqX + time) + SIMPLE_WAVE.cross * Math.sin(z * SIMPLE_WAVE.freqZ + time * 1.3));
12752
- }
12753
- //#endregion
12754
- //#region src/3d/nodes/water-3d.ts
12755
- const WATER_QUALITIES = ["fancy", "simple"];
12756
- const COLOR_KEYS = [
12757
- "trough",
12758
- "surface",
12759
- "peak"
12760
- ];
12761
- const WATER_PRESET_NAMES = [
12762
- "custom",
12763
- "ocean",
12764
- "pool",
12765
- "lake",
12766
- "pond"
12767
- ];
12768
- /**
12769
- * The four curated water types — `preset: 'ocean' | 'pool' | 'lake' | 'pond'`
12770
- * is THE one-choice dial: each bundle sets every wave/color/clarity/caustics
12771
- * knob to a hand-tuned sea state. A preset value applies ONLY while the prop
12772
- * still sits at its schema default (same convention as the env-sun handoff),
12773
- * so any explicitly authored prop wins — pick the type, then override details
12774
- * to taste.
12775
- *
12776
- * - ocean: traveling swell + whitecaps, wide turquoise→deep-blue absorption
12777
- * - pool: near-flat crystal water, strong above-water caustics on the floor
12778
- * - lake: calm mirror — high reflectivity, murkier green-teal depths
12779
- * - pond: still, mossy, opaque quickly — the garden pond
12780
- */
12781
- const WATER_PRESETS = {
12782
- ocean: {
12783
- waveHeight: .045,
12784
- swell: .34,
12785
- swellWavelength: 52,
12786
- whitecaps: .15,
12787
- absorption: .17,
12788
- opacity: .85,
12789
- detailStrength: .17,
12790
- sunIntensity: .85,
12791
- colors: {
12792
- trough: "#175360",
12793
- surface: "#2f8792",
12794
- peak: "#9bd8d2"
12795
- },
12796
- color: "#2f8792",
12797
- causticsAbove: .3,
12798
- shoreWaves: .7,
12799
- reflectionInterval: 400,
12800
- _look: {
12801
- fresnelScale: 1.15,
12802
- reflectivityMax: .93,
12803
- foamRangeScale: .35,
12804
- foamIntensity: .55
12805
- }
12806
- },
12807
- pool: {
12808
- waveHeight: .006,
12809
- swell: 0,
12810
- whitecaps: 0,
12811
- absorption: .3,
12812
- opacity: .5,
12813
- detailStrength: .16,
12814
- sunIntensity: 1.25,
12815
- colors: {
12816
- trough: "#1e8dbe",
12817
- surface: "#3fb6dc",
12818
- peak: "#bdeef7"
12819
- },
12820
- color: "#3fb6dc",
12821
- causticsAbove: 1,
12822
- reflectionInterval: 600,
12823
- _look: {
12824
- fresnelScale: .85,
12825
- reflectivityMax: .5,
12826
- edgeFade: false
12827
- }
12828
- },
12829
- lake: {
12830
- waveHeight: .028,
12831
- swell: .14,
12832
- swellWavelength: 24,
12833
- whitecaps: 0,
12834
- absorption: .5,
12835
- opacity: .88,
12836
- detailStrength: .3,
12837
- colors: {
12838
- trough: "#12343a",
12839
- surface: "#25565c",
12840
- peak: "#7fb2ab"
12841
- },
12842
- color: "#25565c",
12843
- causticsAbove: .15,
12844
- reflectionInterval: 300,
12845
- _look: {
12846
- fresnelScale: 1.25,
12847
- reflectivityMax: .78
12848
- }
12849
- },
12850
- pond: {
12851
- waveHeight: .012,
12852
- swell: 0,
12853
- whitecaps: 0,
12854
- absorption: .55,
12855
- opacity: .88,
12856
- detailStrength: .2,
12857
- sunIntensity: .9,
12858
- colors: {
12859
- trough: "#24402a",
12860
- surface: "#436b4d",
12861
- peak: "#8fae8e"
12862
- },
12863
- color: "#436b4d",
12864
- causticsAbove: 0,
12865
- reflectionInterval: 800,
12866
- _look: {
12867
- fresnelScale: 1.1,
12868
- reflectivityMax: .68,
12869
- edgeFade: false
12870
- }
12871
- }
12872
- };
12873
- /** Refinement-pass palette — teal-leaning sea blues sampled off the reference
12874
- * calm sea (deep open water ≈ #2b7993, mid teal ≈ #16878a). The old ported
12875
- * palette (#1f5b8a/#52a0d0/#aee3ff) read as saturated pool blue. */
12876
- const FANCY_COLORS = {
12877
- trough: "#135a72",
12878
- surface: "#2a86a0",
12879
- peak: "#a5ddd6"
12880
- };
12881
- /** Color-ramp constants ported as-is; fresnel RETUNED for the atmosphere
12882
- * stage: the source's scale 1.5 / power 0.8 made the surface a ~75% mirror at
12883
- * every angle — invisible against its LDR backdrop, but a milk-white sea once
12884
- * the reflection cube carries the HDR physical sky. Schlick's real exponent
12885
- * (5) with unit scale keeps steep views water-colored and grazing views sky. */
12886
- const FANCY_LOOK = {
12887
- peakThreshold: .08,
12888
- peakTransition: .05,
12889
- troughThreshold: -.01,
12890
- troughTransition: .15,
12891
- fresnelScale: 1,
12892
- fresnelPower: 5
12893
- };
12894
- /** Foam/contact constants. Refinement-pass retune: `range` scales with the
12895
- * water's extent (see foamRangeFor) — SEAS get wide wispy channel foam like
12896
- * the reference, ponds keep a shoreline KISS (a flat 1.4 m range painted a
12897
- * 0.45 m-deep pond white wall-to-wall — the exact v2 regression, relearned).
12898
- * `intensity` drops and the fragment ramp softens — wispy patches, never a
12899
- * bright band. `edgeIntensity` 2 → 0.6: the contact line is a hint. */
12900
- const FOAM_LOOK = {
12901
- color: "#dcebe8",
12902
- intensity: .32,
12903
- rangeMin: .45,
12904
- rangeMax: 1.4,
12905
- scale: .35,
12906
- edgeColor: "#ffffff",
12907
- edgeIntensity: .6,
12908
- edgeWidth: .5
12909
- };
12910
- /** Foam depth-range in meters, scaled by the surface's smaller extent: a
12911
- * garden pond (≤ ~37 m) keeps the tight 0.45 m contact band, open water
12912
- * ramps to 1.4 m so foam spreads across whole shallow channels. */
12913
- function foamRangeFor(w, d) {
12914
- return Math.min(FOAM_LOOK.rangeMax, Math.max(FOAM_LOOK.rangeMin, Math.min(w, d) * .012));
12915
- }
12916
- /** `waveHeight` keeps its role as THE wave dial: the legacy default (0.08)
12917
- * reproduces the source's reference amplitude (0.525) exactly. */
12918
- const WAVE_WORLD_REF = 128;
12919
- const AMPLITUDE_PER_WAVE_HEIGHT = REFERENCE_AMPLITUDE / REFERENCE_WAVE_HEIGHT;
12920
- /** Cube-map reflection resolution (source: reflectionQuality = 256). */
12921
- const REFLECTION_RESOLUTION = 256;
12922
- /** Planar-mirror target size cap (per side) — half the drawing buffer, clamped. */
12923
- const MIRROR_PASS_MAX = 1024;
12924
- /** How far a wavelet's tilt slides the mirror sample, in UV per unit of normal lean. */
12925
- const MIRROR_DISTORT = .06;
12926
- /** Scene pre-pass (depth for absorption/foam + color grab for refraction):
12927
- * half the drawing buffer, clamped — ONE extra scene render per frame. */
12928
- const SCENE_PASS_MAX = 1024;
12929
- /** Grab-clip tolerance above the waterline (meters) — covers depth-buffer
12930
- * precision at the clip plane, nothing more. Deliberately NO wave-height
12931
- * slack: clipping at crest height instead kept each piercing object's
12932
- * [waterline..crest] slice in the grab, and that pale band re-bled through
12933
- * the mip-biased read and the ripple-wiggled offsets as silhouette scribbles
12934
- * — a miniature of the halo disease the clip exists to kill. The cost is
12935
- * crests refracting a hair short at an object's contact (invisible: the
12936
- * object's own deeper column fills the gap with near-identical color). */
12937
- const WATERLINE_CLIP_SLACK = .02;
12938
- /** Fancy vertex density: the FBM displaces per vertex, so density scales with
12939
- * extent (the source's formula degenerated to always-256; ours ramps to it). */
12940
- const FANCY_SEGMENTS_PER_M = 4;
12941
- const FANCY_SEGMENTS_MIN = 32;
12942
- const FANCY_SEGMENTS_MAX = 256;
12943
- /** Simple quality: coarse enough to animate every vertex on the CPU. */
12944
- const SIMPLE_SEGMENTS = 32;
12945
- /** Bodies must rise this far above the waterline to 'exit' (no surface flicker). */
12946
- const SURFACE_HYSTERESIS = .05;
12947
- /** Entry splash amplitude: base + per-(m/s of |vy|), capped. */
12948
- const ENTER_AMP_BASE = .08;
12949
- const ENTER_AMP_PER_VY = .02;
12950
- const ENTER_AMP_MAX = .18;
12951
- /** Leaving the water drags a smaller ring out with you. */
12952
- const EXIT_AMP = .04;
12953
- /** Bodies floating IN water bob a gentle ring every interval (seconds). */
12954
- const BOB_INTERVAL = 1.4;
12955
- const BOB_AMP = .04;
12956
- /** A body striding through water trails a WAKE: a ripple every this-many metres. */
12957
- const WAKE_STEP = .45;
12958
- const WAKE_AMP = .08;
12959
- /** Splash FOAM strengths (0..1) the shaders paint as surface whitewater.
12960
- * Entry foam scales with fall speed (see `entryFoam`); these tag the rest. */
12961
- const EXIT_FOAM = .4;
12962
- /** Wake foam tracks how fast you stride through the water (the "물살"). */
12963
- const WAKE_FOAM_BASE = .22;
12964
- const WAKE_FOAM_PER_SPEED = .09;
12965
- const WAKE_FOAM_MAX = .85;
12966
- /**
12967
- * A water surface on XZ. `quality: 'fancy'` (default) is a commercial-grade
12968
- * water shader — 9-iteration simplex FBM displacement with analytic normals,
12969
- * two animated procedural detail-normal layers (`detailStrength`), a sun
12970
- * specular glint+sheen (`sunDirection`/`sunColor`/`sunIntensity`), Beer's-law
12971
- * depth `absorption` (clear shallows → dark depths), screen-space
12972
- * `refraction` of the submerged scene, depth-texture shoreline `foam` (on by
12973
- * default), a trough/surface/peak color ramp and fresnel-mixed CubeCamera
12974
- * reflections (re-rendered every `reflectionInterval` ms). `quality:
12975
- * 'simple'` is the cheap CPU sine-wave material (low-end fallback; also the
12976
- * deterministic headless path).
12977
- *
12978
- * Per-frame GPU budget for fancy (beyond drawing the surface): ONE half-res
12979
- * scene pre-pass that feeds depth (absorption/foam/soft intersections) AND
12980
- * the refraction color grab; the cube reflection stays on its `1000 ms`
12981
- * throttle.
12982
- *
12983
- * Character interaction (`interaction: true`): each update the node scans the
12984
- * tree for CharacterBody3D/RigidBody3D, compares their world y against the
12985
- * waterline at their xz (wave offset included, CPU twin of the shader), emits
12986
- * `entered`/`exited` with the body node, and pushes ripple impulses into a
12987
- * fixed 8-slot ring buffer both shaders turn into expanding ring waves. The
12988
- * surface has no bottom — anything below the waterline inside the XZ
12989
- * footprint counts as in-water (pair with `Area3D` for volumetric gameplay).
12990
- *
12991
- * GPU-only features (reflection/foam) run through the renderer's
12992
- * `_onRender3D` hand-off — headless runs simply never invoke it.
12993
- */
12994
- var Water3D = class Water3D extends Node3D {
12995
- static typeName = "Water3D";
12996
- static signals = ["entered", "exited"];
12997
- static props = {
12998
- size: { default: [40, 40] },
12999
- color: { default: "#2a6fbe" },
13000
- opacity: { default: .8 },
13001
- renderOrder: { default: 1 },
13002
- waveHeight: { default: .04 },
13003
- waveSpeed: { default: 1 },
13004
- quality: {
13005
- default: "fancy",
13006
- options: WATER_QUALITIES
13007
- },
13008
- colors: { default: {} },
13009
- reflection: { default: true },
13010
- reflectionInterval: { default: 1e3 },
13011
- mirror: { default: false },
13012
- foam: { default: true },
13013
- interaction: { default: true },
13014
- splash: { default: true },
13015
- sunDirection: { default: [
13016
- .5,
13017
- .8,
13018
- .3
13019
- ] },
13020
- sunColor: { default: "#fff5d6" },
13021
- sunIntensity: { default: 1 },
13022
- detailStrength: { default: .26 },
13023
- absorption: { default: .15 },
13024
- refraction: { default: true },
13025
- underwater: { default: true },
13026
- swell: { default: 0 },
13027
- swellDirectionDeg: { default: 0 },
13028
- swellWavelength: { default: 30 },
13029
- whitecaps: { default: 0 },
13030
- preset: {
13031
- default: "custom",
13032
- options: WATER_PRESET_NAMES
13033
- },
13034
- causticsAbove: { default: 0 },
13035
- shoreWaves: { default: 0 }
13036
- };
13037
- /** [width, depth] in meters (the surface lies on XZ). */
13038
- size = [40, 40];
13039
- /** Legacy single hue: simple-quality color; tints the fancy ramp when set. */
13040
- color = "#2a6fbe";
13041
- opacity = .8;
13042
- /** Draw after opaque geometry (overrides Node3D's 0 default). */
13043
- renderOrder = 1;
13044
- /** Wave intensity dial, meters-ish. 0 = mirror-flat; 0.04 (default) reads
13045
- * as a calm sea; 0.08 = the noisier ported-source look. */
13046
- waveHeight = .04;
13047
- waveSpeed = 1;
13048
- /** 'fancy' shader water (default) | 'simple' cheap CPU sine material. */
13049
- quality = "fancy";
13050
- /** Fancy ramp overrides: `{trough?, surface?, peak?}` hex strings. */
13051
- colors = {};
13052
- /** CubeCamera reflections (fancy only; needs a live renderer). */
13053
- reflection = true;
13054
- /**
13055
- * TRUE planar reflection: the scene re-rendered from the camera mirrored
13056
- * across this surface, sampled per fragment. A cube map can only carry
13057
- * distant content — the shoreline trees, the moored boat, the player at the
13058
- * water's edge only appear in the water with this on. Costs one extra scene
13059
- * render per frame, so it is off by default; a still lake earns it.
13060
- */
13061
- mirror = false;
13062
- /** ms between reflection (and foam depth) re-renders. */
13063
- reflectionInterval = 1e3;
13064
- /** Depth-texture shoreline foam — rides the always-on fancy scene pass. */
13065
- foam = true;
13066
- /** Bodies crossing the surface emit signals and raise ripples. */
13067
- interaction = true;
13068
- /** Splash WHITEWATER: the shaders paint expanding surface foam where bodies
13069
- * interact — a "풍덩" froth bloom on entry (scaled by fall speed) and a foam
13070
- * trail ("물살") behind a body wading/running through. Needs `interaction`. */
13071
- splash = true;
13072
- /** Fixed sun for the specular glint — match the scene's key light. */
13073
- sunDirection = [
13074
- .5,
13075
- .8,
13076
- .3
13077
- ];
13078
- /** Sun glint/sheen tint. */
13079
- sunColor = "#fff5d6";
13080
- /** Sun glint/sheen strength — 0 disables the specular entirely. */
13081
- sunIntensity = 1;
13082
- /** Animated detail-normal strength — the close-up liquid feel (0 = off). */
13083
- detailStrength = .26;
13084
- /** Beer's-law absorption per meter of water depth (0 = no depth tinting).
13085
- * Per-channel under the hood: red dies ~3× faster than blue, so shallows
13086
- * over a bright bottom read turquoise before fading to deep blue. */
13087
- absorption = .15;
13088
- /** Screen-space refraction of the submerged scene (fancy only). */
13089
- refraction = true;
13090
- /**
13091
- * Submerged-camera look: when the camera dips below this surface (inside its
13092
- * XZ footprint) the scene switches to an underwater fog + tint for that frame
13093
- * — the murky, limited-visibility feel of being underwater. `true` (default)
13094
- * derives the murk from `color`; `false` disables it; an object
13095
- * `{ color?, visibility? }` overrides the fog hue / view distance (meters).
13096
- */
13097
- underwater = true;
13098
- /**
13099
- * Directional traveling SWELL amplitude in meters (fancy only; 0 = off, the
13100
- * legacy isotropic look). Three peaked wave trains march across the surface
13101
- * at real deep-water speeds — THE open-ocean dial: 0.15 = lively lake chop,
13102
- * 0.4 = coastal sea, 0.8 = heavy weather. Crests feed the color ramp,
13103
- * `whitecaps` and the shore foam automatically.
13104
- */
13105
- swell = 0;
13106
- /** Compass direction the swell TRAVELS toward, degrees (0 = +z, 90 = +x). */
13107
- swellDirectionDeg = 0;
13108
- /** Primary swell wavelength in meters (the two crossing trains derive). */
13109
- swellWavelength = 30;
13110
- /** Open-water whitecap foam on the tallest crests, 0–1 (fancy only). Works
13111
- * best with `swell` — the caps ride the swell's crest lines. */
13112
- whitecaps = 0;
13113
- /**
13114
- * THE one-choice water type: 'ocean' | 'pool' | 'lake' | 'pond' bundles
13115
- * every wave/color/clarity/caustics knob into a hand-tuned sea state
13116
- * ('custom', the default, changes nothing). A preset value applies ONLY
13117
- * where a prop still sits at its schema default — set the type first, then
13118
- * override any detail prop to taste and your value wins.
13119
- */
13120
- preset = "custom";
13121
- /**
13122
- * Above-water CAUSTICS intensity (0 = off): the dancing light web on the
13123
- * submerged bottom, seen THROUGH the surface — the pool / tropical-shallows
13124
- * look. Needs `refraction` (fancy quality). Presets set it; override freely.
13125
- */
13126
- causticsAbove = 0;
13127
- /**
13128
- * Traveling SHORE BREAKERS, 0–1 (fancy + depth pre-pass only): foam fronts
13129
- * that roll down the depth gradient and break toward every beach — the
13130
- * fronts follow the shore's depth contours automatically. Presets: ocean
13131
- * ships 0.7; 0 (default) = off.
13132
- */
13133
- shoreWaves = 0;
13134
- /** Preset-layered prop read (see {@link WATER_PRESETS}): the preset value
13135
- * applies only while the prop sits at its schema default. */
13136
- rp(key) {
13137
- const bundle = WATER_PRESETS[this.preset];
13138
- const self = this;
13139
- if (bundle && key in bundle) {
13140
- const def = Water3D.props[key]?.default;
13141
- const cur = self[key];
13142
- if (typeof cur === "object" && cur !== null ? JSON.stringify(cur) === JSON.stringify(def) : cur === def) return bundle[key];
13143
- }
13144
- return self[key];
13145
- }
13146
- /** Per-preset internal look constants (fresnel curve, reflection ceiling). */
13147
- presetLook() {
13148
- const look = WATER_PRESETS[this.preset]?._look;
13149
- return {
13150
- fresnelScale: look?.fresnelScale ?? FANCY_LOOK.fresnelScale,
13151
- reflectivityMax: look?.reflectivityMax ?? .6,
13152
- edgeFade: look?.edgeFade ?? true,
13153
- foamRangeScale: look?.foamRangeScale ?? 1,
13154
- foamIntensity: look?.foamIntensity ?? FOAM_LOOK.intensity
13155
- };
13156
- }
13157
- /** Loader hook: degenerate planes and bad enums fail at LOAD, not at render. */
13158
- static validateJson(node) {
13159
- const w = node;
13160
- 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" });
13161
- 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}'.`, {
13162
- prop: "quality",
13163
- validOptions: WATER_QUALITIES
13164
- });
13165
- for (const [key, value] of Object.entries(w.colors)) {
13166
- if (!COLOR_KEYS.includes(key)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' colors has unknown key '${key}'. Valid keys: [${COLOR_KEYS.join(", ")}].`, {
13167
- prop: "colors",
13168
- validOptions: COLOR_KEYS
13169
- });
13170
- 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" });
13171
- }
13172
- if (!(w.reflectionInterval > 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' reflectionInterval must be > 0 ms, got ${w.reflectionInterval}.`, { prop: "reflectionInterval" });
13173
- const sun = w.sunDirection;
13174
- 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" });
13175
- if (!(w.sunIntensity >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' sunIntensity must be >= 0, got ${w.sunIntensity}.`, { prop: "sunIntensity" });
13176
- if (!(w.detailStrength >= 0)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' detailStrength must be >= 0, got ${w.detailStrength}.`, { prop: "detailStrength" });
13177
- 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" });
13178
- 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" });
13179
- if (!(w.swellWavelength >= 2)) throw new IncantoError("BAD_FORMAT", `Water3D '${node.name}' swellWavelength must be >= 2 meters, got ${JSON.stringify(w.swellWavelength)}.`, { prop: "swellWavelength" });
13180
- 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" });
13181
- 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}'.`, {
13182
- prop: "preset",
13183
- validOptions: WATER_PRESET_NAMES
13184
- });
13185
- 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" });
13186
- 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" });
13187
- const uw = w.underwater;
13188
- if (typeof uw !== "boolean") {
13189
- 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" });
13190
- const o = uw;
13191
- 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" });
13192
- 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" });
13193
- }
13194
- }
13195
- time = 0;
13196
- surfaceKey = "";
13197
- /** @internal Live ripple ring buffer (≤8) — tests and tools read this. */
13198
- _ripples = [];
13199
- inWater = /* @__PURE__ */ new Set();
13200
- bobTimers = /* @__PURE__ */ new Map();
13201
- /** Last position a wake ripple was dropped, per in-water body (for the trail). */
13202
- wadeAnchors = /* @__PURE__ */ new Map();
13203
- _scratchA = {
13204
- x: 0,
13205
- y: 0,
13206
- z: 0
13207
- };
13208
- _scratchB = {
13209
- x: 0,
13210
- y: 0,
13211
- z: 0
13212
- };
13213
- _bodies = [];
13214
- cubeTarget = null;
13215
- cubeCamera = null;
13216
- /** Reflection near plane (max half extent) — recreate the camera on resize. */
13217
- reflectionNear = 0;
13218
- lastReflectionAt = 0;
13219
- /** Per-frame scene pre-pass: depth (absorption/foam) + color grab (refraction). */
13220
- scenePassTarget = null;
13221
- mirrorTarget = null;
13222
- mirrorCamera = new PerspectiveCamera();
13223
- mirrorMatrix = new Matrix4();
13224
- _createObject3D() {
13225
- return new Mesh();
13226
- }
13227
- update(dt) {
13228
- this.time += dt * this.waveSpeed;
13229
- ageRipples(this._ripples, dt);
13230
- if (this.interaction) this.stepInteraction(dt);
13231
- }
13232
- _syncObject3D() {
13233
- super._syncObject3D();
13234
- const mesh = this._ensureObject3D();
13235
- this.rebuildSurfaceIfNeeded(mesh);
13236
- if (this.quality === "simple") this.syncSimple(mesh);
13237
- else this.syncFancy(mesh);
13238
- }
13239
- /**
13240
- * @internal Renderer hand-off (see {@link RenderContext3D}): re-render the
13241
- * CubeCamera reflection every `reflectionInterval` ms, plus ONE per-frame
13242
- * half-res scene pre-pass whose depth texture drives Beer's-law absorption,
13243
- * shoreline foam and soft intersections, and whose color texture is the
13244
- * refraction grab — one extra render feeds all three.
13245
- */
13246
- _onRender3D(ctx) {
13247
- const mesh = this._ensureObject3D();
13248
- const material = mesh.material;
13249
- ctx.camera.updateWorldMatrix(true, false);
13250
- ctx.camera.getWorldPosition(underwaterCamScratch);
13251
- if (this.underwaterAt(underwaterCamScratch.x, underwaterCamScratch.y, underwaterCamScratch.z)) {
13252
- if (material.isShaderMaterial) {
13253
- const u = material.uniforms;
13254
- if (u.uUseSceneDepth) u.uUseSceneDepth.value = 0;
13255
- if (u.uUseRefraction) u.uUseRefraction.value = 0;
13256
- if (u.uUseMirror) u.uUseMirror.value = 0;
13257
- }
13258
- return;
13259
- }
13260
- if (this.quality !== "fancy" || !material.isShaderMaterial) return;
13261
- const halfDiagonal = Math.hypot((this.size[0] ?? 1) / 2, (this.size[1] ?? 1) / 2);
13262
- if (!sphereInCameraFrustum(ctx.camera, worldTranslationOf(this), halfDiagonal + 1)) return;
13263
- const u = material.uniforms;
13264
- const now = Date.now();
13265
- if (this.reflection) {
13266
- if (!this.cubeTarget) this.cubeTarget = new WebGLCubeRenderTarget(REFLECTION_RESOLUTION, {
13267
- generateMipmaps: true,
13268
- minFilter: LinearMipmapLinearFilter,
13269
- type: HalfFloatType
13270
- });
13271
- const near = Math.max((this.size[0] ?? 1) / 2, (this.size[1] ?? 1) / 2);
13272
- if (!this.cubeCamera || this.reflectionNear !== near) {
13273
- this.reflectionNear = near;
13274
- this.cubeCamera = new CubeCamera(near, 1e3, this.cubeTarget);
13275
- this.lastReflectionAt = 0;
13276
- }
13277
- if (this.lastReflectionAt === 0 || now - this.lastReflectionAt >= this.rp("reflectionInterval")) {
13278
- this.lastReflectionAt = now;
13279
- const center = worldTranslationOf(this);
13280
- const cam = this.cubeCamera;
13281
- cam.position.set(center.x, center.y + .1, center.z);
13282
- const wasVisible = mesh.visible;
13283
- mesh.visible = false;
13284
- cam.update(ctx.gl, ctx.scene);
13285
- mesh.visible = wasVisible;
13286
- }
13287
- if (u.uEnvironmentMap && u.uUseEnvironmentMap) {
13288
- u.uEnvironmentMap.value = this.cubeTarget.texture;
13289
- u.uUseEnvironmentMap.value = 1;
13290
- }
13291
- }
13292
- if (this.mirror && u.uMirrorMap && u.uMirrorMatrix && u.uUseMirror) {
13293
- ctx.gl.getDrawingBufferSize(drawingBufferScratch);
13294
- const mirrorW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), MIRROR_PASS_MAX));
13295
- const mirrorH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), MIRROR_PASS_MAX));
13296
- if (this.mirrorTarget && (this.mirrorTarget.width !== mirrorW || this.mirrorTarget.height !== mirrorH)) {
13297
- this.mirrorTarget.dispose();
13298
- this.mirrorTarget = null;
13299
- }
13300
- if (!this.mirrorTarget) this.mirrorTarget = new WebGLRenderTarget(mirrorW, mirrorH, {
13301
- depthBuffer: true,
13302
- stencilBuffer: false,
13303
- type: HalfFloatType
13304
- });
13305
- const surfaceY = worldTranslationOf(this).y;
13306
- const cam = ctx.camera;
13307
- cam.getWorldPosition(mirrorEyeScratch);
13308
- cam.getWorldQuaternion(mirrorQuatScratch);
13309
- mirrorTargetScratch.set(0, 0, -1).applyQuaternion(mirrorQuatScratch).add(mirrorEyeScratch);
13310
- mirrorUpScratch.set(0, 1, 0).applyQuaternion(mirrorQuatScratch);
13311
- mirrorEyeScratch.y = 2 * surfaceY - mirrorEyeScratch.y;
13312
- mirrorTargetScratch.y = 2 * surfaceY - mirrorTargetScratch.y;
13313
- mirrorUpScratch.y = -mirrorUpScratch.y;
13314
- const mirrorCam = this.mirrorCamera;
13315
- mirrorCam.fov = cam.fov ?? 50;
13316
- mirrorCam.aspect = cam.aspect ?? mirrorW / mirrorH;
13317
- mirrorCam.near = cam.near ?? .1;
13318
- mirrorCam.far = cam.far ?? 1e3;
13319
- mirrorCam.position.copy(mirrorEyeScratch);
13320
- mirrorCam.up.copy(mirrorUpScratch);
13321
- mirrorCam.lookAt(mirrorTargetScratch);
13322
- mirrorCam.updateMatrixWorld(true);
13323
- mirrorCam.updateProjectionMatrix();
13324
- this.mirrorMatrix.set(.5, 0, 0, .5, 0, .5, 0, .5, 0, 0, .5, .5, 0, 0, 0, 1);
13325
- this.mirrorMatrix.multiply(mirrorCam.projectionMatrix);
13326
- this.mirrorMatrix.multiply(mirrorCam.matrixWorldInverse);
13327
- const prevMirrorTarget = ctx.gl.getRenderTarget();
13328
- const prevMirrorClip = ctx.gl.clippingPlanes;
13329
- const wasMirrorVisible = mesh.visible;
13330
- mesh.visible = false;
13331
- mirrorClipPlane.constant = -(surfaceY - WATERLINE_CLIP_SLACK);
13332
- ctx.gl.clippingPlanes = mirrorClipPlanes;
13333
- ctx.gl.setRenderTarget(this.mirrorTarget);
13334
- ctx.gl.clear(true, true, false);
13335
- ctx.gl.render(ctx.scene, mirrorCam);
13336
- ctx.gl.setRenderTarget(prevMirrorTarget);
13337
- ctx.gl.clippingPlanes = prevMirrorClip;
13338
- mesh.visible = wasMirrorVisible;
13339
- u.uMirrorMap.value = this.mirrorTarget.texture;
13340
- u.uMirrorMatrix.value.copy(this.mirrorMatrix);
13341
- u.uUseMirror.value = 1;
13342
- } else if (u.uUseMirror) u.uUseMirror.value = 0;
13343
- ctx.gl.getDrawingBufferSize(drawingBufferScratch);
13344
- const passW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), SCENE_PASS_MAX));
13345
- const passH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), SCENE_PASS_MAX));
13346
- if (this.scenePassTarget && (this.scenePassTarget.width !== passW || this.scenePassTarget.height !== passH)) {
13347
- this.scenePassTarget.depthTexture?.dispose();
13348
- this.scenePassTarget.dispose();
13349
- this.scenePassTarget = null;
13350
- }
13351
- if (!this.scenePassTarget) {
13352
- const depthTexture = new DepthTexture(passW, passH);
13353
- depthTexture.type = FloatType;
13354
- this.scenePassTarget = new WebGLRenderTarget(passW, passH, {
13355
- depthTexture,
13356
- depthBuffer: true,
13357
- stencilBuffer: false,
13358
- generateMipmaps: true,
13359
- minFilter: LinearMipmapLinearFilter
13360
- });
13361
- }
13362
- const prevTarget = ctx.gl.getRenderTarget();
13363
- const prevAutoClear = ctx.gl.autoClear;
13364
- const prevClippingPlanes = ctx.gl.clippingPlanes;
13365
- const wasVisible = mesh.visible;
13366
- mesh.visible = false;
13367
- const waterY = worldTranslationOf(this).y;
13368
- grabClipPlane.constant = waterY + WATERLINE_CLIP_SLACK;
13369
- ctx.gl.clippingPlanes = grabClipPlanes;
13370
- ctx.gl.setRenderTarget(this.scenePassTarget);
13371
- ctx.gl.autoClear = true;
13372
- ctx.gl.clear(true, true, false);
13373
- ctx.gl.render(ctx.scene, ctx.camera);
13374
- depthOnlyClipPlane.constant = -(waterY - WATERLINE_CLIP_SLACK);
13375
- ctx.gl.clippingPlanes = depthOnlyClipPlanes;
13376
- const prevOverride = ctx.scene.overrideMaterial;
13377
- const prevBackground = ctx.scene.background;
13378
- ctx.scene.overrideMaterial = depthOnlyMaterial;
13379
- ctx.scene.background = null;
13380
- ctx.gl.autoClear = false;
13381
- ctx.gl.render(ctx.scene, ctx.camera);
13382
- ctx.scene.overrideMaterial = prevOverride;
13383
- ctx.scene.background = prevBackground;
13384
- ctx.gl.setRenderTarget(prevTarget);
13385
- ctx.gl.autoClear = prevAutoClear;
13386
- ctx.gl.clippingPlanes = prevClippingPlanes;
13387
- mesh.visible = wasVisible;
13388
- if (u.uDepthTexture && u.uSceneColor && u.uCameraNear && u.uCameraFar && u.uUseSceneDepth) {
13389
- u.uDepthTexture.value = this.scenePassTarget.depthTexture;
13390
- u.uSceneColor.value = this.scenePassTarget.texture;
13391
- u.uCameraNear.value = ctx.camera.near;
13392
- u.uCameraFar.value = ctx.camera.far;
13393
- u.uUseSceneDepth.value = 1;
13394
- if (u.uUseRefraction) u.uUseRefraction.value = this.refraction ? 1 : 0;
13395
- if (u.uEnableFoam) u.uEnableFoam.value = this.foam ? 1 : 0;
13396
- }
13397
- }
13398
- /**
13399
- * Camera-submersion query (the renderer asks every water node each frame): the
13400
- * resolved underwater look + this surface's Y when the camera EYE is below
13401
- * THIS surface inside its XZ footprint, else null. Deliberately gated to the
13402
- * eye being underwater (not merely near the surface) so wading with the camera
13403
- * above the water never tints the view. Cheap above water (geometric test first).
13404
- */
13405
- underwaterAt(camX, camY, camZ) {
13406
- if (this.underwater === false) return null;
13407
- const center = worldTranslationOf(this, this._scratchA);
13408
- if (!isCameraUnderwater(camX, camY, camZ, {
13409
- centerX: center.x,
13410
- centerY: center.y,
13411
- centerZ: center.z,
13412
- halfW: (this.size[0] ?? 1) / 2,
13413
- halfD: (this.size[1] ?? 1) / 2
13414
- }, UNDERWATER_MARGIN)) return null;
13415
- const cfg = resolveUnderwater(this.underwater, this.rp("color"));
13416
- return cfg.enabled ? {
13417
- ...cfg,
13418
- surfaceY: center.y
13419
- } : null;
13420
- }
13421
- free() {
13422
- const mesh = this._ensureObject3D();
13423
- mesh.geometry?.dispose();
13424
- mesh.material?.dispose?.();
13425
- this.cubeTarget?.dispose();
13426
- this.cubeTarget = null;
13427
- this.cubeCamera = null;
13428
- this.scenePassTarget?.depthTexture?.dispose();
13429
- this.scenePassTarget?.dispose();
13430
- this.scenePassTarget = null;
13431
- this.mirrorTarget?.dispose();
13432
- this.mirrorTarget = null;
13433
- super.free();
13434
- }
13435
- /** Swap geometry+material only when a surface-shaping prop changed. */
13436
- rebuildSurfaceIfNeeded(mesh) {
13437
- const key = JSON.stringify([this.size, this.quality]);
13438
- if (key === this.surfaceKey && mesh.geometry.getAttribute("position")) return;
13439
- this.surfaceKey = key;
13440
- mesh.geometry?.dispose();
13441
- mesh.material?.dispose?.();
13442
- const w = this.size[0] ?? 1;
13443
- const d = this.size[1] ?? 1;
13444
- if (this.quality === "simple") {
13445
- mesh.geometry = buildWaterGeometry(w, d, SIMPLE_SEGMENTS);
13446
- mesh.material = this.buildLakeMaterial();
13447
- } else {
13448
- 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));
13449
- mesh.material = this.buildFancyMaterial();
13450
- }
13451
- }
13452
- buildFancyMaterial() {
13453
- return new ShaderMaterial({
13454
- vertexShader: WATER_VERT,
13455
- fragmentShader: WATER_FRAG,
13456
- fog: true,
13457
- uniforms: {
13458
- fogColor: { value: new Color("#ffffff") },
13459
- fogNear: { value: 1 },
13460
- fogFar: { value: 1e3 },
13461
- fogDensity: { value: 25e-5 },
13462
- uTime: { value: 0 },
13463
- uOpacity: { value: this.rp("opacity") },
13464
- uEnvironmentMap: { value: null },
13465
- uUseEnvironmentMap: { value: 0 },
13466
- uMirrorMap: { value: null },
13467
- uMirrorMatrix: { value: new Matrix4() },
13468
- uUseMirror: { value: 0 },
13469
- uMirrorDistort: { value: MIRROR_DISTORT },
13470
- uWavesAmplitude: { value: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT },
13471
- uWavesSpeed: { value: FANCY_WAVE.speed },
13472
- uWavesFrequency: { value: FANCY_WAVE.frequency },
13473
- uWavesPersistence: { value: FANCY_WAVE.persistence },
13474
- uWavesLacunarity: { value: FANCY_WAVE.lacunarity },
13475
- uWavesIterations: { value: FANCY_WAVE.iterations },
13476
- uTroughColor: { value: new Color(FANCY_COLORS.trough) },
13477
- uSurfaceColor: { value: new Color(FANCY_COLORS.surface) },
13478
- uPeakColor: { value: new Color(FANCY_COLORS.peak) },
13479
- uPeakThreshold: { value: FANCY_LOOK.peakThreshold },
13480
- uPeakTransition: { value: FANCY_LOOK.peakTransition },
13481
- uTroughThreshold: { value: FANCY_LOOK.troughThreshold },
13482
- uTroughTransition: { value: FANCY_LOOK.troughTransition },
13483
- uFresnelScale: { value: FANCY_LOOK.fresnelScale },
13484
- uFresnelPower: { value: FANCY_LOOK.fresnelPower },
13485
- uScale: { value: new Vector3(1, 1, 1) },
13486
- uWaterCenter: { value: new Vector3() },
13487
- uRipples: { value: Array.from({ length: 8 }, () => new Vector4()) },
13488
- uRippleFoam: { value: Array.from({ length: 8 }, () => 0) },
13489
- uSplashFoam: { value: this.splash ? 1 : 0 },
13490
- uSunDirection: { value: new Vector3(.5, .8, .3) },
13491
- uSunColor: { value: new Color(this.sunColor) },
13492
- uSunIntensity: { value: this.rp("sunIntensity") },
13493
- uDetailStrength: { value: this.rp("detailStrength") },
13494
- uAbsorption: { value: this.rp("absorption") },
13495
- uUseSceneDepth: { value: 0 },
13496
- uUseRefraction: { value: 0 },
13497
- uSceneColor: { value: null },
13498
- uEnableFoam: { value: 0 },
13499
- uFoamColor: { value: new Color(FOAM_LOOK.color) },
13500
- uFoamIntensity: { value: FOAM_LOOK.intensity },
13501
- uFoamRange: { value: foamRangeFor(this.size[0] ?? 1, this.size[1] ?? 1) },
13502
- uFoamScale: { value: FOAM_LOOK.scale },
13503
- uDepthTexture: { value: null },
13504
- uCameraNear: { value: .1 },
13505
- uCameraFar: { value: 1e3 },
13506
- uEdgeColor: { value: new Color(FOAM_LOOK.edgeColor) },
13507
- uEdgeIntensity: { value: FOAM_LOOK.edgeIntensity },
13508
- uEdgeWidth: { value: FOAM_LOOK.edgeWidth },
13509
- uSwellAmplitude: { value: this.rp("swell") },
13510
- uSwellDir: { value: new Vector2(0, 1) },
13511
- uSwellWavelength: { value: this.rp("swellWavelength") },
13512
- uWhitecaps: { value: this.rp("whitecaps") },
13513
- uWaveEnvelope: { value: 0 },
13514
- uCausticsAbove: { value: this.rp("causticsAbove") },
13515
- uShoreWaves: { value: this.rp("shoreWaves") },
13516
- uReflectivityMax: { value: .6 },
13517
- uEdgeFadeOn: { value: 1 }
13518
- },
13519
- transparent: true,
13520
- depthTest: true,
13521
- depthWrite: true,
13522
- side: DoubleSide
13523
- });
13524
- }
13525
- /** The cheap lake ShaderMaterial — fresnel sky tint + ripple normals + sun
13526
- * glint + edge fade, NO scene re-renders (see lake.ts). */
13527
- buildLakeMaterial() {
13528
- return new ShaderMaterial({
13529
- vertexShader: LAKE_VERT,
13530
- fragmentShader: LAKE_FRAG,
13531
- fog: true,
13532
- uniforms: {
13533
- fogColor: { value: new Color("#ffffff") },
13534
- fogNear: { value: 1 },
13535
- fogFar: { value: 1e3 },
13536
- fogDensity: { value: 25e-5 },
13537
- uTime: { value: 0 },
13538
- uOpacity: { value: this.rp("opacity") },
13539
- uColor: { value: new Color(this.rp("color")) },
13540
- uSurfaceColor: { value: new Color(this.rp("color")) },
13541
- uSkyColor: { value: new Color(this.rp("color")) },
13542
- uSunDirection: { value: new Vector3(.5, .8, .3).normalize() },
13543
- uSunColor: { value: new Color(this.sunColor) },
13544
- uSunIntensity: { value: this.rp("sunIntensity") },
13545
- uDetailStrength: { value: this.rp("detailStrength") },
13546
- uFresnelPower: { value: FANCY_LOOK.fresnelPower },
13547
- uScale: { value: new Vector3(1, 1, 1) },
13548
- uWaterCenter: { value: new Vector3() },
13549
- uRipples: { value: Array.from({ length: 8 }, () => new Vector4()) },
13550
- uRippleFoam: { value: Array.from({ length: 8 }, () => 0) },
13551
- uSplashFoam: { value: this.splash ? 1 : 0 },
13552
- uFoamColor: { value: new Color(FOAM_LOOK.color) }
13553
- },
13554
- transparent: true,
13555
- depthTest: true,
13556
- depthWrite: false,
13557
- side: DoubleSide
13558
- });
13559
- }
13560
- syncFancy(mesh) {
13561
- const u = mesh.material.uniforms;
13562
- const w = this.size[0] ?? 1;
13563
- const d = this.size[1] ?? 1;
13564
- const center = worldTranslationOf(this);
13565
- if (u.uTime) u.uTime.value = this.time;
13566
- if (u.uOpacity) u.uOpacity.value = this.rp("opacity");
13567
- if (u.uWavesAmplitude) u.uWavesAmplitude.value = this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT;
13568
- (u.uScale?.value)?.set(w / 2, d / 2, d / 2);
13569
- (u.uWaterCenter?.value)?.set(center.x, center.y, center.z);
13570
- const palette = this.resolvePalette();
13571
- (u.uTroughColor?.value)?.copy(palette.trough);
13572
- (u.uSurfaceColor?.value)?.copy(palette.surface);
13573
- (u.uPeakColor?.value)?.copy(palette.peak);
13574
- (u.uSunDirection?.value)?.set(this.sunDirection[0] ?? 0, this.sunDirection[1] ?? 1, this.sunDirection[2] ?? 0).normalize();
13575
- (u.uSunColor?.value)?.set(this.sunColor);
13576
- if (u.uSunIntensity) u.uSunIntensity.value = this.rp("sunIntensity");
13577
- if (u.uDetailStrength) u.uDetailStrength.value = this.rp("detailStrength");
13578
- if (u.uAbsorption) u.uAbsorption.value = this.rp("absorption");
13579
- if (u.uSwellAmplitude) u.uSwellAmplitude.value = this.rp("swell");
13580
- if (u.uSwellWavelength) u.uSwellWavelength.value = this.rp("swellWavelength");
13581
- if (u.uWhitecaps) u.uWhitecaps.value = this.rp("whitecaps");
13582
- if (u.uCausticsAbove) u.uCausticsAbove.value = this.rp("causticsAbove");
13583
- if (u.uShoreWaves) u.uShoreWaves.value = this.rp("shoreWaves");
13584
- const look = this.presetLook();
13585
- if (u.uFresnelScale) u.uFresnelScale.value = look.fresnelScale;
13586
- if (u.uReflectivityMax) u.uReflectivityMax.value = look.reflectivityMax;
13587
- if (u.uEdgeFadeOn) u.uEdgeFadeOn.value = look.edgeFade ? 1 : 0;
13588
- if (u.uFoamRange) u.uFoamRange.value = foamRangeFor(w, d) * look.foamRangeScale;
13589
- if (u.uFoamIntensity) u.uFoamIntensity.value = look.foamIntensity;
13590
- const swellRad = this.swellDirectionDeg * Math.PI / 180;
13591
- (u.uSwellDir?.value)?.set(Math.sin(swellRad), Math.cos(swellRad));
13592
- if (u.uWaveEnvelope) u.uWaveEnvelope.value = this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT * 1.5 + this.rp("swell") * 1.03;
13593
- if (!this.reflection && u.uUseEnvironmentMap) u.uUseEnvironmentMap.value = 0;
13594
- if (!this.foam && u.uEnableFoam) u.uEnableFoam.value = 0;
13595
- if (!this.refraction && u.uUseRefraction) u.uUseRefraction.value = 0;
13596
- if (u.uSplashFoam) u.uSplashFoam.value = this.splash ? 1 : 0;
13597
- const ripples = u.uRipples?.value;
13598
- const foam = u.uRippleFoam?.value;
13599
- if (ripples) for (let i = 0; i < ripples.length; i++) {
13600
- const r = this._ripples[i];
13601
- if (r) ripples[i].set(r.x, r.z, r.age, r.amp);
13602
- else ripples[i].set(0, 0, 0, 0);
13603
- if (foam) foam[i] = r ? r.foam ?? 0 : 0;
13604
- }
13605
- }
13606
- /**
13607
- * @internal Environment-sky sun hand-off (see {@link SunConsumer3D}): the
13608
- * sky's sun drives the glint ONLY while the `sunDirection` prop sits at its
13609
- * schema default — an explicitly authored prop always wins. Runs right
13610
- * after `_syncObject3D` (which wrote the prop value), overwriting the
13611
- * uniform for this frame.
13612
- */
13613
- _applySunDirection(dir) {
13614
- const def = Water3D.props.sunDirection?.default;
13615
- if (!this.sunDirection.every((v, i) => v === def[i])) return;
13616
- (this._ensureObject3D().material.uniforms?.uSunDirection?.value)?.set(dir[0], dir[1], dir[2]);
13617
- }
13618
- /** The fancy three-color ramp; a customized legacy `color` tints it. */
13619
- resolvePalette() {
13620
- const overrides = this.rp("colors");
13621
- const tint = this.rp("color");
13622
- const legacyTint = tint !== Water3D.props.color?.default ? tint : null;
13623
- const surface = overrides.surface ?? legacyTint ?? FANCY_COLORS.surface;
13624
- paletteScratch.surface.set(surface);
13625
- if (overrides.trough) paletteScratch.trough.set(overrides.trough);
13626
- else if (legacyTint) paletteScratch.trough.set(legacyTint).lerp(BLACK, .45);
13627
- else paletteScratch.trough.set(FANCY_COLORS.trough);
13628
- if (overrides.peak) paletteScratch.peak.set(overrides.peak);
13629
- else if (legacyTint) paletteScratch.peak.set(legacyTint).lerp(WHITE, .3);
13630
- else paletteScratch.peak.set(FANCY_COLORS.peak);
13631
- return paletteScratch;
13632
- }
13633
- syncSimple(mesh) {
13634
- const mat = mesh.material;
13635
- mat.opacity = this.rp("opacity");
13636
- const u = mat.uniforms;
13637
- const w = this.size[0] ?? 1;
13638
- const d = this.size[1] ?? 1;
13639
- const center = worldTranslationOf(this, this._scratchA);
13640
- if (u.uTime) u.uTime.value = this.time;
13641
- if (u.uOpacity) u.uOpacity.value = this.rp("opacity");
13642
- (u.uSurfaceColor?.value)?.set(this.rp("color")).lerp(WHITE, .12);
13643
- (u.uColor?.value)?.set(this.rp("color")).lerp(BLACK, .4);
13644
- (u.uSkyColor?.value)?.set(this.rp("color")).lerp(SKY_PALE, .82);
13645
- (u.uScale?.value)?.set(w / 2, d / 2, d / 2);
13646
- (u.uWaterCenter?.value)?.set(center.x, center.y, center.z);
13647
- (u.uSunDirection?.value)?.set(this.sunDirection[0] ?? 0, this.sunDirection[1] ?? 1, this.sunDirection[2] ?? 0).normalize();
13648
- (u.uSunColor?.value)?.set(this.sunColor);
13649
- if (u.uSunIntensity) u.uSunIntensity.value = this.rp("sunIntensity");
13650
- if (u.uDetailStrength) u.uDetailStrength.value = this.rp("detailStrength");
13651
- if (u.uSplashFoam) u.uSplashFoam.value = this.splash ? 1 : 0;
13652
- const uRip = u.uRipples?.value;
13653
- const uFoam = u.uRippleFoam?.value;
13654
- if (uRip) for (let i = 0; i < uRip.length; i++) {
13655
- const r = this._ripples[i];
13656
- if (r) uRip[i].set(r.x, r.z, r.age, r.amp);
13657
- else uRip[i].set(0, 0, 0, 0);
13658
- if (uFoam) uFoam[i] = r ? r.foam ?? 0 : 0;
13659
- }
13660
- const geo = mesh.geometry;
13661
- const pos = geo.getAttribute("position");
13662
- const norm = geo.getAttribute("normal");
13663
- const t = this.time;
13664
- const wh = this.rp("waveHeight");
13665
- const cx = center.x;
13666
- const cz = center.z;
13667
- const ripples = this._ripples;
13668
- const eps = .5;
13669
- for (let i = 0; i < pos.count; i++) {
13670
- const x = pos.getX(i);
13671
- const z = pos.getZ(i);
13672
- const h = simpleWaveHeight(x, z, t, wh) + ripplesHeightAt(x + cx, z + cz, ripples);
13673
- pos.setY(i, h);
13674
- const hX = simpleWaveHeight(x + eps, z, t, wh) + ripplesHeightAt(x + cx + eps, z + cz, ripples);
13675
- const hZ = simpleWaveHeight(x, z + eps, t, wh) + ripplesHeightAt(x + cx, z + cz + eps, ripples);
13676
- const nx = (h - hX) / eps;
13677
- const nz = (h - hZ) / eps;
13678
- const inv = 1 / Math.hypot(nx, 1, nz);
13679
- norm.setXYZ(i, nx * inv, inv, nz * inv);
13680
- }
13681
- pos.needsUpdate = true;
13682
- norm.needsUpdate = true;
13683
- }
13684
- stepInteraction(dt) {
13685
- let top = this;
13686
- while (top.parent) top = top.parent;
13687
- const bodies = this._bodies;
13688
- bodies.length = 0;
13689
- collectBodies(top, bodies);
13690
- const center = worldTranslationOf(this, this._scratchA);
13691
- const halfW = (this.size[0] ?? 0) / 2;
13692
- const halfD = (this.size[1] ?? 0) / 2;
13693
- const samples = [];
13694
- for (const body of bodies) {
13695
- const p = worldTranslationOf(body, this._scratchB);
13696
- if (Math.abs(p.x - center.x) > halfW || Math.abs(p.z - center.z) > halfD) continue;
13697
- const footDrop = colliderFootDrop(body.collider);
13698
- samples.push({
13699
- id: body,
13700
- x: p.x,
13701
- y: p.y,
13702
- z: p.z,
13703
- waterline: center.y + this.waveOffsetAt(p.x, p.z, center) + footDrop
13704
- });
13705
- }
13706
- const { entered, exited, inWater } = detectCrossings(this.inWater, samples, SURFACE_HYSTERESIS);
13707
- this.inWater = inWater;
13708
- const splashFoam = this.splash;
13709
- for (const s of entered) {
13710
- const vy = Math.abs(bodyVelocity(s.id)?.[1] ?? 0);
13711
- pushRipple(this._ripples, s.x, s.z, entrySplashAmp(s.id), splashFoam ? entryFoam(vy) : 0);
13712
- this.emit("entered", s.id);
13713
- }
13714
- for (const s of exited) {
13715
- pushRipple(this._ripples, s.x, s.z, EXIT_AMP, splashFoam ? EXIT_FOAM : 0);
13716
- this.emit("exited", s.id);
13717
- }
13718
- for (const body of stepBobTimers(this.bobTimers, inWater, dt, BOB_INTERVAL)) {
13719
- const p = worldTranslationOf(body, this._scratchB);
13720
- pushRipple(this._ripples, p.x, p.z, BOB_AMP);
13721
- }
13722
- for (const body of inWater) {
13723
- const p = worldTranslationOf(body, this._scratchB);
13724
- const a = this.wadeAnchors.get(body);
13725
- if (!a) {
13726
- this.wadeAnchors.set(body, {
13727
- x: p.x,
13728
- z: p.z
13729
- });
13730
- continue;
13731
- }
13732
- const dx = p.x - a.x;
13733
- const dz = p.z - a.z;
13734
- if (dx * dx + dz * dz >= WAKE_STEP * WAKE_STEP) {
13735
- const v = bodyVelocity(body);
13736
- const speed = Math.hypot(v?.[0] ?? 0, v?.[2] ?? 0);
13737
- const wakeFoam = splashFoam ? Math.min(WAKE_FOAM_BASE + WAKE_FOAM_PER_SPEED * speed, WAKE_FOAM_MAX) : 0;
13738
- pushRipple(this._ripples, p.x, p.z, WAKE_AMP, wakeFoam);
13739
- a.x = p.x;
13740
- a.z = p.z;
13741
- }
13742
- }
13743
- for (const body of this.wadeAnchors.keys()) if (!inWater.has(body)) this.wadeAnchors.delete(body);
13744
- }
13745
- /** Base wave height at world (x, z) — the CPU twin of the active shader.
13746
- * Ripples are excluded on purpose: a splash must not re-trigger itself. */
13747
- waveOffsetAt(x, z, center) {
13748
- if (this.quality === "simple") return simpleWaveHeight(x - center.x, z - center.z, this.time, this.rp("waveHeight"));
13749
- return waveElevation(x, z, this.time, {
13750
- amplitude: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT,
13751
- frequency: FANCY_WAVE.frequency,
13752
- persistence: FANCY_WAVE.persistence,
13753
- lacunarity: FANCY_WAVE.lacunarity,
13754
- iterations: FANCY_WAVE.iterations,
13755
- speed: FANCY_WAVE.speed,
13756
- scaleNorm: WAVE_WORLD_REF
13757
- });
13758
- }
13759
- };
13760
- /** Bodies that can splash: kinematic characters and dynamic rigid bodies. */
13761
- function collectBodies(node, out) {
13762
- if (node instanceof CharacterBody3D || node instanceof RigidBody3D) out.push(node);
13763
- for (const child of node.children) collectBodies(child, out);
13764
- }
13765
- /**
13766
- * World position approximation: the sum of `position` props up the Node3D
13767
- * ancestor chain. Ancestor ROTATION/SCALE are deliberately ignored — water
13768
- * interaction assumes world-aligned layouts (the overwhelmingly common case);
13769
- * it runs in update(), before the renderer refreshes any matrixWorld.
13770
- */
13771
- function worldTranslationOf(node, out = {
13772
- x: 0,
13773
- y: 0,
13774
- z: 0
13775
- }) {
13776
- let x = 0;
13777
- let y = 0;
13778
- let z = 0;
13779
- let current = node;
13780
- while (current) {
13781
- if (current instanceof Node3D) {
13782
- x += current.position[0] ?? 0;
13783
- y += current.position[1] ?? 0;
13784
- z += current.position[2] ?? 0;
13785
- }
13786
- current = current.parent;
13787
- }
13788
- out.x = x;
13789
- out.y = y;
13790
- out.z = z;
13791
- return out;
13792
- }
13793
- /** A body's velocity vector — kinematic `velocity` or dynamic `linearVelocity`. */
13794
- function bodyVelocity(body) {
13795
- return body.velocity ?? body.linearVelocity;
13796
- }
13797
- /** Entry splash amplitude — faster falls splash harder. */
13798
- function entrySplashAmp(body) {
13799
- const vy = Math.abs(bodyVelocity(body)?.[1] ?? 0);
13800
- return Math.min(ENTER_AMP_BASE + ENTER_AMP_PER_VY * vy, ENTER_AMP_MAX);
13801
- }
13802
- function buildWaterGeometry(width, depth, segments) {
13803
- const geo = new PlaneGeometry(width, depth, segments, segments);
13804
- geo.rotateX(-Math.PI / 2);
13805
- return geo;
13806
- }
13807
- const BLACK = new Color("#000000");
13808
- const WHITE = new Color("#ffffff");
13809
- /** Pale sky-blue the lake's single hue is lifted toward for its fresnel
13810
- * reflection tint (no cube map to sample — see lake.ts). */
13811
- const SKY_PALE = new Color("#cfe3f0");
13812
- const paletteScratch = {
13813
- trough: new Color(),
13814
- surface: new Color(),
13815
- peak: new Color()
13816
- };
13817
- const drawingBufferScratch = new Vector2();
13818
- /** Reused camera world-position scratch for the submersion test (zero alloc). */
13819
- const underwaterCamScratch = new Vector3();
13820
- /** XZ slack (m) so the underwater look persists a touch past the water's rim. */
13821
- const UNDERWATER_MARGIN = 3;
13822
- /** Keep-below clip plane for the scene pre-pass (constant set per render):
13823
- * normal (0,−1,0) keeps y < constant — see the grab-clip note in _onRender3D. */
13824
- const mirrorEyeScratch = new Vector3();
13825
- const mirrorTargetScratch = new Vector3();
13826
- const mirrorUpScratch = new Vector3();
13827
- const mirrorQuatScratch = new Quaternion();
13828
- /** Mirror pass: keep only what is ABOVE the waterline. */
13829
- const mirrorClipPlane = new Plane(new Vector3(0, 1, 0), 0);
13830
- const mirrorClipPlanes = [mirrorClipPlane];
13831
- const grabClipPlane = new Plane(new Vector3(0, -1, 0), 0);
13832
- const grabClipPlanes = [grabClipPlane];
13833
- /** Keep-above twin for the depth-completion pass: normal (0,1,0) keeps
13834
- * y > −constant — set per render to waterY − slack (overlapping the grab). */
13835
- const depthOnlyClipPlane = new Plane(new Vector3(0, 1, 0), 0);
13836
- const depthOnlyClipPlanes = [depthOnlyClipPlane];
13837
- /** Shared override for the depth-completion pass — writes depth, never color. */
13838
- const depthOnlyMaterial = new MeshDepthMaterial();
13839
- depthOnlyMaterial.colorWrite = false;
13840
- //#endregion
13841
10848
  //#region src/3d/register.ts
13842
10849
  /**
13843
10850
  * Register the 3D node taxonomy (and the core nodes). Call once in your game
@@ -13875,4 +10882,4 @@ function registerNodes3D() {
13875
10882
  registerNode(CharacterBody3D);
13876
10883
  }
13877
10884
  //#endregion
13878
- 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 };