incanto 0.19.0 → 0.21.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.
@@ -5,7 +5,7 @@ import { t as Rng } from "./rng-DP-SR7eg.js";
5
5
  import { i as getNodeSchema, l as registerNode } from "./registry-BVJ2HbCn.js";
6
6
  import { t as createNoise2D } from "./noise-CGUMx44x.js";
7
7
  import { i as applyParticlePreset, o as effectiveOrder, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-CyUU7HVU.js";
8
- import { n as splatWeights, t as buildHeightmap } from "./heightmap-DgX-Opav.js";
8
+ import { n as splatWeights, t as buildHeightmap } from "./heightmap-CRK0M4jT.js";
9
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";
10
10
  import { clone } from "three/addons/utils/SkeletonUtils.js";
11
11
  //#region src/3d/camera-rig.ts
@@ -1211,6 +1211,7 @@ var Terrain3D = class extends Node3D {
1211
1211
  layers: { default: [] },
1212
1212
  textureBase: { default: DEFAULT_TERRAIN_TEXTURE_BASE },
1213
1213
  basins: { default: [] },
1214
+ channels: { default: [] },
1214
1215
  wetline: { default: null }
1215
1216
  };
1216
1217
  /** [width, depth] extent in meters, centered on the node. */
@@ -1239,6 +1240,10 @@ var Terrain3D = class extends Node3D {
1239
1240
  * collider + heightAt + dressing all see (place a Water3D in each).
1240
1241
  * NEGATIVE depth raises a smooth dome MOUND instead (a hill, an islet). */
1241
1242
  basins = [];
1243
+ /** River/road trenches carved along polylines — `[{ path, width, depth,
1244
+ * taper? }]`. The line counterpart of `basins`: what gives running water a
1245
+ * bed to sit in, and the collider/heightAt/drape all see it. */
1246
+ channels = [];
1242
1247
  /**
1243
1248
  * WET SAND band: `{ y, band? }` darkens + glosses the splat in a noisy,
1244
1249
  * breathing band just above height `y` (a waterline) — the trace of the
@@ -1282,6 +1287,11 @@ var Terrain3D = class extends Node3D {
1282
1287
  t.basins.forEach((b, i) => {
1283
1288
  if (!b || typeof b !== "object" || !Number.isFinite(b.x) || !Number.isFinite(b.z) || !(b.radius > 0) || !Number.isFinite(b.depth) || b.depth === 0) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' basins[${i}] needs finite x, z, positive radius and nonzero depth (negative = mound), got ${JSON.stringify(b)}.`, { prop: "basins" });
1284
1289
  });
1290
+ if (!Array.isArray(t.channels)) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' channels must be an array of { path, width, depth, taper? }, got ${JSON.stringify(t.channels)}.`, { prop: "channels" });
1291
+ t.channels.forEach((c, i) => {
1292
+ const path = c.path;
1293
+ if (!(c && typeof c === "object" && Array.isArray(path) && path.length >= 2 && path.every((p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1])) && c.width > 0 && c.depth > 0 && (c.taper === void 0 || c.taper > 0))) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' channels[${i}] needs a path of 2+ [x, z] points plus positive width and depth, got ${JSON.stringify(c)}.`, { prop: "channels" });
1294
+ });
1285
1295
  if (t.theme === "custom") {
1286
1296
  if (!Array.isArray(t.layers) || t.layers.length < 1 || t.layers.length > 4) throw new IncantoError("BAD_FORMAT", `Terrain3D '${node.name}' theme 'custom' needs 1–4 layers, got ${Array.isArray(t.layers) ? t.layers.length : typeof t.layers}.`, { prop: "layers" });
1287
1297
  t.layers.forEach((layer, i) => {
@@ -1326,7 +1336,8 @@ var Terrain3D = class extends Node3D {
1326
1336
  this.detail,
1327
1337
  this.flatThreshold,
1328
1338
  this.effectiveIslandEdge(),
1329
- this.basins
1339
+ this.basins,
1340
+ this.channels
1330
1341
  ]);
1331
1342
  if (!this.heightmap || key !== this.heightmapKey) {
1332
1343
  this.heightmap = buildHeightmap({
@@ -1340,7 +1351,8 @@ var Terrain3D = class extends Node3D {
1340
1351
  detail: this.detail,
1341
1352
  flatThreshold: this.flatThreshold,
1342
1353
  islandEdge: this.effectiveIslandEdge(),
1343
- basins: this.basins
1354
+ basins: this.basins,
1355
+ channels: this.channels
1344
1356
  });
1345
1357
  this.heightmapKey = key;
1346
1358
  }
@@ -5697,7 +5709,7 @@ function validateSections(sections, name) {
5697
5709
  }
5698
5710
  }
5699
5711
  /** Endpoint-clamped Catmull-Rom through scalar samples. */
5700
- function catmullRom(p0, p1, p2, p3, t) {
5712
+ function catmullRom$1(p0, p1, p2, p3, t) {
5701
5713
  const t2 = t * t;
5702
5714
  const t3 = t2 * t;
5703
5715
  return .5 * (2 * p1 + (p2 - p0) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
@@ -5724,11 +5736,11 @@ function sampleRings(sections, smooth) {
5724
5736
  for (let s = 0; s < steps; s++) {
5725
5737
  const t = s / smooth;
5726
5738
  rings.push({
5727
- z: catmullRom(a.z, b.z, c.z, d.z, t),
5728
- width: Math.max(1e-4, catmullRom(a.width, b.width, c.width, d.width, t)),
5729
- height: Math.max(1e-4, catmullRom(a.height, b.height, c.height, d.height, t)),
5730
- y: catmullRom(a.y, b.y, c.y, d.y, t),
5731
- corner: Math.min(1, Math.max(0, catmullRom(a.corner, b.corner, c.corner, d.corner, t)))
5739
+ z: catmullRom$1(a.z, b.z, c.z, d.z, t),
5740
+ width: Math.max(1e-4, catmullRom$1(a.width, b.width, c.width, d.width, t)),
5741
+ height: Math.max(1e-4, catmullRom$1(a.height, b.height, c.height, d.height, t)),
5742
+ y: catmullRom$1(a.y, b.y, c.y, d.y, t),
5743
+ corner: Math.min(1, Math.max(0, catmullRom$1(a.corner, b.corner, c.corner, d.corner, t)))
5732
5744
  });
5733
5745
  }
5734
5746
  }
@@ -6672,6 +6684,1289 @@ function softSprite() {
6672
6684
  return tex;
6673
6685
  }
6674
6686
  //#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
+ //#region src/3d/water/river.ts
6803
+ /** Dense Catmull-Rom taps per control segment before arc-length resampling. */
6804
+ const SPLINE_TAPS = 24;
6805
+ /** Box-filter half-window (samples) that irons kinks out of the bed profile. */
6806
+ const PROFILE_SMOOTH = 3;
6807
+ /** Grade → current gain: a 1:10 chute runs ~1.4× the reference speed. */
6808
+ const SLOPE_GAIN = 4;
6809
+ const SPEED_MIN = .05;
6810
+ const SPEED_MAX = 12;
6811
+ /**
6812
+ * Smooth the control path and walk it at even arc-length steps. The step is
6813
+ * nudged so the last sample lands exactly on the mouth — an odd short segment
6814
+ * at the end would pinch the ribbon's final quad.
6815
+ */
6816
+ function resamplePath(path, step) {
6817
+ const pts = path.map((p) => ({
6818
+ x: p[0] ?? 0,
6819
+ z: p[1] ?? 0
6820
+ })).filter((p, i, all) => i === 0 || Math.hypot(p.x - (all[i - 1]?.x ?? 0), p.z - (all[i - 1]?.z ?? 0)) > 1e-6);
6821
+ if (pts.length < 2 || step <= 0) return [];
6822
+ const dense = [];
6823
+ let acc = 0;
6824
+ for (let i = 0; i < pts.length - 1; i++) {
6825
+ const p0 = pts[Math.max(0, i - 1)];
6826
+ const p1 = pts[i];
6827
+ const p2 = pts[i + 1];
6828
+ const p3 = pts[Math.min(pts.length - 1, i + 2)];
6829
+ for (let s = 0; s < SPLINE_TAPS; s++) {
6830
+ const t = s / SPLINE_TAPS;
6831
+ const x = catmullRom(p0.x, p1.x, p2.x, p3.x, t);
6832
+ const z = catmullRom(p0.z, p1.z, p2.z, p3.z, t);
6833
+ const prev = dense[dense.length - 1];
6834
+ if (prev) acc += Math.hypot(x - prev.x, z - prev.z);
6835
+ dense.push({
6836
+ x,
6837
+ z,
6838
+ along: acc
6839
+ });
6840
+ }
6841
+ }
6842
+ const tail = pts[pts.length - 1];
6843
+ const prev = dense[dense.length - 1];
6844
+ if (prev) acc += Math.hypot(tail.x - prev.x, tail.z - prev.z);
6845
+ dense.push({
6846
+ x: tail.x,
6847
+ z: tail.z,
6848
+ along: acc
6849
+ });
6850
+ const total = acc;
6851
+ if (total < 1e-6) return [];
6852
+ const spans = Math.max(1, Math.round(total / step));
6853
+ const even = total / spans;
6854
+ const out = [];
6855
+ let cursor = 0;
6856
+ for (let i = 0; i <= spans; i++) {
6857
+ const target = Math.min(i * even, total);
6858
+ while (cursor < dense.length - 2 && dense[cursor + 1].along < target) cursor++;
6859
+ const a = dense[cursor];
6860
+ const b = dense[Math.min(cursor + 1, dense.length - 1)];
6861
+ const span = b.along - a.along;
6862
+ const t = span > 1e-9 ? (target - a.along) / span : 0;
6863
+ out.push({
6864
+ x: a.x + (b.x - a.x) * t,
6865
+ z: a.z + (b.z - a.z) * t,
6866
+ tx: 1,
6867
+ tz: 0,
6868
+ along: target
6869
+ });
6870
+ }
6871
+ for (let i = 0; i < out.length; i++) {
6872
+ const a = out[Math.max(0, i - 1)];
6873
+ const b = out[Math.min(out.length - 1, i + 1)];
6874
+ const dx = b.x - a.x;
6875
+ const dz = b.z - a.z;
6876
+ const len = Math.hypot(dx, dz) || 1;
6877
+ const cur = out[i];
6878
+ cur.tx = dx / len;
6879
+ cur.tz = dz / len;
6880
+ }
6881
+ return out;
6882
+ }
6883
+ /** Width profile lookup at normalized distance `t` ∈ [0, 1] down the river. */
6884
+ function widthAlong(widths, t) {
6885
+ if (widths.length === 0) return 0;
6886
+ if (widths.length === 1) return widths[0];
6887
+ const pos = Math.min(Math.max(t, 0), 1) * (widths.length - 1);
6888
+ const i = Math.min(Math.floor(pos), widths.length - 2);
6889
+ const f = pos - i;
6890
+ const a = widths[i];
6891
+ return a + (widths[i + 1] - a) * f;
6892
+ }
6893
+ /**
6894
+ * Stations along the river: surface height, grade and current.
6895
+ *
6896
+ * The surface profile is the bed's DESCENDING envelope (a running minimum),
6897
+ * box-smoothed and re-clamped — water can pour over a lip but never climb one,
6898
+ * and the smoothing keeps a noisy bed from shattering the surface into steps.
6899
+ * Bed bumps that poke above the resulting surface simply lose their water
6900
+ * column: the shader drops them out as riffle rocks.
6901
+ */
6902
+ function buildRiverRings(opts) {
6903
+ const samples = resamplePath(opts.path, opts.step);
6904
+ if (samples.length === 0) return [];
6905
+ const total = samples[samples.length - 1].along || 1;
6906
+ const halfWidths = samples.map((s) => Math.max(widthAlong(opts.widths, s.along / total), .05) / 2);
6907
+ const bed = samples.map((s) => opts.bedAt(s.x, s.z));
6908
+ const descending = new Array(bed.length);
6909
+ for (let i = 0; i < bed.length; i++) {
6910
+ const raw = bed[i];
6911
+ descending[i] = i === 0 ? raw : Math.min(raw, descending[i - 1]);
6912
+ }
6913
+ const keep = Math.max(.5, opts.depth * 1.5);
6914
+ const smoothed = descending.map((center, i) => {
6915
+ let sum = 0;
6916
+ let n = 0;
6917
+ for (let k = -3; k <= PROFILE_SMOOTH; k++) {
6918
+ const h = descending[Math.min(Math.max(i + k, 0), descending.length - 1)];
6919
+ if (Math.abs(h - center) > keep) continue;
6920
+ sum += h;
6921
+ n++;
6922
+ }
6923
+ return n > 0 ? sum / n : center;
6924
+ });
6925
+ const profile = new Array(smoothed.length);
6926
+ for (let i = 0; i < smoothed.length; i++) {
6927
+ const s = smoothed[i];
6928
+ profile[i] = i === 0 ? s : Math.min(s, profile[i - 1]);
6929
+ }
6930
+ const meanHalf = halfWidths.reduce((a, b) => a + b, 0) / halfWidths.length || 1;
6931
+ return samples.map((s, i) => {
6932
+ const surfaceY = profile[i] + opts.depth;
6933
+ const prev = Math.max(0, i - 1);
6934
+ const next = Math.min(samples.length - 1, i + 1);
6935
+ const run = samples[next].along - samples[prev].along;
6936
+ const drop = (profile[prev] + opts.depth - (profile[next] + opts.depth)) / (run || 1);
6937
+ const slope = Math.max(0, drop);
6938
+ const halfWidth = halfWidths[i];
6939
+ const speed = Math.min(Math.max(opts.flowSpeed * Math.sqrt(meanHalf / halfWidth) * (1 + SLOPE_GAIN * slope), SPEED_MIN), SPEED_MAX);
6940
+ return {
6941
+ ...s,
6942
+ halfWidth,
6943
+ surfaceY,
6944
+ slope,
6945
+ speed
6946
+ };
6947
+ });
6948
+ }
6949
+ /** Ribbon mesh for the stations — flat across, draped along, attribute-rich. */
6950
+ function buildRiverGeometry(rings, opts) {
6951
+ const across = Math.max(1, Math.round(opts.acrossSegments));
6952
+ const perRing = across + 1;
6953
+ const count = rings.length * perRing;
6954
+ const positions = new Float32Array(count * 3);
6955
+ const normals = new Float32Array(count * 3);
6956
+ const uvs = new Float32Array(count * 2);
6957
+ const flow = new Float32Array(count * 2);
6958
+ const river = new Float32Array(count * 4);
6959
+ const quads = Math.max(0, rings.length - 1) * across;
6960
+ const indices = new Uint32Array(quads * 6);
6961
+ for (let r = 0; r < rings.length; r++) {
6962
+ const ring = rings[r];
6963
+ const px = -ring.tz;
6964
+ const pz = ring.tx;
6965
+ const nLen = Math.hypot(ring.slope, 1);
6966
+ const nx = ring.tx * ring.slope / nLen;
6967
+ const ny = 1 / nLen;
6968
+ const nz = ring.tz * ring.slope / nLen;
6969
+ for (let c = 0; c <= across; c++) {
6970
+ const t = c / across * 2 - 1;
6971
+ const offset = t * ring.halfWidth * opts.bankOvershoot;
6972
+ const x = ring.x + px * offset;
6973
+ const z = ring.z + pz * offset;
6974
+ const i = r * perRing + c;
6975
+ positions[i * 3] = x;
6976
+ positions[i * 3 + 1] = ring.surfaceY;
6977
+ positions[i * 3 + 2] = z;
6978
+ normals[i * 3] = nx;
6979
+ normals[i * 3 + 1] = ny;
6980
+ normals[i * 3 + 2] = nz;
6981
+ uvs[i * 2] = ring.along;
6982
+ uvs[i * 2 + 1] = offset;
6983
+ flow[i * 2] = ring.tx;
6984
+ flow[i * 2 + 1] = ring.tz;
6985
+ river[i * 4] = ring.speed;
6986
+ river[i * 4 + 1] = Math.max(0, ring.surfaceY - sampleBed(opts, x, z));
6987
+ river[i * 4 + 2] = ring.slope;
6988
+ river[i * 4 + 3] = t * opts.bankOvershoot;
6989
+ }
6990
+ }
6991
+ let w = 0;
6992
+ for (let r = 0; r + 1 < rings.length; r++) for (let c = 0; c < across; c++) {
6993
+ const a = r * perRing + c;
6994
+ const b = a + 1;
6995
+ const d = a + perRing;
6996
+ const e = d + 1;
6997
+ indices[w++] = a;
6998
+ indices[w++] = b;
6999
+ indices[w++] = d;
7000
+ indices[w++] = b;
7001
+ indices[w++] = e;
7002
+ indices[w++] = d;
7003
+ }
7004
+ return {
7005
+ positions,
7006
+ normals,
7007
+ uvs,
7008
+ flow,
7009
+ river,
7010
+ indices
7011
+ };
7012
+ }
7013
+ /**
7014
+ * Where a world point sits in the channel — the query gameplay runs against
7015
+ * (drift a boat, sweep a swimmer, splash a footstep). Projects onto the two
7016
+ * segments neighbouring the closest station and keeps the better one.
7017
+ */
7018
+ /** Bed height under a vertex, averaged over `bedBlur` (0 = a single tap). */
7019
+ function sampleBed(opts, x, z) {
7020
+ const r = opts.bedBlur ?? 0;
7021
+ if (r <= 0) return opts.bedAt(x, z);
7022
+ return (opts.bedAt(x, z) + opts.bedAt(x + r, z) + opts.bedAt(x - r, z) + opts.bedAt(x, z + r) + opts.bedAt(x, z - r)) / 5;
7023
+ }
7024
+ function projectToRiver(rings, x, z) {
7025
+ if (rings.length === 0) return null;
7026
+ let best = 0;
7027
+ let bestD = Number.POSITIVE_INFINITY;
7028
+ for (let i = 0; i < rings.length; i++) {
7029
+ const r = rings[i];
7030
+ const d = (r.x - x) ** 2 + (r.z - z) ** 2;
7031
+ if (d < bestD) {
7032
+ bestD = d;
7033
+ best = i;
7034
+ }
7035
+ }
7036
+ if (rings.length === 1) {
7037
+ const only = rings[0];
7038
+ return hitFrom(only, only, 0, x, z, false);
7039
+ }
7040
+ let hit = null;
7041
+ let hitD = Number.POSITIVE_INFINITY;
7042
+ for (const start of [best - 1, best]) {
7043
+ if (start < 0 || start + 1 >= rings.length) continue;
7044
+ const a = rings[start];
7045
+ const b = rings[start + 1];
7046
+ const dx = b.x - a.x;
7047
+ const dz = b.z - a.z;
7048
+ const len2 = dx * dx + dz * dz;
7049
+ const raw = len2 > 1e-9 ? ((x - a.x) * dx + (z - a.z) * dz) / len2 : 0;
7050
+ const t = Math.min(Math.max(raw, 0), 1);
7051
+ const capped = start === 0 && raw < 0 || start + 2 === rings.length && raw > 1;
7052
+ const px = a.x + dx * t;
7053
+ const pz = a.z + dz * t;
7054
+ const d = (px - x) ** 2 + (pz - z) ** 2;
7055
+ if (d < hitD) {
7056
+ hitD = d;
7057
+ hit = hitFrom(a, b, t, x, z, capped);
7058
+ }
7059
+ }
7060
+ return hit;
7061
+ }
7062
+ function hitFrom(a, b, t, x, z, capped) {
7063
+ const cx = a.x + (b.x - a.x) * t;
7064
+ const cz = a.z + (b.z - a.z) * t;
7065
+ let tx = a.tx + (b.tx - a.tx) * t;
7066
+ let tz = a.tz + (b.tz - a.tz) * t;
7067
+ const tl = Math.hypot(tx, tz) || 1;
7068
+ tx /= tl;
7069
+ tz /= tl;
7070
+ const halfWidth = a.halfWidth + (b.halfWidth - a.halfWidth) * t;
7071
+ const across = ((x - cx) * -tz + (z - cz) * tx) / (halfWidth || 1);
7072
+ return {
7073
+ inside: !capped && Math.abs(across) <= 1,
7074
+ across,
7075
+ along: a.along + (b.along - a.along) * t,
7076
+ surfaceY: a.surfaceY + (b.surfaceY - a.surfaceY) * t,
7077
+ dirX: tx,
7078
+ dirZ: tz,
7079
+ speed: a.speed + (b.speed - a.speed) * t,
7080
+ halfWidth
7081
+ };
7082
+ }
7083
+ /**
7084
+ * Find the waterfalls in a course: maximal runs of steep stations, reported at
7085
+ * the station where the water lands. Callers hang spray on these, and the
7086
+ * shader aerates the curtain from the same grade the detector reads — one
7087
+ * source of truth for "this is falling, not flowing".
7088
+ */
7089
+ function detectFalls(rings, opts = {}) {
7090
+ const minGrade = opts.minGrade ?? .45;
7091
+ const minDrop = opts.minDrop ?? 1.2;
7092
+ const out = [];
7093
+ let start = -1;
7094
+ for (let i = 0; i <= rings.length; i++) {
7095
+ const steep = i < rings.length && rings[i].slope >= minGrade;
7096
+ if (steep && start < 0) start = i;
7097
+ if (steep || start < 0) continue;
7098
+ const lip = rings[Math.max(0, start - 1)];
7099
+ const base = rings[Math.min(rings.length - 1, i)];
7100
+ const drop = lip.surfaceY - base.surfaceY;
7101
+ if (drop >= minDrop) out.push({
7102
+ x: base.x,
7103
+ z: base.z,
7104
+ topY: lip.surfaceY,
7105
+ baseY: base.surfaceY,
7106
+ drop,
7107
+ halfWidth: base.halfWidth,
7108
+ dirX: base.tx,
7109
+ dirZ: base.tz,
7110
+ speed: base.speed
7111
+ });
7112
+ start = -1;
7113
+ }
7114
+ return out;
7115
+ }
7116
+ /**
7117
+ * Trace where water would actually run from a point: repeated steepest descent
7118
+ * with momentum, so the line follows a valley floor instead of rattling between
7119
+ * its walls. Ends when the ground goes flat (a pool), the trace leaves the play
7120
+ * area, or `maxPoints` is reached.
7121
+ *
7122
+ * Feed the result straight into a {@link River3D} `path` — the terrain then
7123
+ * picks its own river, which is how a generated map gets water that belongs.
7124
+ */
7125
+ function traceDownhillPath(bedAt, opts) {
7126
+ const step = Math.max(opts.step, .001);
7127
+ const maxPoints = Math.max(2, opts.maxPoints ?? 200);
7128
+ const minDrop = opts.minDrop ?? step * .01;
7129
+ const bounds = opts.bounds ?? Number.POSITIVE_INFINITY;
7130
+ let x = opts.x;
7131
+ let z = opts.z;
7132
+ let dirX = 0;
7133
+ let dirZ = 0;
7134
+ let stalls = 0;
7135
+ const out = [[x, z]];
7136
+ for (let i = 1; i < maxPoints; i++) {
7137
+ const gx = (bedAt(x + step, z) - bedAt(x - step, z)) / (2 * step);
7138
+ const gz = (bedAt(x, z + step) - bedAt(x, z - step)) / (2 * step);
7139
+ const grad = Math.hypot(gx, gz);
7140
+ let nx = grad > 1e-9 ? -gx / grad : dirX;
7141
+ let nz = grad > 1e-9 ? -gz / grad : dirZ;
7142
+ nx = nx * .6 + dirX * .4;
7143
+ nz = nz * .6 + dirZ * .4;
7144
+ const len = Math.hypot(nx, nz);
7145
+ if (len < 1e-6) break;
7146
+ dirX = nx / len;
7147
+ dirZ = nz / len;
7148
+ let px = x + dirX * step;
7149
+ let pz = z + dirZ * step;
7150
+ let bestOff = 0;
7151
+ let bestY = bedAt(px, pz);
7152
+ for (const off of [
7153
+ -.8,
7154
+ -.4,
7155
+ .4,
7156
+ .8
7157
+ ]) {
7158
+ const y = bedAt(px - dirZ * off * step, pz + dirX * off * step);
7159
+ if (y < bestY - 1e-4) {
7160
+ bestY = y;
7161
+ bestOff = off;
7162
+ }
7163
+ }
7164
+ if (bestOff !== 0) {
7165
+ px -= dirZ * bestOff * step;
7166
+ pz += dirX * bestOff * step;
7167
+ }
7168
+ if (Math.abs(px) > bounds || Math.abs(pz) > bounds) break;
7169
+ if (bedAt(x, z) - bedAt(px, pz) < minDrop) {
7170
+ if (++stalls >= 3) break;
7171
+ } else stalls = 0;
7172
+ x = px;
7173
+ z = pz;
7174
+ out.push([x, z]);
7175
+ }
7176
+ return out.length >= 2 ? out : [];
7177
+ }
7178
+ function catmullRom(p0, p1, p2, p3, t) {
7179
+ const t2 = t * t;
7180
+ const t3 = t2 * t;
7181
+ return .5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
7182
+ }
7183
+ //#endregion
7184
+ //#region src/3d/water/river-shaders.ts
7185
+ /**
7186
+ * River surface shader — running water, as distinct from the standing water of
7187
+ * {@link WATER_FRAG}.
7188
+ *
7189
+ * The whole look hangs off four per-vertex facts the CPU derives from the path
7190
+ * and the ground (see river.ts): the downstream direction, the current speed,
7191
+ * the water column and the grade. From those:
7192
+ *
7193
+ * - **the current is a frame, not a scroll.** Every pattern lives in the
7194
+ * ribbon's (downstream, across) meters, so a bend carries its ripples around
7195
+ * the corner instead of dragging a world-locked texture sideways.
7196
+ * - **speed varies along a river, and that shears any scrolling field apart.**
7197
+ * Two copies of every advected pattern run half a period out of step and
7198
+ * cross-fade on a triangle wave, so each is reset while the other carries the
7199
+ * image — the surface never accumulates smear.
7200
+ * - **whitewater is derived, never painted.** Grade × speed makes rapids, the
7201
+ * bank shear line makes edge froth, a thinning column makes riffles over
7202
+ * rock. Foam then rides downstream as stretched streaks.
7203
+ * - **standing waves stand.** In a chute the humps are world-locked (phase on
7204
+ * the downstream coordinate, no scroll) while the water tears through them —
7205
+ * the signature of a hydraulic jump, and the thing that reads as "fast".
7206
+ */
7207
+ const RIVER_NOISE = `
7208
+ float riverHash(vec2 p) {
7209
+ p = fract(p * vec2(127.31, 311.7));
7210
+ p += dot(p, p + 34.23);
7211
+ return fract(p.x * p.y);
7212
+ }
7213
+
7214
+ float riverNoise(vec2 p) {
7215
+ vec2 i = floor(p);
7216
+ vec2 f = fract(p);
7217
+ vec2 u = f * f * (3.0 - 2.0 * f);
7218
+ float a = riverHash(i);
7219
+ float b = riverHash(i + vec2(1.0, 0.0));
7220
+ float c = riverHash(i + vec2(0.0, 1.0));
7221
+ float d = riverHash(i + vec2(1.0, 1.0));
7222
+ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
7223
+ }
7224
+ `;
7225
+ const RIVER_VERT = `
7226
+ precision highp float;
7227
+
7228
+ #include <fog_pars_vertex>
7229
+
7230
+ uniform float uTime;
7231
+ uniform float uStandingAmp;
7232
+ uniform float uGlideAmp;
7233
+
7234
+ // (downstream m, across m) — the flow frame every pattern lives in
7235
+ attribute vec2 aFlowUv;
7236
+ // unit downstream direction in XZ
7237
+ attribute vec2 aFlow;
7238
+ // (speed m/s, water column m, grade, across ∈ [-overshoot, +overshoot])
7239
+ attribute vec4 aRiver;
7240
+
7241
+ varying vec3 vWorldPosition;
7242
+ varying vec3 vSurfaceNormal;
7243
+ varying vec2 vFlowUv;
7244
+ varying vec2 vFlow;
7245
+ varying vec4 vRiver;
7246
+
7247
+ void main() {
7248
+ vFlowUv = aFlowUv;
7249
+ vFlow = aFlow;
7250
+ vRiver = aRiver;
7251
+
7252
+ float column = aRiver.y;
7253
+ float speed = aRiver.x;
7254
+ float grade = aRiver.z;
7255
+ float across = aRiver.w;
7256
+
7257
+ // displace only where there IS water: a bank vertex whose column has run out
7258
+ // must stay welded to the ground or the ribbon lifts off the shore
7259
+ float body = smoothstep(0.0, 0.3, column) * (1.0 - smoothstep(0.7, 1.0, abs(across)));
7260
+
7261
+ // hydraulic jump: in a chute the wave train stands still in the world (phase
7262
+ // on the downstream coordinate, NEVER scrolled) and only breathes in time
7263
+ // standing waves belong to a RAPID: past ~20° the water has left the bed and
7264
+ // a wave train on a falling curtain reads as a rippling flag
7265
+ float chute = smoothstep(0.035, 0.16, grade) * smoothstep(0.6, 2.4, speed) *
7266
+ (1.0 - smoothstep(0.35, 0.7, grade));
7267
+ float standing =
7268
+ sin(aFlowUv.x * 1.9 + sin(uTime * 0.7) * 0.35) * 0.6 +
7269
+ sin(aFlowUv.x * 3.6 + sin(uTime * 0.9) * 0.5) * 0.4;
7270
+ // calm reach: one long low undulation gliding downstream with the current
7271
+ float glide = sin(aFlowUv.x * 0.5 - uTime * speed * 0.5 + aFlowUv.y * 0.3);
7272
+
7273
+ vec3 displaced = position;
7274
+ displaced.y += body * (standing * chute * uStandingAmp + glide * uGlideAmp * (1.0 - chute));
7275
+
7276
+ vec4 world = modelMatrix * vec4(displaced, 1.0);
7277
+ vWorldPosition = world.xyz;
7278
+ vSurfaceNormal = normalize(mat3(modelMatrix) * normal);
7279
+ // three's fog chunk reads mvPosition — the built-in materials get it from
7280
+ // the project_vertex chunk, which a hand-written vertex stage stands in for
7281
+ vec4 mvPosition = viewMatrix * world;
7282
+ gl_Position = projectionMatrix * mvPosition;
7283
+
7284
+ #include <fog_vertex>
7285
+ }
7286
+ `;
7287
+ const RIVER_FRAG = `
7288
+ precision highp float;
7289
+
7290
+ #include <fog_pars_fragment>
7291
+
7292
+ uniform float uTime;
7293
+ uniform vec3 uShallowColor;
7294
+ uniform vec3 uDeepColor;
7295
+ uniform vec3 uFoamColor;
7296
+ uniform vec3 uSkyColor;
7297
+ uniform vec3 uHorizonColor;
7298
+ uniform vec3 uBankColor;
7299
+ uniform vec3 uSunDirection;
7300
+ uniform vec3 uSunColor;
7301
+ uniform float uSunIntensity;
7302
+ uniform float uMaxDepth;
7303
+ uniform float uAbsorption;
7304
+ uniform float uOpacity;
7305
+ uniform float uFoam;
7306
+ uniform float uRippleScale;
7307
+ uniform float uRippleStrength;
7308
+ uniform float uFlowRate;
7309
+ uniform float uSparkle;
7310
+ uniform float uFresnelScale;
7311
+
7312
+ varying vec3 vWorldPosition;
7313
+ varying vec3 vSurfaceNormal;
7314
+ varying vec2 vFlowUv;
7315
+ varying vec2 vFlow;
7316
+ varying vec4 vRiver;
7317
+
7318
+ ${RIVER_NOISE}
7319
+
7320
+ /**
7321
+ * Ripple field in flow space: (height, d/d-downstream, d/d-across).
7322
+ *
7323
+ * Anisotropic on purpose — the downstream axis is squeezed so crests run
7324
+ * ACROSS the channel, which is what wavelets on running water actually do.
7325
+ */
7326
+ vec3 riverRipple(vec2 p) {
7327
+ vec2 q = vec2(p.x * 1.7, p.y * 0.55);
7328
+ float h = riverNoise(q) * 0.62 + riverNoise(q * 2.4 + 13.1) * 0.38;
7329
+ float e = 0.35;
7330
+ vec2 qa = vec2((p.x + e) * 1.7, p.y * 0.55);
7331
+ vec2 qb = vec2(p.x * 1.7, (p.y + e) * 0.55);
7332
+ float ha = riverNoise(qa) * 0.62 + riverNoise(qa * 2.4 + 13.1) * 0.38;
7333
+ float hb = riverNoise(qb) * 0.62 + riverNoise(qb * 2.4 + 13.1) * 0.38;
7334
+ return vec3(h, (ha - h) / e, (hb - h) / e);
7335
+ }
7336
+
7337
+ /** Foam streaks: stretched downstream, so froth reads as trails, but broken
7338
+ * enough across the channel that it never becomes a painted stripe. */
7339
+ float riverStreak(vec2 p) {
7340
+ vec2 q = vec2(p.x * 0.5, p.y * 2.0);
7341
+ return riverNoise(q) * 0.5 + riverNoise(q * 3.3 + 7.3) * 0.5;
7342
+ }
7343
+
7344
+ void main() {
7345
+ float speed = vRiver.x;
7346
+ float column = vRiver.y;
7347
+ float grade = vRiver.z;
7348
+ float across = vRiver.w;
7349
+
7350
+ vec2 dir = normalize(vFlow + vec2(1e-5, 0.0));
7351
+ vec3 dir3 = vec3(dir.x, 0.0, dir.y);
7352
+ vec3 perp3 = vec3(-dir.y, 0.0, dir.x);
7353
+
7354
+ // ── dual-phase advection ────────────────────────────────────────────────
7355
+ // A field scrolled at a speed that varies along the river stretches a little
7356
+ // more every frame until it smears. Run two copies half a period apart and
7357
+ // cross-fade with a triangle wave: whichever copy is near its wrap is the one
7358
+ // being faded out, so the reset never shows.
7359
+ float phase = uTime * uFlowRate;
7360
+ float pA = fract(phase);
7361
+ float pB = fract(phase + 0.5);
7362
+ float blend = abs(pA - 0.5) * 2.0;
7363
+ vec2 base = vFlowUv * uRippleScale;
7364
+ float travel = speed * uRippleScale / max(uFlowRate, 0.001);
7365
+
7366
+ vec3 ripA = riverRipple(base - vec2(travel * pA, 0.0));
7367
+ vec3 ripB = riverRipple(base - vec2(travel * pB, 0.0));
7368
+ vec3 ripple = mix(ripA, ripB, blend);
7369
+
7370
+ // on a curtain the pattern stretches into strands: the same field, sampled
7371
+ // with the downstream axis squeezed hard, is what reads as falling water
7372
+ float streakScale = mix(0.55, 0.16, smoothstep(0.35, 0.9, grade));
7373
+ float streakA = riverStreak(base * streakScale - vec2(travel * pA * streakScale, 0.0));
7374
+ float streakB = riverStreak(base * streakScale - vec2(travel * pB * streakScale, 0.0));
7375
+ float streak = mix(streakA, streakB, blend);
7376
+
7377
+ // ripples flatten out where the water thins to a film over the bed
7378
+ float lively = smoothstep(0.02, 0.22, column) * (0.45 + 0.55 * smoothstep(0.1, 1.6, speed));
7379
+ // the gradient is a NOISE slope, not a water slope: clamp it before it tilts
7380
+ // the normal past a real wavelet (an over-driven normal throws fresnel to its
7381
+ // maximum everywhere and the river turns into a sheet of white sky)
7382
+ vec2 slopeXY = clamp(ripple.yz * uRippleStrength * lively, vec2(-0.5), vec2(0.5));
7383
+ vec3 normal = normalize(vSurfaceNormal - dir3 * slopeXY.x - perp3 * slopeXY.y);
7384
+
7385
+ vec3 viewDir = normalize(cameraPosition - vWorldPosition);
7386
+
7387
+ // ── whitewater ──────────────────────────────────────────────────────────
7388
+ // three independent births, all physical: a steep fast reach breaks into
7389
+ // rapids, the shear line along a bank froths, and a column thin enough to
7390
+ // scrape the bed riffles over it
7391
+ float rapids = smoothstep(0.07, 0.22, grade) * smoothstep(1.2, 3.0, speed);
7392
+ // bank froth comes and goes along a shear line — a continuous white hem
7393
+ // around the whole river reads as a drawn outline, never as water
7394
+ float bankPatch = smoothstep(0.35, 0.75, riverStreak(base * 0.22 - vec2(travel * 0.12, 0.0)));
7395
+ float bankShear =
7396
+ smoothstep(0.74, 1.0, abs(across)) * smoothstep(0.8, 2.4, speed) * (0.35 + 0.65 * bankPatch);
7397
+ // shallow is RELATIVE to this river: a hand's depth is a riffle in a brook
7398
+ // and nothing at all in a millrace, so the thresholds ride uMaxDepth
7399
+ float shallowT = column / max(uMaxDepth, 0.05);
7400
+ // a riffle needs water to break OVER something — a centimetres-thin film on
7401
+ // a drying bar is a wet rock, and painting it white is what turns the tail of
7402
+ // a creek into a bright sheet
7403
+ float riffle =
7404
+ smoothstep(0.04, 0.13, column) *
7405
+ (1.0 - smoothstep(0.12, 0.42, shallowT)) *
7406
+ smoothstep(0.7, 2.2, speed);
7407
+ // FALLING, not flowing: past ~20° the sheet leaves the bed, shatters and
7408
+ // aerates. Aerated water is a white DIFFUSE body — it stops mirroring the
7409
+ // sky (that is the tell that separates a waterfall from a steep chute).
7410
+ float fall = smoothstep(0.35, 0.9, grade);
7411
+ float drive = clamp(rapids * 1.0 + bankShear * 0.35 + riffle * 0.5 + fall * 1.6, 0.0, 2.0) * uFoam;
7412
+ // COVERAGE is the thing to be strict about: whitewater is where the surface
7413
+ // has actually broken, and a river that fizzes everywhere reads as milk
7414
+ float foam = smoothstep(0.74 - 0.45 * drive, 1.02 - 0.24 * drive, streak) * min(drive, 1.0);
7415
+ // crest froth: the tops of standing waves in a real rapid always carry white
7416
+ foam = max(foam, smoothstep(0.68, 0.96, ripple.x) * smoothstep(0.4, 1.0, rapids) * uFoam * 0.5);
7417
+ // froth can never be more present than the water carrying it
7418
+ foam = clamp(foam, 0.0, 1.0) * smoothstep(0.02, 0.14, column);
7419
+
7420
+ // ── body ────────────────────────────────────────────────────────────────
7421
+ float depthT = clamp(column / max(uMaxDepth, 0.01), 0.0, 1.0);
7422
+ // THREE bands, because a two-stop ramp lies about thin water. A film over the
7423
+ // bank does not lighten the ground, it DARKENS it — soaked stone, soaked
7424
+ // gravel — and only once there is a real column does the water start showing
7425
+ // its own colour. Skipping the wet band is what makes shallow game water look
7426
+ // like a sheet of cellophane laid over the terrain.
7427
+ vec3 wet = uDeepColor * 0.45;
7428
+ vec3 body = mix(wet, uShallowColor, smoothstep(0.0, 0.3, depthT));
7429
+ body = mix(body, uDeepColor, smoothstep(0.28, 0.78, depthT));
7430
+ // sunlight focused by the ripples dances on the bed of a shallow reach
7431
+ float dapple = smoothstep(0.55, 0.95, ripple.x) * (1.0 - smoothstep(0.15, 0.8, depthT));
7432
+ body += uSunColor * dapple * 0.14 * uSunIntensity;
7433
+
7434
+ // Beer's law decides how much of the bed the water still lets through — this
7435
+ // surface composites over the terrain, so absorption IS the alpha curve
7436
+ float hide = 1.0 - exp(-uAbsorption * column * 3.2);
7437
+
7438
+ // ── sky + sun ───────────────────────────────────────────────────────────
7439
+ // What a river actually mirrors depends on how far up the reflected ray
7440
+ // goes: almost every fragment of a stream is seen at a grazing angle, and
7441
+ // those rays leave nearly horizontal — into the far BANK (dark trees, wet
7442
+ // rock), not the zenith. Reflecting sky everywhere is what turns game water
7443
+ // into a sheet of white plastic.
7444
+ vec3 reflectDir = reflect(-viewDir, normal);
7445
+ // the far bank fills a surprising slice of the reflected hemisphere when the
7446
+ // eye is near the water — hold its dark tone well past the horizon line
7447
+ vec3 sky = mix(uBankColor, uHorizonColor, smoothstep(0.05, 0.34, reflectDir.y));
7448
+ sky = mix(sky, uSkyColor, smoothstep(0.34, 0.85, reflectDir.y));
7449
+ float fresnel = clamp(
7450
+ uFresnelScale * pow(1.0 - clamp(dot(normal, viewDir), 0.0, 1.0), 5.0),
7451
+ 0.0,
7452
+ 0.82
7453
+ );
7454
+
7455
+ vec3 sunDir = normalize(uSunDirection);
7456
+ vec3 halfVec = normalize(sunDir + viewDir);
7457
+ float ndh = max(dot(normal, halfVec), 0.0);
7458
+ // two lobes: a tight glitter spark plus a broad sheen. The sheen is what
7459
+ // makes a river READ as a moving surface from across the valley, where the
7460
+ // sparkle is smaller than a pixel.
7461
+ float spec = (pow(ndh, 96.0) + pow(ndh, 18.0) * 0.22) * uSunIntensity;
7462
+ // glitter: the same advected field gates the highlight so it breaks into
7463
+ // travelling sparks instead of one smooth sheen
7464
+ float glint = smoothstep(0.55, 0.95, ripple.x);
7465
+ spec *= 0.45 + 0.55 * glint * uSparkle;
7466
+ spec *= 1.0 - foam * 0.85; // froth is rough — it kills the mirror
7467
+
7468
+ fresnel *= 1.0 - 0.85 * fall; // froth scatters; it does not reflect
7469
+ vec3 color = mix(body, sky, fresnel);
7470
+ color += uSunColor * spec * 1.6;
7471
+ color = mix(color, uFoamColor, foam * 0.88);
7472
+
7473
+ // the LIP reads glassy-dark for a moment before the sheet breaks — the
7474
+ // contrast against the white curtain below is most of the drop's drama
7475
+ float lip = smoothstep(0.12, 0.32, grade) * (1.0 - smoothstep(0.32, 0.55, grade));
7476
+ color = mix(color, color * 0.72, lip * 0.6);
7477
+
7478
+ // ── edges ───────────────────────────────────────────────────────────────
7479
+ // the ribbon is built wider than the channel; what makes its outline is the
7480
+ // water running out, so bars and boulders cut it exactly like the ground does
7481
+ float edge = smoothstep(0.0, 0.06, column) * (1.0 - smoothstep(0.84, 1.0, abs(across)));
7482
+ float alpha = clamp(max(hide * uOpacity, fresnel * 0.85), 0.0, 1.0);
7483
+ alpha = clamp(max(alpha, foam * 0.95) * edge, 0.0, 1.0);
7484
+
7485
+ gl_FragColor = vec4(color, alpha);
7486
+
7487
+ #include <tonemapping_fragment>
7488
+ #include <colorspace_fragment>
7489
+ #include <fog_fragment>
7490
+ }
7491
+ `;
7492
+ //#endregion
7493
+ //#region src/3d/nodes/river-3d.ts
7494
+ /** Ribbon half-width as a multiple of the channel's — the water column fades
7495
+ * out inside the overshoot, so the bank is drawn by the ground, not by an edge. */
7496
+ const BANK_OVERSHOOT = 1.25;
7497
+ /** Centerline station spacing, derived from the narrowest reach. */
7498
+ const STEP_MIN = .6;
7499
+ const STEP_MAX = 2.5;
7500
+ const STEP_PER_WIDTH = .45;
7501
+ /** Quads across the channel — enough for the bank fade to read as a gradient. */
7502
+ const ACROSS_MIN = 4;
7503
+ const ACROSS_MAX = 12;
7504
+ const METERS_PER_ACROSS_SEGMENT = 1.5;
7505
+ /** Water-column averaging radius — small enough that a boulder still thins the
7506
+ * flow over it, wide enough that terrain grain does not fizz the whole river. */
7507
+ const COLUMN_BLUR_PER_HALF_WIDTH = .35;
7508
+ const COLUMN_BLUR_MAX = 1.2;
7509
+ /** How hard the current drags a body toward the water's own velocity (1/s at
7510
+ * `flowForce: 1`). Drag, not thrust: nothing ever outruns the river. Tuned
7511
+ * against CharacterController3D, whose stand-still damping fights it — at this
7512
+ * rate a person planted in a 2 m/s creek slides, which is what a 2 m/s creek
7513
+ * does to a person, while a free-floating raft still converges on the current. */
7514
+ const CURRENT_DRAG = 6;
7515
+ /** A body counts as in the current while its feet are under the surface and it
7516
+ * has not sunk more than this far below the bed. */
7517
+ const CURRENT_SURFACE_SLACK = .05;
7518
+ const CURRENT_UNDER_SLACK = 1.5;
7519
+ const DEFAULT_COLORS = {
7520
+ shallow: "#74b2a2",
7521
+ deep: "#123f4b",
7522
+ sky: "#8ab6d8",
7523
+ horizon: "#9fb6c2",
7524
+ bank: "#465447"
7525
+ };
7526
+ const FOAM_COLOR = "#e9f2ef";
7527
+ /** Fixed look constants — the props stay intent-level, these carry the craft. */
7528
+ const LOOK = {
7529
+ /** Pattern units per meter of flow space. */
7530
+ rippleScale: .85,
7531
+ /** Ripple normal SLOPE (wavelets are centimetres — a big value reads as
7532
+ * churning glass, not water). */
7533
+ rippleStrength: .09,
7534
+ /** Advection phase periods per second (the dual-phase crossfade rate). */
7535
+ flowRate: .42,
7536
+ fresnelScale: .95,
7537
+ /** Standing-wave height in a chute, meters. */
7538
+ standingAmp: .11,
7539
+ /** Long glide undulation on a calm reach, meters. */
7540
+ glideAmp: .025
7541
+ };
7542
+ /**
7543
+ * A river: running water that finds its own shape.
7544
+ *
7545
+ * A scene declares a centerline `path`, a `width` (constant or a profile) and a
7546
+ * `depth`; the node samples the Terrain3D underneath and derives everything
7547
+ * else — a surface that descends with the ground and never climbs, a current
7548
+ * that speeds up where the channel pinches or tips, whitewater wherever those
7549
+ * two make it, and banks cut by the ground itself (each vertex carries its own
7550
+ * water column, and the shader stops drawing where that runs out).
7551
+ *
7552
+ * Unlike {@link Water3D} it costs no extra render passes — a river is a ribbon
7553
+ * with one draw call, so a map can carry a dozen of them.
7554
+ *
7555
+ * ```json
7556
+ * { "type": "River3D", "props": {
7557
+ * "path": [[-80, -30], [-20, 5], [30, 10], [90, -10]],
7558
+ * "widths": [3, 6, 11], "depth": 0.9, "flowSpeed": 1.8 } }
7559
+ * ```
7560
+ */
7561
+ var River3D = class River3D extends Node3D {
7562
+ static typeName = "River3D";
7563
+ static props = {
7564
+ path: { default: [] },
7565
+ width: { default: 6 },
7566
+ widths: { default: [] },
7567
+ depth: { default: .8 },
7568
+ flowSpeed: { default: 1.6 },
7569
+ colors: { default: {} },
7570
+ absorption: { default: .5 },
7571
+ opacity: { default: .85 },
7572
+ foam: { default: 1 },
7573
+ ripples: { default: 1 },
7574
+ sunDirection: { default: [
7575
+ .5,
7576
+ .8,
7577
+ .3
7578
+ ] },
7579
+ sunColor: { default: "#fff6e0" },
7580
+ sunIntensity: { default: 1 },
7581
+ terrain: { default: "" },
7582
+ flowForce: { default: 1 },
7583
+ spray: { default: 1 }
7584
+ };
7585
+ /** Centerline control points `[[x, z], …]` in the node's local frame. */
7586
+ path = [];
7587
+ /** Channel width in meters (constant unless `widths` overrides it). */
7588
+ width = 6;
7589
+ /** Width profile lerped source→mouth, e.g. `[2, 5, 9]` for a stream growing
7590
+ * into a river. Empty = the constant `width`. */
7591
+ widths = [];
7592
+ /** Water column at the centerline, in meters. */
7593
+ depth = .8;
7594
+ /** Reference current in m/s (the mean; reaches speed up and slow down). */
7595
+ flowSpeed = 1.6;
7596
+ /** `{ shallow, deep, sky, horizon, bank }` — the body ramp plus what the
7597
+ * surface mirrors (a grazing river mostly mirrors its BANK, not the sky). */
7598
+ colors = {};
7599
+ /** Beer's-law density: how fast the water hides its bed. */
7600
+ absorption = .5;
7601
+ /** Upper bound on the body's opacity (1 = the deep reach goes solid). */
7602
+ opacity = .85;
7603
+ /** Whitewater dial: 0 = a glassy canal, 1 = default, 2 = raging. */
7604
+ foam = 1;
7605
+ /** Surface-detail dial: ripple relief and glitter. */
7606
+ ripples = 1;
7607
+ /** Glint direction (the sky's sun overrides while this is at its default). */
7608
+ sunDirection = [
7609
+ .5,
7610
+ .8,
7611
+ .3
7612
+ ];
7613
+ sunColor = "#fff6e0";
7614
+ sunIntensity = 1;
7615
+ /** Drape target path; empty = auto-find the first Terrain3D in the tree. */
7616
+ terrain = "";
7617
+ /** How hard the current sweeps bodies downstream (0 = visual only). */
7618
+ flowForce = 1;
7619
+ /**
7620
+ * Mist at the foot of every waterfall the course contains: the node finds
7621
+ * the drops itself (see {@link detectFalls}) and hangs a Particles3D plume
7622
+ * on each, sized by how far the water falls. 0 = off.
7623
+ */
7624
+ spray = 1;
7625
+ renderOrder = 1;
7626
+ time = 0;
7627
+ rings = [];
7628
+ falls = [];
7629
+ sprayKey = "";
7630
+ courseKey = "";
7631
+ bedAt = null;
7632
+ world = [
7633
+ 0,
7634
+ 0,
7635
+ 0
7636
+ ];
7637
+ _bodies = [];
7638
+ /** Loader hook: a malformed river fails at LOAD, not as an invisible ribbon. */
7639
+ static validateJson(node) {
7640
+ const r = node;
7641
+ if (!Array.isArray(r.path)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path must be an array of [x, z] points, got ${JSON.stringify(r.path)}.`, { prop: "path" });
7642
+ if (r.path.length > 0 && r.path.length < 2) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path needs at least 2 points (source → mouth), got ${r.path.length}.`, { prop: "path" });
7643
+ r.path.forEach((p, i) => {
7644
+ if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' path[${i}] must be [x, z] with finite numbers, got ${JSON.stringify(p)}.`, { prop: "path" });
7645
+ });
7646
+ if (!(typeof r.width === "number" && r.width > 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' width must be a positive number of meters, got ${JSON.stringify(r.width)}.`, { prop: "width" });
7647
+ if (!Array.isArray(r.widths) || r.widths.some((w) => !(typeof w === "number" && w > 0))) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' widths must be a list of positive meters (source→mouth), got ${JSON.stringify(r.widths)}.`, { prop: "widths" });
7648
+ if (!(r.depth > 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' depth must be > 0 meters, got ${JSON.stringify(r.depth)}.`, { prop: "depth" });
7649
+ if (!(r.flowSpeed >= 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' flowSpeed must be >= 0 m/s, got ${JSON.stringify(r.flowSpeed)}.`, { prop: "flowSpeed" });
7650
+ for (const key of [
7651
+ "absorption",
7652
+ "foam",
7653
+ "ripples",
7654
+ "flowForce",
7655
+ "spray",
7656
+ "sunIntensity"
7657
+ ]) {
7658
+ const v = r[key];
7659
+ if (!(typeof v === "number" && v >= 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' ${key} must be a number >= 0, got ${JSON.stringify(v)}.`, { prop: key });
7660
+ }
7661
+ if (!(r.opacity >= 0 && r.opacity <= 1)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' opacity must be within [0, 1], got ${JSON.stringify(r.opacity)}.`, { prop: "opacity" });
7662
+ const colors = r.colors;
7663
+ if (typeof colors !== "object" || colors === null || Array.isArray(colors)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' colors must be an object of { shallow, deep, sky, horizon, bank } hex strings.`, { prop: "colors" });
7664
+ for (const key of Object.keys(colors)) {
7665
+ if (!(key in DEFAULT_COLORS)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' colors has unknown key '${key}' — expected shallow, deep, sky, horizon or bank.`, { prop: "colors" });
7666
+ if (typeof colors[key] !== "string") throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' colors.${key} must be a hex string, got ${JSON.stringify(colors[key])}.`, { prop: "colors" });
7667
+ }
7668
+ }
7669
+ _createObject3D() {
7670
+ return new Mesh(new BufferGeometry(), this.buildMaterial());
7671
+ }
7672
+ update(dt) {
7673
+ this.time += dt;
7674
+ this.ensureCourse();
7675
+ this.syncSpray();
7676
+ if (this.flowForce > 0 && this.rings.length > 1) this.sweepBodies(dt);
7677
+ }
7678
+ _syncObject3D() {
7679
+ super._syncObject3D();
7680
+ const mesh = this._ensureObject3D();
7681
+ this.rebuildIfNeeded(mesh);
7682
+ this.syncUniforms(mesh);
7683
+ }
7684
+ /**
7685
+ * Where a WORLD point sits in the channel — the query gameplay scripts run
7686
+ * (steer a raft, drown a torch, decide a ford is too fast to cross). `null`
7687
+ * until the river has been built.
7688
+ */
7689
+ sampleAt(x, z) {
7690
+ this.ensureCourse();
7691
+ if (this.rings.length === 0) return null;
7692
+ const [wx, wy, wz] = this.world;
7693
+ const hit = projectToRiver(this.rings, x - wx, z - wz);
7694
+ return hit ? {
7695
+ ...hit,
7696
+ surfaceY: hit.surfaceY + wy
7697
+ } : null;
7698
+ }
7699
+ /**
7700
+ * The waterfalls on this course — where the water stops flowing and starts
7701
+ * falling, with the drop height and the plunge point in WORLD coordinates.
7702
+ * Hang your own effects (sound, a rainbow, a hidden cave) on these.
7703
+ */
7704
+ fallsAt() {
7705
+ this.ensureCourse();
7706
+ const [wx, wy, wz] = this.world;
7707
+ return this.falls.map((f) => ({
7708
+ ...f,
7709
+ worldX: f.x + wx,
7710
+ worldY: f.baseY + wy,
7711
+ worldZ: f.z + wz
7712
+ }));
7713
+ }
7714
+ /** @internal The derived centerline stations (surface, grade, current). */
7715
+ _rings() {
7716
+ return this.rings;
7717
+ }
7718
+ /**
7719
+ * @internal Environment-sky sun hand-off (see {@link SunConsumer3D}): only
7720
+ * while `sunDirection` sits at its schema default — an authored glint wins.
7721
+ */
7722
+ _applySunDirection(dir) {
7723
+ const def = River3D.props.sunDirection?.default;
7724
+ if (!this.sunDirection.every((v, i) => v === def[i])) return;
7725
+ (this._ensureObject3D().material.uniforms?.uSunDirection?.value)?.set(dir[0], dir[1], dir[2]);
7726
+ }
7727
+ free() {
7728
+ const mesh = this._ensureObject3D();
7729
+ mesh.geometry?.dispose();
7730
+ mesh.material?.dispose?.();
7731
+ super.free();
7732
+ }
7733
+ widthProfile() {
7734
+ return this.widths.length > 0 ? this.widths : [this.width];
7735
+ }
7736
+ /** Resolve the drape terrain; an explicit path that misses is a hard error. */
7737
+ resolveTerrain() {
7738
+ const terrain = findDrapeTerrain(this, this.terrain);
7739
+ if (!terrain && this.terrain !== "") throw new IncantoError("NODE_NOT_FOUND", `River3D '${this.name}' terrain '${this.terrain}' is not a Terrain3D in the tree.`, { prop: "terrain" });
7740
+ return terrain;
7741
+ }
7742
+ /**
7743
+ * Derive the course (stations + water column) when a shaping prop changed.
7744
+ * Pure CPU work — no three objects — so gameplay can call it any time.
7745
+ */
7746
+ ensureCourse() {
7747
+ const terrain = this.resolveTerrain();
7748
+ const world = worldXZY(this);
7749
+ const key = JSON.stringify([
7750
+ this.path,
7751
+ this.width,
7752
+ this.widths,
7753
+ this.depth,
7754
+ this.flowSpeed,
7755
+ terrain ? terrain.name : "",
7756
+ world
7757
+ ]);
7758
+ if (key === this.courseKey) return false;
7759
+ this.courseKey = key;
7760
+ this.world = world;
7761
+ this.bedAt = null;
7762
+ if (this.path.length < 2) {
7763
+ this.rings = [];
7764
+ this.falls = [];
7765
+ return true;
7766
+ }
7767
+ const [wx, wy, wz] = world;
7768
+ this.bedAt = terrain ? (lx, lz) => terrain.heightAt(lx + wx, lz + wz) - wy : () => -this.depth;
7769
+ const widths = this.widthProfile();
7770
+ const narrow = Math.min(...widths);
7771
+ this.rings = buildRiverRings({
7772
+ path: this.path,
7773
+ widths,
7774
+ depth: this.depth,
7775
+ flowSpeed: this.flowSpeed,
7776
+ step: Math.min(Math.max(narrow * STEP_PER_WIDTH, STEP_MIN), STEP_MAX),
7777
+ bedAt: this.bedAt
7778
+ });
7779
+ this.falls = detectFalls(this.rings);
7780
+ return true;
7781
+ }
7782
+ rebuildIfNeeded(mesh) {
7783
+ if (!this.ensureCourse() && mesh.geometry.getAttribute("position")) return;
7784
+ if (this.rings.length === 0 || !this.bedAt) {
7785
+ mesh.geometry.dispose();
7786
+ const empty = new BufferGeometry();
7787
+ empty.setAttribute("position", new BufferAttribute(new Float32Array(0), 3));
7788
+ mesh.geometry = empty;
7789
+ return;
7790
+ }
7791
+ const widths = this.widthProfile();
7792
+ const narrow = Math.min(...widths);
7793
+ const wide = Math.max(...widths);
7794
+ const data = buildRiverGeometry(this.rings, {
7795
+ acrossSegments: Math.min(ACROSS_MAX, Math.max(ACROSS_MIN, Math.round(wide / METERS_PER_ACROSS_SEGMENT))),
7796
+ bankOvershoot: BANK_OVERSHOOT,
7797
+ bedBlur: Math.min(narrow / 2 * COLUMN_BLUR_PER_HALF_WIDTH, COLUMN_BLUR_MAX),
7798
+ bedAt: this.bedAt
7799
+ });
7800
+ const geometry = new BufferGeometry();
7801
+ geometry.setAttribute("position", new BufferAttribute(data.positions, 3));
7802
+ geometry.setAttribute("normal", new BufferAttribute(data.normals, 3));
7803
+ geometry.setAttribute("aFlowUv", new BufferAttribute(data.uvs, 2));
7804
+ geometry.setAttribute("aFlow", new BufferAttribute(data.flow, 2));
7805
+ geometry.setAttribute("aRiver", new BufferAttribute(data.river, 4));
7806
+ geometry.setIndex(new BufferAttribute(data.indices, 1));
7807
+ geometry.computeBoundingSphere();
7808
+ mesh.geometry.dispose();
7809
+ mesh.geometry = geometry;
7810
+ }
7811
+ buildMaterial() {
7812
+ return new ShaderMaterial({
7813
+ vertexShader: RIVER_VERT,
7814
+ fragmentShader: RIVER_FRAG,
7815
+ fog: true,
7816
+ uniforms: {
7817
+ fogColor: { value: new Color("#ffffff") },
7818
+ fogNear: { value: 1 },
7819
+ fogFar: { value: 1e3 },
7820
+ fogDensity: { value: 25e-5 },
7821
+ uTime: { value: 0 },
7822
+ uShallowColor: { value: new Color(DEFAULT_COLORS.shallow) },
7823
+ uDeepColor: { value: new Color(DEFAULT_COLORS.deep) },
7824
+ uFoamColor: { value: new Color(FOAM_COLOR) },
7825
+ uSkyColor: { value: new Color(DEFAULT_COLORS.sky) },
7826
+ uHorizonColor: { value: new Color(DEFAULT_COLORS.horizon) },
7827
+ uBankColor: { value: new Color(DEFAULT_COLORS.bank) },
7828
+ uSunDirection: { value: new Vector3(.5, .8, .3) },
7829
+ uSunColor: { value: new Color(this.sunColor) },
7830
+ uSunIntensity: { value: this.sunIntensity },
7831
+ uMaxDepth: { value: this.depth },
7832
+ uAbsorption: { value: this.absorption },
7833
+ uOpacity: { value: this.opacity },
7834
+ uFoam: { value: this.foam },
7835
+ uRippleScale: { value: LOOK.rippleScale },
7836
+ uRippleStrength: { value: LOOK.rippleStrength * this.ripples },
7837
+ uFlowRate: { value: LOOK.flowRate },
7838
+ uSparkle: { value: this.ripples },
7839
+ uFresnelScale: { value: LOOK.fresnelScale },
7840
+ uStandingAmp: { value: LOOK.standingAmp },
7841
+ uGlideAmp: { value: LOOK.glideAmp }
7842
+ },
7843
+ transparent: true,
7844
+ depthTest: true,
7845
+ depthWrite: false,
7846
+ polygonOffset: true,
7847
+ polygonOffsetFactor: -2,
7848
+ polygonOffsetUnits: -4,
7849
+ side: DoubleSide
7850
+ });
7851
+ }
7852
+ syncUniforms(mesh) {
7853
+ const u = mesh.material.uniforms;
7854
+ const set = (key, value) => {
7855
+ const slot = u[key];
7856
+ if (slot) slot.value = value;
7857
+ };
7858
+ const tint = (key, hex) => {
7859
+ (u[key]?.value)?.set(hex);
7860
+ };
7861
+ const colors = this.colors;
7862
+ set("uTime", this.time);
7863
+ tint("uShallowColor", colors.shallow ?? DEFAULT_COLORS.shallow);
7864
+ tint("uDeepColor", colors.deep ?? DEFAULT_COLORS.deep);
7865
+ tint("uSkyColor", colors.sky ?? DEFAULT_COLORS.sky);
7866
+ tint("uHorizonColor", colors.horizon ?? DEFAULT_COLORS.horizon);
7867
+ tint("uBankColor", colors.bank ?? DEFAULT_COLORS.bank);
7868
+ tint("uSunColor", this.sunColor);
7869
+ (u.uSunDirection?.value)?.set(this.sunDirection[0] ?? .5, this.sunDirection[1] ?? .8, this.sunDirection[2] ?? .3);
7870
+ set("uSunIntensity", this.sunIntensity);
7871
+ set("uMaxDepth", this.depth);
7872
+ set("uAbsorption", this.absorption);
7873
+ set("uOpacity", this.opacity);
7874
+ set("uFoam", this.foam);
7875
+ set("uRippleStrength", LOOK.rippleStrength * this.ripples);
7876
+ set("uSparkle", this.ripples);
7877
+ }
7878
+ /**
7879
+ * Hang a mist plume on every fall. Runtime children (`__spray*`), rebuilt
7880
+ * only when the course or the dial changes — a plume is a Particles3D, so it
7881
+ * carries its own budget, culling and editor visibility for free.
7882
+ */
7883
+ syncSpray() {
7884
+ const key = this.spray > 0 ? `${this.spray}|${JSON.stringify(this.falls)}` : "";
7885
+ if (key === this.sprayKey) return;
7886
+ this.sprayKey = key;
7887
+ for (const child of [...this.children]) if (child.name.startsWith("__spray")) child.free();
7888
+ if (this.spray <= 0) return;
7889
+ this.falls.forEach((fall, i) => {
7890
+ const plume = new Particles3D(`__spray${i}`);
7891
+ plume.position = [
7892
+ fall.x + fall.dirX * .6,
7893
+ fall.baseY + .15,
7894
+ fall.z + fall.dirZ * .6
7895
+ ];
7896
+ const power = Math.min(1.6, .4 + fall.drop * .12) * this.spray;
7897
+ plume.rate = Math.round(Math.min(46, 10 + fall.drop * 3) * this.spray);
7898
+ plume.maxParticles = 160;
7899
+ plume.lifetime = [1, 2.1];
7900
+ plume.speed = [55 * power, 170 * power];
7901
+ plume.directionDeg = -90;
7902
+ plume.spreadDeg = 58;
7903
+ plume.gravity = [
7904
+ 0,
7905
+ -240,
7906
+ 0
7907
+ ];
7908
+ plume.drag = 1.2;
7909
+ plume.sizeStart = 16 * power;
7910
+ plume.sizeEnd = 80 * power;
7911
+ plume.colorStart = "#ffffff";
7912
+ plume.colorEnd = "#dfe9ec";
7913
+ plume.alphaStart = .2;
7914
+ plume.alphaEnd = 0;
7915
+ plume.blend = "normal";
7916
+ plume.depthTest = true;
7917
+ this.addChild(plume);
7918
+ });
7919
+ }
7920
+ /**
7921
+ * Drag every body floating in the channel toward the water's own velocity.
7922
+ * Drag rather than thrust: a raft accelerates until it matches the current
7923
+ * and then coasts, and a swimmer can still fight across a slow ford.
7924
+ */
7925
+ sweepBodies(dt) {
7926
+ let top = this;
7927
+ while (top.parent) top = top.parent;
7928
+ const bodies = this._bodies;
7929
+ bodies.length = 0;
7930
+ collectBodies$1(top, bodies);
7931
+ if (bodies.length === 0) return;
7932
+ const [wx, wy, wz] = this.world;
7933
+ for (const body of bodies) {
7934
+ const p = worldXZY(body);
7935
+ const hit = projectToRiver(this.rings, p[0] - wx, p[2] - wz);
7936
+ if (!hit?.inside) continue;
7937
+ const surfaceY = hit.surfaceY + wy;
7938
+ const feet = p[1] - colliderFootDrop(body.collider);
7939
+ if (feet > surfaceY + CURRENT_SURFACE_SLACK) continue;
7940
+ if (feet < surfaceY - this.depth - CURRENT_UNDER_SLACK) continue;
7941
+ const vel = bodyVelocity$1(body);
7942
+ if (!vel) continue;
7943
+ const gain = Math.min(1, this.flowForce * CURRENT_DRAG * dt);
7944
+ const dvx = (hit.dirX * hit.speed - (vel[0] ?? 0)) * gain;
7945
+ const dvz = (hit.dirZ * hit.speed - (vel[2] ?? 0)) * gain;
7946
+ if (body instanceof RigidBody3D) {
7947
+ const mass = body.mass || 1;
7948
+ body.applyImpulse([
7949
+ dvx * mass,
7950
+ 0,
7951
+ dvz * mass
7952
+ ]);
7953
+ } else {
7954
+ vel[0] = (vel[0] ?? 0) + dvx;
7955
+ vel[2] = (vel[2] ?? 0) + dvz;
7956
+ }
7957
+ }
7958
+ }
7959
+ };
7960
+ /** Bodies a current can carry: kinematic characters and dynamic rigid bodies. */
7961
+ function collectBodies$1(node, out) {
7962
+ if (node instanceof CharacterBody3D || node instanceof RigidBody3D) out.push(node);
7963
+ for (const child of node.children) collectBodies$1(child, out);
7964
+ }
7965
+ /** A body's velocity vector — kinematic `velocity` or dynamic `linearVelocity`. */
7966
+ function bodyVelocity$1(body) {
7967
+ return body.velocity ?? body.linearVelocity;
7968
+ }
7969
+ //#endregion
6675
7970
  //#region src/3d/nodes/trail-3d.ts
6676
7971
  const MAX_POINTS = 128;
6677
7972
  /**
@@ -9948,121 +11243,6 @@ function sphereInCameraFrustum(camera, center, radius) {
9948
11243
  return scratchFrustum.intersectsSphere(scratchSphere);
9949
11244
  }
9950
11245
  //#endregion
9951
- //#region src/3d/water/interaction.ts
9952
- /**
9953
- * Water3D character interaction — pure functions, no three, node-env testable.
9954
- *
9955
- * The node samples physics bodies each update, detects waterline crossings
9956
- * (`detectCrossings`), and converts them into ripple impulses in a FIXED-SIZE
9957
- * ring buffer that mirrors the shader's `uRipples[8]` uniform array
9958
- * (`pushRipple` / `ageRipples`). `rippleHeight` is the exact ring-wave shape
9959
- * the vertex shader evaluates — `quality: 'simple'` runs it on the CPU.
9960
- */
9961
- /** Ring-buffer capacity — MUST match `uRipples[8]` in the water shaders. */
9962
- const WATER_MAX_RIPPLES = 8;
9963
- /**
9964
- * Distance from a body's ORIGIN down to its collider's lowest point. Water
9965
- * interaction adds this to the waterline so a body counts as touching the water
9966
- * when its FEET dip below the surface — not only when its centre does (a wading
9967
- * character whose origin rides ~1 m above its feet would otherwise never splash).
9968
- * 0 for a missing/empty/unsized collider (origin == feet).
9969
- */
9970
- function colliderFootDrop(collider) {
9971
- if (!collider || typeof collider !== "object") return 0;
9972
- switch (collider.shape) {
9973
- case "capsule": return (collider.height ?? 0) / 2 + (collider.radius ?? 0);
9974
- case "sphere": return collider.radius ?? 0;
9975
- case "box": return (collider.size?.[1] ?? 0) / 2;
9976
- default: return 0;
9977
- }
9978
- }
9979
- /**
9980
- * Foam strength (0..1) for a body ENTERING the water, from its DOWNWARD speed:
9981
- * a gentle wade-in froths a little; a hard plunge froths fully. Clamped at
9982
- * `FOAM_FULL_SPEED`.
9983
- */
9984
- function entryFoam(fallSpeed) {
9985
- return .55 + .45 * (Math.min(Math.max(fallSpeed, 0), 12) / 12);
9986
- }
9987
- /**
9988
- * Diff body positions against the waterline. A body enters when `y` drops
9989
- * below its waterline and exits when it rises above `waterline + hysteresis`
9990
- * (the band stops surface-bobbing bodies from flickering entered/exited).
9991
- * Bodies missing from `samples` (despawned mid-water) are dropped WITHOUT an
9992
- * `exited` event — their splash position no longer exists.
9993
- */
9994
- function detectCrossings(prev, samples, hysteresis = 0) {
9995
- const entered = [];
9996
- const exited = [];
9997
- const inWater = /* @__PURE__ */ new Set();
9998
- for (const s of samples) {
9999
- const wasIn = prev.has(s.id);
10000
- if (wasIn ? s.y <= s.waterline + hysteresis : s.y < s.waterline) {
10001
- inWater.add(s.id);
10002
- if (!wasIn) entered.push(s);
10003
- } else if (wasIn) exited.push(s);
10004
- }
10005
- return {
10006
- entered,
10007
- exited,
10008
- inWater
10009
- };
10010
- }
10011
- /** Append a ripple at age 0; when the buffer is full, evict the OLDEST.
10012
- * `foam` (0..1) tags how much surface froth the shaders paint around it. */
10013
- function pushRipple(ripples, x, z, amp, foam = 0, max = 8) {
10014
- if (ripples.length >= max) {
10015
- let oldest = 0;
10016
- for (let i = 1; i < ripples.length; i++) if (ripples[i].age > ripples[oldest].age) oldest = i;
10017
- ripples.splice(oldest, 1);
10018
- }
10019
- ripples.push({
10020
- x,
10021
- z,
10022
- age: 0,
10023
- amp,
10024
- foam
10025
- });
10026
- }
10027
- /** Advance ages by `dt` and drop ripples decayed past `maxAge` (in place). */
10028
- function ageRipples(ripples, dt, maxAge = 3) {
10029
- for (let i = ripples.length - 1; i >= 0; i--) {
10030
- const r = ripples[i];
10031
- r.age += dt;
10032
- if (r.age >= maxAge) ripples.splice(i, 1);
10033
- }
10034
- }
10035
- /** The ring-wave height contribution of one ripple at world (x, z). */
10036
- function rippleHeight(x, z, ripple) {
10037
- const dx = x - ripple.x;
10038
- const dz = z - ripple.z;
10039
- const r = Math.sqrt(dx * dx + dz * dz);
10040
- return ripple.amp * Math.sin(6 * r - 8 * ripple.age) * Math.exp(-1.1 * ripple.age) * Math.exp(-.15 * r * r);
10041
- }
10042
- /** Sum of every live ripple's height at world (x, z) — the CPU water path. */
10043
- function ripplesHeightAt(x, z, ripples) {
10044
- let h = 0;
10045
- for (const r of ripples) h += rippleHeight(x, z, r);
10046
- return h;
10047
- }
10048
- /**
10049
- * Accumulate per-body bob timers for bodies floating IN water; returns the
10050
- * ids due for a gentle bob impulse this update (timer wraps, cadence keeps).
10051
- * Timers of bodies that left the water are forgotten.
10052
- */
10053
- function stepBobTimers(timers, inWater, dt, interval) {
10054
- const due = [];
10055
- for (const id of timers.keys()) if (!inWater.has(id)) timers.delete(id);
10056
- for (const id of inWater) {
10057
- const t = (timers.get(id) ?? 0) + dt;
10058
- if (t >= interval) {
10059
- due.push(id);
10060
- timers.set(id, t - interval);
10061
- } else timers.set(id, t);
10062
- }
10063
- return due;
10064
- }
10065
- //#endregion
10066
11246
  //#region src/3d/water/lake.ts
10067
11247
  /**
10068
11248
  * The Water3D 'simple' quality lake shader — the CHEAP good-looking path.
@@ -10517,6 +11697,9 @@ uniform vec4 uRipples[8];
10517
11697
  uniform float uSwellAmplitude;
10518
11698
  uniform vec2 uSwellDir;
10519
11699
  uniform float uSwellWavelength;
11700
+ // incanto GERSTNER: 0 = the legacy height-only swell, 1 = maximum trochoid
11701
+ // pinch (crests narrow to a peak). Physical steepness, capped per train.
11702
+ uniform float uSwellSteepness;
10520
11703
  // incanto shore: the SWASH SURGE shares the shore-waves dial — a very long
10521
11704
  // slow bulge marching shoreward; ±9 cm of water height walks the waterline
10522
11705
  // meters up and down a gentle beach (the lapping runup).
@@ -10529,6 +11712,8 @@ uniform float uEdgeFadeOn;
10529
11712
  varying vec3 vNormal;
10530
11713
  varying vec3 vWorldPosition;
10531
11714
  varying vec4 vScreenPosition;
11715
+ // incanto GERSTNER: how far the surface has folded here (whitecap seed)
11716
+ varying float vFold;
10532
11717
 
10533
11718
  // Simplex 3D Noise
10534
11719
  // by Ian McEwan, Stefan Gustavson (https://github.com/stegu/webgl-noise)
@@ -10631,6 +11816,43 @@ float getSwell(vec2 p) {
10631
11816
  + swellWave(p, d3, uSwellWavelength * 0.31, uSwellAmplitude * 0.16, 1.8);
10632
11817
  }
10633
11818
 
11819
+ // incanto GERSTNER: the horizontal half of a swell. A height field can only
11820
+ // raise and lower water; a real wave train also DRAGS it toward the crest,
11821
+ // which is what makes crests narrow and troughs broad — the single strongest
11822
+ // tell of open ocean. The same derivatives give the JACOBIAN of the surface
11823
+ // map, and where that collapses the water is folding over itself: whitecaps
11824
+ // belong exactly there, not wherever the height happens to be high.
11825
+ //
11826
+ // Returns (pinch.x, pinch.z, fold) — fold is 1 where the surface folds.
11827
+ vec3 getSwellPinch(vec2 p) {
11828
+ if (uSwellAmplitude <= 0.0 || uSwellSteepness <= 0.0) return vec3(0.0);
11829
+ vec2 d1 = uSwellDir;
11830
+ vec2 d2 = normalize(vec2(d1.x * 0.906 - d1.y * 0.423, d1.x * 0.423 + d1.y * 0.906));
11831
+ vec2 d3 = normalize(vec2(d1.x * 0.946 + d1.y * 0.326, -d1.x * 0.326 + d1.y * 0.946));
11832
+ vec2 pinch = vec2(0.0);
11833
+ float jxx = 1.0;
11834
+ float jzz = 1.0;
11835
+ float jxz = 0.0;
11836
+ // (direction, wavelength, amplitude) — the same three trains as getSwell
11837
+ for (int i = 0; i < 3; i++) {
11838
+ vec2 d = i == 0 ? d1 : (i == 1 ? d2 : d3);
11839
+ float lambda = uSwellWavelength * (i == 0 ? 1.0 : (i == 1 ? 0.53 : 0.31));
11840
+ float amp = uSwellAmplitude * (i == 0 ? 0.60 : (i == 1 ? 0.27 : 0.16));
11841
+ float k = 6.2831853 / max(lambda, 1.0);
11842
+ float c = sqrt(9.8 / k);
11843
+ float phase = dot(p, d) * k - uTime * c * k;
11844
+ // steepness per train, capped so the trochoid never loops back on itself
11845
+ float q = min(uSwellSteepness / max(k * amp * 3.0, 1e-4), 1.0);
11846
+ pinch -= d * (q * amp * cos(phase));
11847
+ float f = q * amp * k * sin(phase);
11848
+ jxx -= d.x * d.x * f;
11849
+ jzz -= d.y * d.y * f;
11850
+ jxz -= d.x * d.y * f;
11851
+ }
11852
+ float jacobian = jxx * jzz - jxz * jxz;
11853
+ return vec3(pinch, 1.0 - smoothstep(0.15, 0.75, jacobian));
11854
+ }
11855
+
10634
11856
  // incanto: waves + swell + ripples — used for displacement AND the normal
10635
11857
  // samples so every layer catches the light like real waves.
10636
11858
  // incanto shore: two incommensurate long surges — the waterline breathes in
@@ -10652,6 +11874,13 @@ void main() {
10652
11874
 
10653
11875
  float elevation = getHeight(modelPosition.x, modelPosition.z);
10654
11876
  modelPosition.y += elevation;
11877
+ // Gerstner pinch: the height field is sampled at the UNDISPLACED position
11878
+ // (so the analytic finite-difference normals below stay consistent), then
11879
+ // the surface is dragged horizontally toward its crests. At the steepness
11880
+ // this ships the normal error is far under the detail-normal layers.
11881
+ vec3 pinch = getSwellPinch(modelPosition.xz);
11882
+ modelPosition.xz += pinch.xy;
11883
+ vFold = pinch.z;
10655
11884
 
10656
11885
  // Height adjustment to smooth water edges
10657
11886
  // Lower water height as it approaches scale boundaries
@@ -10784,8 +12013,20 @@ uniform float uEdgeWidth;
10784
12013
  varying vec3 vNormal;
10785
12014
  varying vec3 vWorldPosition;
10786
12015
  varying vec4 vScreenPosition;
12016
+ // incanto GERSTNER: how far the surface has folded here (whitecap seed)
12017
+ varying float vFold;
10787
12018
 
10788
12019
  uniform samplerCube uEnvironmentMap;
12020
+ // incanto MIRROR: a planar reflection rendered from the camera mirrored across
12021
+ // the water plane. A water-centered cube map can only carry DISTANT content —
12022
+ // the shoreline trees, the boat, the player standing at the edge are exactly
12023
+ // what a still lake is supposed to show, and only a planar pass has them.
12024
+ // uMirrorMatrix projects a world position into that render's UV, so the sample
12025
+ // is self-consistent no matter where the fragment sits on the surface.
12026
+ uniform sampler2D uMirrorMap;
12027
+ uniform mat4 uMirrorMatrix;
12028
+ uniform float uUseMirror;
12029
+ uniform float uMirrorDistort;
10789
12030
 
10790
12031
  // Noise function (for foam patterns)
10791
12032
  float hash(vec2 p) {
@@ -11160,6 +12401,27 @@ void main() {
11160
12401
  vec4 reflectionColor = textureCube(uEnvironmentMap, reflectedDirection);
11161
12402
  reflectionColor.rgb = min(reflectionColor.rgb, vec3(2.5));
11162
12403
 
12404
+ // incanto MIRROR: where the planar pass has data it REPLACES the cube — it
12405
+ // is the same sky plus everything the cube map is blind to. Rays that leave
12406
+ // the screen have no mirror sample, so the border fade hands those back to
12407
+ // the cube instead of clamping a stretched edge texel across the surface.
12408
+ if (uUseMirror > 0.5) {
12409
+ vec4 mirrorClip = uMirrorMatrix * vec4(vWorldPosition, 1.0);
12410
+ vec2 mirrorUv = mirrorClip.xy / max(mirrorClip.w, 1e-4);
12411
+ // the ripple tilt bends the reflected ray: offset the sample by how far
12412
+ // the wavelet normal leans off flat, damped with distance so the far field
12413
+ // (many wavelets per pixel) stays a clean mirror instead of a jitter field
12414
+ vec2 lean = (fresnelNormal.xz - geoNormal.xz) * uMirrorDistort;
12415
+ mirrorUv += lean * (1.0 - smoothstep(20.0, 90.0, camDist));
12416
+ vec2 edge = smoothstep(vec2(0.0), vec2(0.06), mirrorUv) *
12417
+ (1.0 - smoothstep(vec2(0.94), vec2(1.0), mirrorUv));
12418
+ float valid = edge.x * edge.y * step(0.0, mirrorClip.w);
12419
+ if (valid > 0.0) {
12420
+ vec3 mirrored = min(texture2D(uMirrorMap, mirrorUv).rgb, vec3(2.5));
12421
+ reflectionColor.rgb = mix(reflectionColor.rgb, mirrored, valid);
12422
+ }
12423
+ }
12424
+
11163
12425
  // Calculate fresnel effect
11164
12426
  // incanto: the source dotted the camera→surface direction against the
11165
12427
  // up-facing normal — NEGATIVE at every above-water angle, so the clamp
@@ -11410,6 +12672,10 @@ void main() {
11410
12672
  float capNoise = noise(capUV * 2.2 + uTime * vec2(0.10, 0.06)) * 0.65
11411
12673
  + noise(capUV * 6.5 - uTime * vec2(0.05, 0.14)) * 0.35;
11412
12674
  float capFade = 1.0 - smoothstep(220.0, 640.0, camDist);
12675
+ // height says WHERE the crest is; the Jacobian says where it is actually
12676
+ // breaking. Taking the max keeps the tuned height-based caps and adds the
12677
+ // ones physics insists on — the pinched crest faces about to spill.
12678
+ cap = max(cap, vFold);
11413
12679
  float whitecap = uWhitecaps * cap * smoothstep(0.42, 0.78, capNoise) * capFade;
11414
12680
  foamAmount = max(foamAmount, whitecap);
11415
12681
  }
@@ -11836,6 +13102,10 @@ const WAVE_WORLD_REF = 128;
11836
13102
  const AMPLITUDE_PER_WAVE_HEIGHT = REFERENCE_AMPLITUDE / REFERENCE_WAVE_HEIGHT;
11837
13103
  /** Cube-map reflection resolution (source: reflectionQuality = 256). */
11838
13104
  const REFLECTION_RESOLUTION = 256;
13105
+ /** Planar-mirror target size cap (per side) — half the drawing buffer, clamped. */
13106
+ const MIRROR_PASS_MAX = 1024;
13107
+ /** How far a wavelet's tilt slides the mirror sample, in UV per unit of normal lean. */
13108
+ const MIRROR_DISTORT = .06;
11839
13109
  /** Scene pre-pass (depth for absorption/foam + color grab for refraction):
11840
13110
  * half the drawing buffer, clamped — ONE extra scene render per frame. */
11841
13111
  const SCENE_PASS_MAX = 1024;
@@ -11921,6 +13191,8 @@ var Water3D = class Water3D extends Node3D {
11921
13191
  colors: { default: {} },
11922
13192
  reflection: { default: true },
11923
13193
  reflectionInterval: { default: 1e3 },
13194
+ mirror: { default: false },
13195
+ swellSteepness: { default: .35 },
11924
13196
  foam: { default: true },
11925
13197
  interaction: { default: true },
11926
13198
  splash: { default: true },
@@ -11963,6 +13235,14 @@ var Water3D = class Water3D extends Node3D {
11963
13235
  colors = {};
11964
13236
  /** CubeCamera reflections (fancy only; needs a live renderer). */
11965
13237
  reflection = true;
13238
+ /**
13239
+ * TRUE planar reflection: the scene re-rendered from the camera mirrored
13240
+ * across this surface, sampled per fragment. A cube map can only carry
13241
+ * distant content — the shoreline trees, the moored boat, the player at the
13242
+ * water's edge only appear in the water with this on. Costs one extra scene
13243
+ * render per frame, so it is off by default; a still lake earns it.
13244
+ */
13245
+ mirror = false;
11966
13246
  /** ms between reflection (and foam depth) re-renders. */
11967
13247
  reflectionInterval = 1e3;
11968
13248
  /** Depth-texture shoreline foam — rides the always-on fancy scene pass. */
@@ -12011,6 +13291,13 @@ var Water3D = class Water3D extends Node3D {
12011
13291
  swellDirectionDeg = 0;
12012
13292
  /** Primary swell wavelength in meters (the two crossing trains derive). */
12013
13293
  swellWavelength = 30;
13294
+ /**
13295
+ * GERSTNER pinch on the swell: 0 keeps the legacy height-only trains, 1 is
13296
+ * maximum trochoid (crests narrow to a peak, troughs go broad). It also
13297
+ * decides where whitecaps break — the surface's Jacobian collapses on a
13298
+ * folding crest, and that is where foam belongs.
13299
+ */
13300
+ swellSteepness = .35;
12014
13301
  /** Open-water whitecap foam on the tallest crests, 0–1 (fancy only). Works
12015
13302
  * best with `swell` — the caps ride the swell's crest lines. */
12016
13303
  whitecaps = 0;
@@ -12122,6 +13409,9 @@ var Water3D = class Water3D extends Node3D {
12122
13409
  lastReflectionAt = 0;
12123
13410
  /** Per-frame scene pre-pass: depth (absorption/foam) + color grab (refraction). */
12124
13411
  scenePassTarget = null;
13412
+ mirrorTarget = null;
13413
+ mirrorCamera = new PerspectiveCamera();
13414
+ mirrorMatrix = new Matrix4();
12125
13415
  _createObject3D() {
12126
13416
  return new Mesh();
12127
13417
  }
@@ -12154,6 +13444,7 @@ var Water3D = class Water3D extends Node3D {
12154
13444
  const u = material.uniforms;
12155
13445
  if (u.uUseSceneDepth) u.uUseSceneDepth.value = 0;
12156
13446
  if (u.uUseRefraction) u.uUseRefraction.value = 0;
13447
+ if (u.uUseMirror) u.uUseMirror.value = 0;
12157
13448
  }
12158
13449
  return;
12159
13450
  }
@@ -12189,6 +13480,57 @@ var Water3D = class Water3D extends Node3D {
12189
13480
  u.uUseEnvironmentMap.value = 1;
12190
13481
  }
12191
13482
  }
13483
+ if (this.mirror && u.uMirrorMap && u.uMirrorMatrix && u.uUseMirror) {
13484
+ ctx.gl.getDrawingBufferSize(drawingBufferScratch);
13485
+ const mirrorW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), MIRROR_PASS_MAX));
13486
+ const mirrorH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), MIRROR_PASS_MAX));
13487
+ if (this.mirrorTarget && (this.mirrorTarget.width !== mirrorW || this.mirrorTarget.height !== mirrorH)) {
13488
+ this.mirrorTarget.dispose();
13489
+ this.mirrorTarget = null;
13490
+ }
13491
+ if (!this.mirrorTarget) this.mirrorTarget = new WebGLRenderTarget(mirrorW, mirrorH, {
13492
+ depthBuffer: true,
13493
+ stencilBuffer: false,
13494
+ type: HalfFloatType
13495
+ });
13496
+ const surfaceY = worldTranslationOf(this).y;
13497
+ const cam = ctx.camera;
13498
+ cam.getWorldPosition(mirrorEyeScratch);
13499
+ cam.getWorldQuaternion(mirrorQuatScratch);
13500
+ mirrorTargetScratch.set(0, 0, -1).applyQuaternion(mirrorQuatScratch).add(mirrorEyeScratch);
13501
+ mirrorUpScratch.set(0, 1, 0).applyQuaternion(mirrorQuatScratch);
13502
+ mirrorEyeScratch.y = 2 * surfaceY - mirrorEyeScratch.y;
13503
+ mirrorTargetScratch.y = 2 * surfaceY - mirrorTargetScratch.y;
13504
+ mirrorUpScratch.y = -mirrorUpScratch.y;
13505
+ const mirrorCam = this.mirrorCamera;
13506
+ mirrorCam.fov = cam.fov ?? 50;
13507
+ mirrorCam.aspect = cam.aspect ?? mirrorW / mirrorH;
13508
+ mirrorCam.near = cam.near ?? .1;
13509
+ mirrorCam.far = cam.far ?? 1e3;
13510
+ mirrorCam.position.copy(mirrorEyeScratch);
13511
+ mirrorCam.up.copy(mirrorUpScratch);
13512
+ mirrorCam.lookAt(mirrorTargetScratch);
13513
+ mirrorCam.updateMatrixWorld(true);
13514
+ mirrorCam.updateProjectionMatrix();
13515
+ this.mirrorMatrix.set(.5, 0, 0, .5, 0, .5, 0, .5, 0, 0, .5, .5, 0, 0, 0, 1);
13516
+ this.mirrorMatrix.multiply(mirrorCam.projectionMatrix);
13517
+ this.mirrorMatrix.multiply(mirrorCam.matrixWorldInverse);
13518
+ const prevMirrorTarget = ctx.gl.getRenderTarget();
13519
+ const prevMirrorClip = ctx.gl.clippingPlanes;
13520
+ const wasMirrorVisible = mesh.visible;
13521
+ mesh.visible = false;
13522
+ mirrorClipPlane.constant = -(surfaceY - WATERLINE_CLIP_SLACK);
13523
+ ctx.gl.clippingPlanes = mirrorClipPlanes;
13524
+ ctx.gl.setRenderTarget(this.mirrorTarget);
13525
+ ctx.gl.clear(true, true, false);
13526
+ ctx.gl.render(ctx.scene, mirrorCam);
13527
+ ctx.gl.setRenderTarget(prevMirrorTarget);
13528
+ ctx.gl.clippingPlanes = prevMirrorClip;
13529
+ mesh.visible = wasMirrorVisible;
13530
+ u.uMirrorMap.value = this.mirrorTarget.texture;
13531
+ u.uMirrorMatrix.value.copy(this.mirrorMatrix);
13532
+ u.uUseMirror.value = 1;
13533
+ } else if (u.uUseMirror) u.uUseMirror.value = 0;
12192
13534
  ctx.gl.getDrawingBufferSize(drawingBufferScratch);
12193
13535
  const passW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), SCENE_PASS_MAX));
12194
13536
  const passH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), SCENE_PASS_MAX));
@@ -12277,6 +13619,8 @@ var Water3D = class Water3D extends Node3D {
12277
13619
  this.scenePassTarget?.depthTexture?.dispose();
12278
13620
  this.scenePassTarget?.dispose();
12279
13621
  this.scenePassTarget = null;
13622
+ this.mirrorTarget?.dispose();
13623
+ this.mirrorTarget = null;
12280
13624
  super.free();
12281
13625
  }
12282
13626
  /** Swap geometry+material only when a surface-shaping prop changed. */
@@ -12310,6 +13654,10 @@ var Water3D = class Water3D extends Node3D {
12310
13654
  uOpacity: { value: this.rp("opacity") },
12311
13655
  uEnvironmentMap: { value: null },
12312
13656
  uUseEnvironmentMap: { value: 0 },
13657
+ uMirrorMap: { value: null },
13658
+ uMirrorMatrix: { value: new Matrix4() },
13659
+ uUseMirror: { value: 0 },
13660
+ uMirrorDistort: { value: MIRROR_DISTORT },
12313
13661
  uWavesAmplitude: { value: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT },
12314
13662
  uWavesSpeed: { value: FANCY_WAVE.speed },
12315
13663
  uWavesFrequency: { value: FANCY_WAVE.frequency },
@@ -12352,6 +13700,7 @@ var Water3D = class Water3D extends Node3D {
12352
13700
  uSwellAmplitude: { value: this.rp("swell") },
12353
13701
  uSwellDir: { value: new Vector2(0, 1) },
12354
13702
  uSwellWavelength: { value: this.rp("swellWavelength") },
13703
+ uSwellSteepness: { value: this.rp("swellSteepness") },
12355
13704
  uWhitecaps: { value: this.rp("whitecaps") },
12356
13705
  uWaveEnvelope: { value: 0 },
12357
13706
  uCausticsAbove: { value: this.rp("causticsAbove") },
@@ -12421,6 +13770,7 @@ var Water3D = class Water3D extends Node3D {
12421
13770
  if (u.uAbsorption) u.uAbsorption.value = this.rp("absorption");
12422
13771
  if (u.uSwellAmplitude) u.uSwellAmplitude.value = this.rp("swell");
12423
13772
  if (u.uSwellWavelength) u.uSwellWavelength.value = this.rp("swellWavelength");
13773
+ if (u.uSwellSteepness) u.uSwellSteepness.value = this.rp("swellSteepness");
12424
13774
  if (u.uWhitecaps) u.uWhitecaps.value = this.rp("whitecaps");
12425
13775
  if (u.uCausticsAbove) u.uCausticsAbove.value = this.rp("causticsAbove");
12426
13776
  if (u.uShoreWaves) u.uShoreWaves.value = this.rp("shoreWaves");
@@ -12664,6 +14014,13 @@ const underwaterCamScratch = new Vector3();
12664
14014
  const UNDERWATER_MARGIN = 3;
12665
14015
  /** Keep-below clip plane for the scene pre-pass (constant set per render):
12666
14016
  * normal (0,−1,0) keeps y < constant — see the grab-clip note in _onRender3D. */
14017
+ const mirrorEyeScratch = new Vector3();
14018
+ const mirrorTargetScratch = new Vector3();
14019
+ const mirrorUpScratch = new Vector3();
14020
+ const mirrorQuatScratch = new Quaternion();
14021
+ /** Mirror pass: keep only what is ABOVE the waterline. */
14022
+ const mirrorClipPlane = new Plane(new Vector3(0, 1, 0), 0);
14023
+ const mirrorClipPlanes = [mirrorClipPlane];
12667
14024
  const grabClipPlane = new Plane(new Vector3(0, -1, 0), 0);
12668
14025
  const grabClipPlanes = [grabClipPlane];
12669
14026
  /** Keep-above twin for the depth-completion pass: normal (0,1,0) keeps
@@ -12695,6 +14052,7 @@ function registerNodes3D() {
12695
14052
  registerNode(BoneAttachment3D);
12696
14053
  registerNode(BoneLookAt3D);
12697
14054
  registerNode(InstancedMesh3D);
14055
+ registerNode(River3D);
12698
14056
  registerNode(Terrain3D);
12699
14057
  registerNode(Water3D);
12700
14058
  registerNode(Foliage3D);
@@ -12710,4 +14068,4 @@ function registerNodes3D() {
12710
14068
  registerNode(CharacterBody3D);
12711
14069
  }
12712
14070
  //#endregion
12713
- export { Joint3D as A, keyboardIntensity as B, BoneLookAt3D as C, DEFAULT_TERRAIN_TEXTURE_BASE as D, Terrain3D as E, StaticBody3D as F, rigPose as H, Node3D as I, validateCollider3D as L, CharacterBody3D as M, PhysicsBody3D as N, TERRAIN_THEMES as O, RigidBody3D as P, QUARTER_PITCH as R, Camera3D as S, Billboard3D as T, movementState as V, DENSITY_PRESETS as _, VOXEL_PALETTE as a, FLOWER_VARIETIES as b, Trail3D as c, LoftMesh3D as d, DirectionalLight3D as f, Foliage3D as g, MeshInstance3D as h, WATER_MAX_RIPPLES as i, Area3D as j, terrainThemeLayers as k, Particles3D as l, InstancedMesh3D as m, Water3D as n, VoxelGrid3D as o, OmniLight3D as p, createCausticsQuad as r, Tree3D as s, registerNodes3D as t, ModelInstance3D as u, Flowers3D as v, BoneAttachment3D as w, CharacterController3D as x, resolveFlowerDensity as y, cameraRelative as z };
14071
+ export { TERRAIN_THEMES as A, QUARTER_PITCH as B, CharacterController3D as C, Billboard3D as D, BoneAttachment3D as E, PhysicsBody3D as F, keyboardIntensity as H, RigidBody3D as I, StaticBody3D as L, Joint3D as M, Area3D as N, Terrain3D as O, CharacterBody3D as P, Node3D as R, FLOWER_VARIETIES as S, BoneLookAt3D as T, movementState as U, cameraRelative as V, rigPose as W, MeshInstance3D as _, VoxelGrid3D as a, Flowers3D as b, River3D as c, Particles3D as d, ModelInstance3D as f, InstancedMesh3D as g, OmniLight3D as h, VOXEL_PALETTE as i, terrainThemeLayers as j, DEFAULT_TERRAIN_TEXTURE_BASE as k, traceDownhillPath as l, DirectionalLight3D as m, Water3D as n, Tree3D as o, LoftMesh3D as p, createCausticsQuad as r, Trail3D as s, registerNodes3D as t, WATER_MAX_RIPPLES as u, Foliage3D as v, Camera3D as w, resolveFlowerDensity as x, DENSITY_PRESETS as y, validateCollider3D as z };