incanto 0.19.0 → 0.20.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,1161 @@ 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 smoothed = descending.map((_, i) => {
6914
+ let sum = 0;
6915
+ let n = 0;
6916
+ 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];
6919
+ n++;
6920
+ }
6921
+ return sum / n;
6922
+ });
6923
+ const profile = new Array(smoothed.length);
6924
+ for (let i = 0; i < smoothed.length; i++) {
6925
+ const s = smoothed[i];
6926
+ profile[i] = i === 0 ? s : Math.min(s, profile[i - 1]);
6927
+ }
6928
+ const meanHalf = halfWidths.reduce((a, b) => a + b, 0) / halfWidths.length || 1;
6929
+ return samples.map((s, i) => {
6930
+ const surfaceY = profile[i] + opts.depth;
6931
+ const prev = Math.max(0, i - 1);
6932
+ const next = Math.min(samples.length - 1, i + 1);
6933
+ const run = samples[next].along - samples[prev].along;
6934
+ const drop = (profile[prev] + opts.depth - (profile[next] + opts.depth)) / (run || 1);
6935
+ const slope = Math.max(0, drop);
6936
+ const halfWidth = halfWidths[i];
6937
+ const speed = Math.min(Math.max(opts.flowSpeed * Math.sqrt(meanHalf / halfWidth) * (1 + SLOPE_GAIN * slope), SPEED_MIN), SPEED_MAX);
6938
+ return {
6939
+ ...s,
6940
+ halfWidth,
6941
+ surfaceY,
6942
+ slope,
6943
+ speed
6944
+ };
6945
+ });
6946
+ }
6947
+ /** Ribbon mesh for the stations — flat across, draped along, attribute-rich. */
6948
+ function buildRiverGeometry(rings, opts) {
6949
+ const across = Math.max(1, Math.round(opts.acrossSegments));
6950
+ const perRing = across + 1;
6951
+ const count = rings.length * perRing;
6952
+ const positions = new Float32Array(count * 3);
6953
+ const normals = new Float32Array(count * 3);
6954
+ const uvs = new Float32Array(count * 2);
6955
+ const flow = new Float32Array(count * 2);
6956
+ const river = new Float32Array(count * 4);
6957
+ const quads = Math.max(0, rings.length - 1) * across;
6958
+ const indices = new Uint32Array(quads * 6);
6959
+ for (let r = 0; r < rings.length; r++) {
6960
+ const ring = rings[r];
6961
+ const px = -ring.tz;
6962
+ const pz = ring.tx;
6963
+ const nLen = Math.hypot(ring.slope, 1);
6964
+ const nx = ring.tx * ring.slope / nLen;
6965
+ const ny = 1 / nLen;
6966
+ const nz = ring.tz * ring.slope / nLen;
6967
+ for (let c = 0; c <= across; c++) {
6968
+ const t = c / across * 2 - 1;
6969
+ const offset = t * ring.halfWidth * opts.bankOvershoot;
6970
+ const x = ring.x + px * offset;
6971
+ const z = ring.z + pz * offset;
6972
+ const i = r * perRing + c;
6973
+ positions[i * 3] = x;
6974
+ positions[i * 3 + 1] = ring.surfaceY;
6975
+ positions[i * 3 + 2] = z;
6976
+ normals[i * 3] = nx;
6977
+ normals[i * 3 + 1] = ny;
6978
+ normals[i * 3 + 2] = nz;
6979
+ uvs[i * 2] = ring.along;
6980
+ uvs[i * 2 + 1] = offset;
6981
+ flow[i * 2] = ring.tx;
6982
+ flow[i * 2 + 1] = ring.tz;
6983
+ river[i * 4] = ring.speed;
6984
+ river[i * 4 + 1] = Math.max(0, ring.surfaceY - sampleBed(opts, x, z));
6985
+ river[i * 4 + 2] = ring.slope;
6986
+ river[i * 4 + 3] = t * opts.bankOvershoot;
6987
+ }
6988
+ }
6989
+ let w = 0;
6990
+ for (let r = 0; r + 1 < rings.length; r++) for (let c = 0; c < across; c++) {
6991
+ const a = r * perRing + c;
6992
+ const b = a + 1;
6993
+ const d = a + perRing;
6994
+ const e = d + 1;
6995
+ indices[w++] = a;
6996
+ indices[w++] = b;
6997
+ indices[w++] = d;
6998
+ indices[w++] = b;
6999
+ indices[w++] = e;
7000
+ indices[w++] = d;
7001
+ }
7002
+ return {
7003
+ positions,
7004
+ normals,
7005
+ uvs,
7006
+ flow,
7007
+ river,
7008
+ indices
7009
+ };
7010
+ }
7011
+ /**
7012
+ * Where a world point sits in the channel — the query gameplay runs against
7013
+ * (drift a boat, sweep a swimmer, splash a footstep). Projects onto the two
7014
+ * segments neighbouring the closest station and keeps the better one.
7015
+ */
7016
+ /** Bed height under a vertex, averaged over `bedBlur` (0 = a single tap). */
7017
+ function sampleBed(opts, x, z) {
7018
+ const r = opts.bedBlur ?? 0;
7019
+ if (r <= 0) return opts.bedAt(x, z);
7020
+ 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;
7021
+ }
7022
+ function projectToRiver(rings, x, z) {
7023
+ if (rings.length === 0) return null;
7024
+ let best = 0;
7025
+ let bestD = Number.POSITIVE_INFINITY;
7026
+ for (let i = 0; i < rings.length; i++) {
7027
+ const r = rings[i];
7028
+ const d = (r.x - x) ** 2 + (r.z - z) ** 2;
7029
+ if (d < bestD) {
7030
+ bestD = d;
7031
+ best = i;
7032
+ }
7033
+ }
7034
+ if (rings.length === 1) {
7035
+ const only = rings[0];
7036
+ return hitFrom(only, only, 0, x, z, false);
7037
+ }
7038
+ let hit = null;
7039
+ let hitD = Number.POSITIVE_INFINITY;
7040
+ for (const start of [best - 1, best]) {
7041
+ if (start < 0 || start + 1 >= rings.length) continue;
7042
+ const a = rings[start];
7043
+ const b = rings[start + 1];
7044
+ const dx = b.x - a.x;
7045
+ const dz = b.z - a.z;
7046
+ const len2 = dx * dx + dz * dz;
7047
+ const raw = len2 > 1e-9 ? ((x - a.x) * dx + (z - a.z) * dz) / len2 : 0;
7048
+ const t = Math.min(Math.max(raw, 0), 1);
7049
+ const capped = start === 0 && raw < 0 || start + 2 === rings.length && raw > 1;
7050
+ const px = a.x + dx * t;
7051
+ const pz = a.z + dz * t;
7052
+ const d = (px - x) ** 2 + (pz - z) ** 2;
7053
+ if (d < hitD) {
7054
+ hitD = d;
7055
+ hit = hitFrom(a, b, t, x, z, capped);
7056
+ }
7057
+ }
7058
+ return hit;
7059
+ }
7060
+ function hitFrom(a, b, t, x, z, capped) {
7061
+ const cx = a.x + (b.x - a.x) * t;
7062
+ const cz = a.z + (b.z - a.z) * t;
7063
+ let tx = a.tx + (b.tx - a.tx) * t;
7064
+ let tz = a.tz + (b.tz - a.tz) * t;
7065
+ const tl = Math.hypot(tx, tz) || 1;
7066
+ tx /= tl;
7067
+ tz /= tl;
7068
+ const halfWidth = a.halfWidth + (b.halfWidth - a.halfWidth) * t;
7069
+ const across = ((x - cx) * -tz + (z - cz) * tx) / (halfWidth || 1);
7070
+ return {
7071
+ inside: !capped && Math.abs(across) <= 1,
7072
+ across,
7073
+ along: a.along + (b.along - a.along) * t,
7074
+ surfaceY: a.surfaceY + (b.surfaceY - a.surfaceY) * t,
7075
+ dirX: tx,
7076
+ dirZ: tz,
7077
+ speed: a.speed + (b.speed - a.speed) * t,
7078
+ halfWidth
7079
+ };
7080
+ }
7081
+ /**
7082
+ * Trace where water would actually run from a point: repeated steepest descent
7083
+ * with momentum, so the line follows a valley floor instead of rattling between
7084
+ * its walls. Ends when the ground goes flat (a pool), the trace leaves the play
7085
+ * area, or `maxPoints` is reached.
7086
+ *
7087
+ * Feed the result straight into a {@link River3D} `path` — the terrain then
7088
+ * picks its own river, which is how a generated map gets water that belongs.
7089
+ */
7090
+ function traceDownhillPath(bedAt, opts) {
7091
+ const step = Math.max(opts.step, .001);
7092
+ const maxPoints = Math.max(2, opts.maxPoints ?? 200);
7093
+ const minDrop = opts.minDrop ?? step * .01;
7094
+ const bounds = opts.bounds ?? Number.POSITIVE_INFINITY;
7095
+ let x = opts.x;
7096
+ let z = opts.z;
7097
+ let dirX = 0;
7098
+ let dirZ = 0;
7099
+ let stalls = 0;
7100
+ const out = [[x, z]];
7101
+ for (let i = 1; i < maxPoints; i++) {
7102
+ const gx = (bedAt(x + step, z) - bedAt(x - step, z)) / (2 * step);
7103
+ const gz = (bedAt(x, z + step) - bedAt(x, z - step)) / (2 * step);
7104
+ const grad = Math.hypot(gx, gz);
7105
+ let nx = grad > 1e-9 ? -gx / grad : dirX;
7106
+ let nz = grad > 1e-9 ? -gz / grad : dirZ;
7107
+ nx = nx * .6 + dirX * .4;
7108
+ nz = nz * .6 + dirZ * .4;
7109
+ const len = Math.hypot(nx, nz);
7110
+ if (len < 1e-6) break;
7111
+ dirX = nx / len;
7112
+ dirZ = nz / len;
7113
+ let px = x + dirX * step;
7114
+ let pz = z + dirZ * step;
7115
+ let bestOff = 0;
7116
+ let bestY = bedAt(px, pz);
7117
+ for (const off of [
7118
+ -.8,
7119
+ -.4,
7120
+ .4,
7121
+ .8
7122
+ ]) {
7123
+ const y = bedAt(px - dirZ * off * step, pz + dirX * off * step);
7124
+ if (y < bestY - 1e-4) {
7125
+ bestY = y;
7126
+ bestOff = off;
7127
+ }
7128
+ }
7129
+ if (bestOff !== 0) {
7130
+ px -= dirZ * bestOff * step;
7131
+ pz += dirX * bestOff * step;
7132
+ }
7133
+ if (Math.abs(px) > bounds || Math.abs(pz) > bounds) break;
7134
+ if (bedAt(x, z) - bedAt(px, pz) < minDrop) {
7135
+ if (++stalls >= 3) break;
7136
+ } else stalls = 0;
7137
+ x = px;
7138
+ z = pz;
7139
+ out.push([x, z]);
7140
+ }
7141
+ return out.length >= 2 ? out : [];
7142
+ }
7143
+ function catmullRom(p0, p1, p2, p3, t) {
7144
+ const t2 = t * t;
7145
+ const t3 = t2 * t;
7146
+ return .5 * (2 * p1 + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
7147
+ }
7148
+ //#endregion
7149
+ //#region src/3d/water/river-shaders.ts
7150
+ /**
7151
+ * River surface shader — running water, as distinct from the standing water of
7152
+ * {@link WATER_FRAG}.
7153
+ *
7154
+ * The whole look hangs off four per-vertex facts the CPU derives from the path
7155
+ * and the ground (see river.ts): the downstream direction, the current speed,
7156
+ * the water column and the grade. From those:
7157
+ *
7158
+ * - **the current is a frame, not a scroll.** Every pattern lives in the
7159
+ * ribbon's (downstream, across) meters, so a bend carries its ripples around
7160
+ * the corner instead of dragging a world-locked texture sideways.
7161
+ * - **speed varies along a river, and that shears any scrolling field apart.**
7162
+ * Two copies of every advected pattern run half a period out of step and
7163
+ * cross-fade on a triangle wave, so each is reset while the other carries the
7164
+ * image — the surface never accumulates smear.
7165
+ * - **whitewater is derived, never painted.** Grade × speed makes rapids, the
7166
+ * bank shear line makes edge froth, a thinning column makes riffles over
7167
+ * rock. Foam then rides downstream as stretched streaks.
7168
+ * - **standing waves stand.** In a chute the humps are world-locked (phase on
7169
+ * the downstream coordinate, no scroll) while the water tears through them —
7170
+ * the signature of a hydraulic jump, and the thing that reads as "fast".
7171
+ */
7172
+ const RIVER_NOISE = `
7173
+ float riverHash(vec2 p) {
7174
+ p = fract(p * vec2(127.31, 311.7));
7175
+ p += dot(p, p + 34.23);
7176
+ return fract(p.x * p.y);
7177
+ }
7178
+
7179
+ float riverNoise(vec2 p) {
7180
+ vec2 i = floor(p);
7181
+ vec2 f = fract(p);
7182
+ vec2 u = f * f * (3.0 - 2.0 * f);
7183
+ float a = riverHash(i);
7184
+ float b = riverHash(i + vec2(1.0, 0.0));
7185
+ float c = riverHash(i + vec2(0.0, 1.0));
7186
+ float d = riverHash(i + vec2(1.0, 1.0));
7187
+ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
7188
+ }
7189
+ `;
7190
+ const RIVER_VERT = `
7191
+ precision highp float;
7192
+
7193
+ #include <fog_pars_vertex>
7194
+
7195
+ uniform float uTime;
7196
+ uniform float uStandingAmp;
7197
+ uniform float uGlideAmp;
7198
+
7199
+ // (downstream m, across m) — the flow frame every pattern lives in
7200
+ attribute vec2 aFlowUv;
7201
+ // unit downstream direction in XZ
7202
+ attribute vec2 aFlow;
7203
+ // (speed m/s, water column m, grade, across ∈ [-overshoot, +overshoot])
7204
+ attribute vec4 aRiver;
7205
+
7206
+ varying vec3 vWorldPosition;
7207
+ varying vec3 vSurfaceNormal;
7208
+ varying vec2 vFlowUv;
7209
+ varying vec2 vFlow;
7210
+ varying vec4 vRiver;
7211
+
7212
+ void main() {
7213
+ vFlowUv = aFlowUv;
7214
+ vFlow = aFlow;
7215
+ vRiver = aRiver;
7216
+
7217
+ float column = aRiver.y;
7218
+ float speed = aRiver.x;
7219
+ float grade = aRiver.z;
7220
+ float across = aRiver.w;
7221
+
7222
+ // displace only where there IS water: a bank vertex whose column has run out
7223
+ // must stay welded to the ground or the ribbon lifts off the shore
7224
+ float body = smoothstep(0.0, 0.3, column) * (1.0 - smoothstep(0.7, 1.0, abs(across)));
7225
+
7226
+ // hydraulic jump: in a chute the wave train stands still in the world (phase
7227
+ // 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);
7229
+ float standing =
7230
+ sin(aFlowUv.x * 1.9 + sin(uTime * 0.7) * 0.35) * 0.6 +
7231
+ sin(aFlowUv.x * 3.6 + sin(uTime * 0.9) * 0.5) * 0.4;
7232
+ // calm reach: one long low undulation gliding downstream with the current
7233
+ float glide = sin(aFlowUv.x * 0.5 - uTime * speed * 0.5 + aFlowUv.y * 0.3);
7234
+
7235
+ vec3 displaced = position;
7236
+ displaced.y += body * (standing * chute * uStandingAmp + glide * uGlideAmp * (1.0 - chute));
7237
+
7238
+ vec4 world = modelMatrix * vec4(displaced, 1.0);
7239
+ vWorldPosition = world.xyz;
7240
+ vSurfaceNormal = normalize(mat3(modelMatrix) * normal);
7241
+ // three's fog chunk reads mvPosition — the built-in materials get it from
7242
+ // the project_vertex chunk, which a hand-written vertex stage stands in for
7243
+ vec4 mvPosition = viewMatrix * world;
7244
+ gl_Position = projectionMatrix * mvPosition;
7245
+
7246
+ #include <fog_vertex>
7247
+ }
7248
+ `;
7249
+ const RIVER_FRAG = `
7250
+ precision highp float;
7251
+
7252
+ #include <fog_pars_fragment>
7253
+
7254
+ uniform float uTime;
7255
+ uniform vec3 uShallowColor;
7256
+ uniform vec3 uDeepColor;
7257
+ uniform vec3 uFoamColor;
7258
+ uniform vec3 uSkyColor;
7259
+ uniform vec3 uHorizonColor;
7260
+ uniform vec3 uBankColor;
7261
+ uniform vec3 uSunDirection;
7262
+ uniform vec3 uSunColor;
7263
+ uniform float uSunIntensity;
7264
+ uniform float uMaxDepth;
7265
+ uniform float uAbsorption;
7266
+ uniform float uOpacity;
7267
+ uniform float uFoam;
7268
+ uniform float uRippleScale;
7269
+ uniform float uRippleStrength;
7270
+ uniform float uFlowRate;
7271
+ uniform float uSparkle;
7272
+ uniform float uFresnelScale;
7273
+
7274
+ varying vec3 vWorldPosition;
7275
+ varying vec3 vSurfaceNormal;
7276
+ varying vec2 vFlowUv;
7277
+ varying vec2 vFlow;
7278
+ varying vec4 vRiver;
7279
+
7280
+ ${RIVER_NOISE}
7281
+
7282
+ /**
7283
+ * Ripple field in flow space: (height, d/d-downstream, d/d-across).
7284
+ *
7285
+ * Anisotropic on purpose — the downstream axis is squeezed so crests run
7286
+ * ACROSS the channel, which is what wavelets on running water actually do.
7287
+ */
7288
+ vec3 riverRipple(vec2 p) {
7289
+ vec2 q = vec2(p.x * 1.7, p.y * 0.55);
7290
+ float h = riverNoise(q) * 0.62 + riverNoise(q * 2.4 + 13.1) * 0.38;
7291
+ float e = 0.35;
7292
+ vec2 qa = vec2((p.x + e) * 1.7, p.y * 0.55);
7293
+ vec2 qb = vec2(p.x * 1.7, (p.y + e) * 0.55);
7294
+ float ha = riverNoise(qa) * 0.62 + riverNoise(qa * 2.4 + 13.1) * 0.38;
7295
+ float hb = riverNoise(qb) * 0.62 + riverNoise(qb * 2.4 + 13.1) * 0.38;
7296
+ return vec3(h, (ha - h) / e, (hb - h) / e);
7297
+ }
7298
+
7299
+ /** Foam streaks: stretched downstream, so froth reads as trails, but broken
7300
+ * enough across the channel that it never becomes a painted stripe. */
7301
+ float riverStreak(vec2 p) {
7302
+ vec2 q = vec2(p.x * 0.5, p.y * 2.0);
7303
+ return riverNoise(q) * 0.5 + riverNoise(q * 3.3 + 7.3) * 0.5;
7304
+ }
7305
+
7306
+ void main() {
7307
+ float speed = vRiver.x;
7308
+ float column = vRiver.y;
7309
+ float grade = vRiver.z;
7310
+ float across = vRiver.w;
7311
+
7312
+ vec2 dir = normalize(vFlow + vec2(1e-5, 0.0));
7313
+ vec3 dir3 = vec3(dir.x, 0.0, dir.y);
7314
+ vec3 perp3 = vec3(-dir.y, 0.0, dir.x);
7315
+
7316
+ // ── dual-phase advection ────────────────────────────────────────────────
7317
+ // A field scrolled at a speed that varies along the river stretches a little
7318
+ // more every frame until it smears. Run two copies half a period apart and
7319
+ // cross-fade with a triangle wave: whichever copy is near its wrap is the one
7320
+ // being faded out, so the reset never shows.
7321
+ float phase = uTime * uFlowRate;
7322
+ float pA = fract(phase);
7323
+ float pB = fract(phase + 0.5);
7324
+ float blend = abs(pA - 0.5) * 2.0;
7325
+ vec2 base = vFlowUv * uRippleScale;
7326
+ float travel = speed * uRippleScale / max(uFlowRate, 0.001);
7327
+
7328
+ vec3 ripA = riverRipple(base - vec2(travel * pA, 0.0));
7329
+ vec3 ripB = riverRipple(base - vec2(travel * pB, 0.0));
7330
+ vec3 ripple = mix(ripA, ripB, blend);
7331
+
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));
7334
+ float streak = mix(streakA, streakB, blend);
7335
+
7336
+ // ripples flatten out where the water thins to a film over the bed
7337
+ float lively = smoothstep(0.02, 0.22, column) * (0.45 + 0.55 * smoothstep(0.1, 1.6, speed));
7338
+ // the gradient is a NOISE slope, not a water slope: clamp it before it tilts
7339
+ // the normal past a real wavelet (an over-driven normal throws fresnel to its
7340
+ // maximum everywhere and the river turns into a sheet of white sky)
7341
+ vec2 slopeXY = clamp(ripple.yz * uRippleStrength * lively, vec2(-0.5), vec2(0.5));
7342
+ vec3 normal = normalize(vSurfaceNormal - dir3 * slopeXY.x - perp3 * slopeXY.y);
7343
+
7344
+ vec3 viewDir = normalize(cameraPosition - vWorldPosition);
7345
+
7346
+ // ── whitewater ──────────────────────────────────────────────────────────
7347
+ // three independent births, all physical: a steep fast reach breaks into
7348
+ // rapids, the shear line along a bank froths, and a column thin enough to
7349
+ // scrape the bed riffles over it
7350
+ float rapids = smoothstep(0.07, 0.22, grade) * smoothstep(1.2, 3.0, speed);
7351
+ // bank froth comes and goes along a shear line — a continuous white hem
7352
+ // around the whole river reads as a drawn outline, never as water
7353
+ float bankPatch = smoothstep(0.35, 0.75, riverStreak(base * 0.22 - vec2(travel * 0.12, 0.0)));
7354
+ float bankShear =
7355
+ smoothstep(0.74, 1.0, abs(across)) * smoothstep(0.8, 2.4, speed) * (0.35 + 0.65 * bankPatch);
7356
+ // shallow is RELATIVE to this river: a hand's depth is a riffle in a brook
7357
+ // and nothing at all in a millrace, so the thresholds ride uMaxDepth
7358
+ 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;
7361
+ // COVERAGE is the thing to be strict about: whitewater is where the surface
7362
+ // has actually broken, and a river that fizzes everywhere reads as milk
7363
+ float foam = smoothstep(0.74 - 0.45 * drive, 1.02 - 0.24 * drive, streak) * min(drive, 1.0);
7364
+ // crest froth: the tops of standing waves in a real rapid always carry white
7365
+ 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);
7367
+
7368
+ // ── body ────────────────────────────────────────────────────────────────
7369
+ float depthT = clamp(column / max(uMaxDepth, 0.01), 0.0, 1.0);
7370
+ // THREE bands, because a two-stop ramp lies about thin water. A film over the
7371
+ // bank does not lighten the ground, it DARKENS it — soaked stone, soaked
7372
+ // gravel — and only once there is a real column does the water start showing
7373
+ // its own colour. Skipping the wet band is what makes shallow game water look
7374
+ // like a sheet of cellophane laid over the terrain.
7375
+ vec3 wet = uDeepColor * 0.45;
7376
+ vec3 body = mix(wet, uShallowColor, smoothstep(0.0, 0.3, depthT));
7377
+ body = mix(body, uDeepColor, smoothstep(0.28, 0.78, depthT));
7378
+ // sunlight focused by the ripples dances on the bed of a shallow reach
7379
+ float dapple = smoothstep(0.55, 0.95, ripple.x) * (1.0 - smoothstep(0.15, 0.8, depthT));
7380
+ body += uSunColor * dapple * 0.14 * uSunIntensity;
7381
+
7382
+ // Beer's law decides how much of the bed the water still lets through — this
7383
+ // surface composites over the terrain, so absorption IS the alpha curve
7384
+ float hide = 1.0 - exp(-uAbsorption * column * 3.2);
7385
+
7386
+ // ── sky + sun ───────────────────────────────────────────────────────────
7387
+ // What a river actually mirrors depends on how far up the reflected ray
7388
+ // goes: almost every fragment of a stream is seen at a grazing angle, and
7389
+ // those rays leave nearly horizontal — into the far BANK (dark trees, wet
7390
+ // rock), not the zenith. Reflecting sky everywhere is what turns game water
7391
+ // into a sheet of white plastic.
7392
+ vec3 reflectDir = reflect(-viewDir, normal);
7393
+ // the far bank fills a surprising slice of the reflected hemisphere when the
7394
+ // eye is near the water — hold its dark tone well past the horizon line
7395
+ vec3 sky = mix(uBankColor, uHorizonColor, smoothstep(0.05, 0.34, reflectDir.y));
7396
+ sky = mix(sky, uSkyColor, smoothstep(0.34, 0.85, reflectDir.y));
7397
+ float fresnel = clamp(
7398
+ uFresnelScale * pow(1.0 - clamp(dot(normal, viewDir), 0.0, 1.0), 5.0),
7399
+ 0.0,
7400
+ 0.82
7401
+ );
7402
+
7403
+ vec3 sunDir = normalize(uSunDirection);
7404
+ vec3 halfVec = normalize(sunDir + viewDir);
7405
+ float ndh = max(dot(normal, halfVec), 0.0);
7406
+ // two lobes: a tight glitter spark plus a broad sheen. The sheen is what
7407
+ // makes a river READ as a moving surface from across the valley, where the
7408
+ // sparkle is smaller than a pixel.
7409
+ float spec = (pow(ndh, 96.0) + pow(ndh, 18.0) * 0.22) * uSunIntensity;
7410
+ // glitter: the same advected field gates the highlight so it breaks into
7411
+ // travelling sparks instead of one smooth sheen
7412
+ float glint = smoothstep(0.55, 0.95, ripple.x);
7413
+ spec *= 0.45 + 0.55 * glint * uSparkle;
7414
+ spec *= 1.0 - foam * 0.85; // froth is rough — it kills the mirror
7415
+
7416
+ vec3 color = mix(body, sky, fresnel);
7417
+ color += uSunColor * spec * 1.6;
7418
+ color = mix(color, uFoamColor, foam * 0.88);
7419
+
7420
+ // ── edges ───────────────────────────────────────────────────────────────
7421
+ // the ribbon is built wider than the channel; what makes its outline is the
7422
+ // water running out, so bars and boulders cut it exactly like the ground does
7423
+ float edge = smoothstep(0.0, 0.06, column) * (1.0 - smoothstep(0.84, 1.0, abs(across)));
7424
+ float alpha = clamp(max(hide * uOpacity, fresnel * 0.85), 0.0, 1.0);
7425
+ alpha = clamp(max(alpha, foam * 0.95) * edge, 0.0, 1.0);
7426
+
7427
+ gl_FragColor = vec4(color, alpha);
7428
+
7429
+ #include <tonemapping_fragment>
7430
+ #include <colorspace_fragment>
7431
+ #include <fog_fragment>
7432
+ }
7433
+ `;
7434
+ //#endregion
7435
+ //#region src/3d/nodes/river-3d.ts
7436
+ /** Ribbon half-width as a multiple of the channel's — the water column fades
7437
+ * out inside the overshoot, so the bank is drawn by the ground, not by an edge. */
7438
+ const BANK_OVERSHOOT = 1.25;
7439
+ /** Centerline station spacing, derived from the narrowest reach. */
7440
+ const STEP_MIN = .6;
7441
+ const STEP_MAX = 2.5;
7442
+ const STEP_PER_WIDTH = .45;
7443
+ /** Quads across the channel — enough for the bank fade to read as a gradient. */
7444
+ const ACROSS_MIN = 4;
7445
+ const ACROSS_MAX = 12;
7446
+ const METERS_PER_ACROSS_SEGMENT = 1.5;
7447
+ /** Water-column averaging radius — small enough that a boulder still thins the
7448
+ * flow over it, wide enough that terrain grain does not fizz the whole river. */
7449
+ const COLUMN_BLUR_PER_HALF_WIDTH = .35;
7450
+ const COLUMN_BLUR_MAX = 1.2;
7451
+ /** How hard the current drags a body toward the water's own velocity (1/s at
7452
+ * `flowForce: 1`). Drag, not thrust: nothing ever outruns the river. Tuned
7453
+ * against CharacterController3D, whose stand-still damping fights it — at this
7454
+ * rate a person planted in a 2 m/s creek slides, which is what a 2 m/s creek
7455
+ * does to a person, while a free-floating raft still converges on the current. */
7456
+ const CURRENT_DRAG = 6;
7457
+ /** A body counts as in the current while its feet are under the surface and it
7458
+ * has not sunk more than this far below the bed. */
7459
+ const CURRENT_SURFACE_SLACK = .05;
7460
+ const CURRENT_UNDER_SLACK = 1.5;
7461
+ const DEFAULT_COLORS = {
7462
+ shallow: "#74b2a2",
7463
+ deep: "#123f4b",
7464
+ sky: "#8ab6d8",
7465
+ horizon: "#9fb6c2",
7466
+ bank: "#465447"
7467
+ };
7468
+ const FOAM_COLOR = "#e9f2ef";
7469
+ /** Fixed look constants — the props stay intent-level, these carry the craft. */
7470
+ const LOOK = {
7471
+ /** Pattern units per meter of flow space. */
7472
+ rippleScale: .85,
7473
+ /** Ripple normal SLOPE (wavelets are centimetres — a big value reads as
7474
+ * churning glass, not water). */
7475
+ rippleStrength: .09,
7476
+ /** Advection phase periods per second (the dual-phase crossfade rate). */
7477
+ flowRate: .42,
7478
+ fresnelScale: .95,
7479
+ /** Standing-wave height in a chute, meters. */
7480
+ standingAmp: .11,
7481
+ /** Long glide undulation on a calm reach, meters. */
7482
+ glideAmp: .025
7483
+ };
7484
+ /**
7485
+ * A river: running water that finds its own shape.
7486
+ *
7487
+ * A scene declares a centerline `path`, a `width` (constant or a profile) and a
7488
+ * `depth`; the node samples the Terrain3D underneath and derives everything
7489
+ * else — a surface that descends with the ground and never climbs, a current
7490
+ * that speeds up where the channel pinches or tips, whitewater wherever those
7491
+ * two make it, and banks cut by the ground itself (each vertex carries its own
7492
+ * water column, and the shader stops drawing where that runs out).
7493
+ *
7494
+ * Unlike {@link Water3D} it costs no extra render passes — a river is a ribbon
7495
+ * with one draw call, so a map can carry a dozen of them.
7496
+ *
7497
+ * ```json
7498
+ * { "type": "River3D", "props": {
7499
+ * "path": [[-80, -30], [-20, 5], [30, 10], [90, -10]],
7500
+ * "widths": [3, 6, 11], "depth": 0.9, "flowSpeed": 1.8 } }
7501
+ * ```
7502
+ */
7503
+ var River3D = class River3D extends Node3D {
7504
+ static typeName = "River3D";
7505
+ static props = {
7506
+ path: { default: [] },
7507
+ width: { default: 6 },
7508
+ widths: { default: [] },
7509
+ depth: { default: .8 },
7510
+ flowSpeed: { default: 1.6 },
7511
+ colors: { default: {} },
7512
+ absorption: { default: .5 },
7513
+ opacity: { default: .85 },
7514
+ foam: { default: 1 },
7515
+ ripples: { default: 1 },
7516
+ sunDirection: { default: [
7517
+ .5,
7518
+ .8,
7519
+ .3
7520
+ ] },
7521
+ sunColor: { default: "#fff6e0" },
7522
+ sunIntensity: { default: 1 },
7523
+ terrain: { default: "" },
7524
+ flowForce: { default: 1 }
7525
+ };
7526
+ /** Centerline control points `[[x, z], …]` in the node's local frame. */
7527
+ path = [];
7528
+ /** Channel width in meters (constant unless `widths` overrides it). */
7529
+ width = 6;
7530
+ /** Width profile lerped source→mouth, e.g. `[2, 5, 9]` for a stream growing
7531
+ * into a river. Empty = the constant `width`. */
7532
+ widths = [];
7533
+ /** Water column at the centerline, in meters. */
7534
+ depth = .8;
7535
+ /** Reference current in m/s (the mean; reaches speed up and slow down). */
7536
+ flowSpeed = 1.6;
7537
+ /** `{ shallow, deep, sky, horizon, bank }` — the body ramp plus what the
7538
+ * surface mirrors (a grazing river mostly mirrors its BANK, not the sky). */
7539
+ colors = {};
7540
+ /** Beer's-law density: how fast the water hides its bed. */
7541
+ absorption = .5;
7542
+ /** Upper bound on the body's opacity (1 = the deep reach goes solid). */
7543
+ opacity = .85;
7544
+ /** Whitewater dial: 0 = a glassy canal, 1 = default, 2 = raging. */
7545
+ foam = 1;
7546
+ /** Surface-detail dial: ripple relief and glitter. */
7547
+ ripples = 1;
7548
+ /** Glint direction (the sky's sun overrides while this is at its default). */
7549
+ sunDirection = [
7550
+ .5,
7551
+ .8,
7552
+ .3
7553
+ ];
7554
+ sunColor = "#fff6e0";
7555
+ sunIntensity = 1;
7556
+ /** Drape target path; empty = auto-find the first Terrain3D in the tree. */
7557
+ terrain = "";
7558
+ /** How hard the current sweeps bodies downstream (0 = visual only). */
7559
+ flowForce = 1;
7560
+ renderOrder = 1;
7561
+ time = 0;
7562
+ rings = [];
7563
+ courseKey = "";
7564
+ bedAt = null;
7565
+ world = [
7566
+ 0,
7567
+ 0,
7568
+ 0
7569
+ ];
7570
+ _bodies = [];
7571
+ /** Loader hook: a malformed river fails at LOAD, not as an invisible ribbon. */
7572
+ static validateJson(node) {
7573
+ const r = node;
7574
+ 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" });
7575
+ 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" });
7576
+ r.path.forEach((p, i) => {
7577
+ 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" });
7578
+ });
7579
+ 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" });
7580
+ 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" });
7581
+ if (!(r.depth > 0)) throw new IncantoError("BAD_FORMAT", `River3D '${node.name}' depth must be > 0 meters, got ${JSON.stringify(r.depth)}.`, { prop: "depth" });
7582
+ 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" });
7583
+ for (const key of [
7584
+ "absorption",
7585
+ "foam",
7586
+ "ripples",
7587
+ "flowForce",
7588
+ "sunIntensity"
7589
+ ]) {
7590
+ const v = r[key];
7591
+ 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 });
7592
+ }
7593
+ 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" });
7594
+ const colors = r.colors;
7595
+ 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" });
7596
+ for (const key of Object.keys(colors)) {
7597
+ 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" });
7598
+ 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" });
7599
+ }
7600
+ }
7601
+ _createObject3D() {
7602
+ return new Mesh(new BufferGeometry(), this.buildMaterial());
7603
+ }
7604
+ update(dt) {
7605
+ this.time += dt;
7606
+ this.ensureCourse();
7607
+ if (this.flowForce > 0 && this.rings.length > 1) this.sweepBodies(dt);
7608
+ }
7609
+ _syncObject3D() {
7610
+ super._syncObject3D();
7611
+ const mesh = this._ensureObject3D();
7612
+ this.rebuildIfNeeded(mesh);
7613
+ this.syncUniforms(mesh);
7614
+ }
7615
+ /**
7616
+ * Where a WORLD point sits in the channel — the query gameplay scripts run
7617
+ * (steer a raft, drown a torch, decide a ford is too fast to cross). `null`
7618
+ * until the river has been built.
7619
+ */
7620
+ sampleAt(x, z) {
7621
+ this.ensureCourse();
7622
+ if (this.rings.length === 0) return null;
7623
+ const [wx, wy, wz] = this.world;
7624
+ const hit = projectToRiver(this.rings, x - wx, z - wz);
7625
+ return hit ? {
7626
+ ...hit,
7627
+ surfaceY: hit.surfaceY + wy
7628
+ } : null;
7629
+ }
7630
+ /** @internal The derived centerline stations (surface, grade, current). */
7631
+ _rings() {
7632
+ return this.rings;
7633
+ }
7634
+ /**
7635
+ * @internal Environment-sky sun hand-off (see {@link SunConsumer3D}): only
7636
+ * while `sunDirection` sits at its schema default — an authored glint wins.
7637
+ */
7638
+ _applySunDirection(dir) {
7639
+ const def = River3D.props.sunDirection?.default;
7640
+ if (!this.sunDirection.every((v, i) => v === def[i])) return;
7641
+ (this._ensureObject3D().material.uniforms?.uSunDirection?.value)?.set(dir[0], dir[1], dir[2]);
7642
+ }
7643
+ free() {
7644
+ const mesh = this._ensureObject3D();
7645
+ mesh.geometry?.dispose();
7646
+ mesh.material?.dispose?.();
7647
+ super.free();
7648
+ }
7649
+ widthProfile() {
7650
+ return this.widths.length > 0 ? this.widths : [this.width];
7651
+ }
7652
+ /** Resolve the drape terrain; an explicit path that misses is a hard error. */
7653
+ resolveTerrain() {
7654
+ const terrain = findDrapeTerrain(this, this.terrain);
7655
+ 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" });
7656
+ return terrain;
7657
+ }
7658
+ /**
7659
+ * Derive the course (stations + water column) when a shaping prop changed.
7660
+ * Pure CPU work — no three objects — so gameplay can call it any time.
7661
+ */
7662
+ ensureCourse() {
7663
+ const terrain = this.resolveTerrain();
7664
+ const world = worldXZY(this);
7665
+ const key = JSON.stringify([
7666
+ this.path,
7667
+ this.width,
7668
+ this.widths,
7669
+ this.depth,
7670
+ this.flowSpeed,
7671
+ terrain ? terrain.name : "",
7672
+ world
7673
+ ]);
7674
+ if (key === this.courseKey) return false;
7675
+ this.courseKey = key;
7676
+ this.world = world;
7677
+ this.bedAt = null;
7678
+ if (this.path.length < 2) {
7679
+ this.rings = [];
7680
+ return true;
7681
+ }
7682
+ const [wx, wy, wz] = world;
7683
+ this.bedAt = terrain ? (lx, lz) => terrain.heightAt(lx + wx, lz + wz) - wy : () => -this.depth;
7684
+ const widths = this.widthProfile();
7685
+ const narrow = Math.min(...widths);
7686
+ this.rings = buildRiverRings({
7687
+ path: this.path,
7688
+ widths,
7689
+ depth: this.depth,
7690
+ flowSpeed: this.flowSpeed,
7691
+ step: Math.min(Math.max(narrow * STEP_PER_WIDTH, STEP_MIN), STEP_MAX),
7692
+ bedAt: this.bedAt
7693
+ });
7694
+ return true;
7695
+ }
7696
+ rebuildIfNeeded(mesh) {
7697
+ if (!this.ensureCourse() && mesh.geometry.getAttribute("position")) return;
7698
+ if (this.rings.length === 0 || !this.bedAt) {
7699
+ mesh.geometry.dispose();
7700
+ const empty = new BufferGeometry();
7701
+ empty.setAttribute("position", new BufferAttribute(new Float32Array(0), 3));
7702
+ mesh.geometry = empty;
7703
+ return;
7704
+ }
7705
+ const widths = this.widthProfile();
7706
+ const narrow = Math.min(...widths);
7707
+ const wide = Math.max(...widths);
7708
+ const data = buildRiverGeometry(this.rings, {
7709
+ acrossSegments: Math.min(ACROSS_MAX, Math.max(ACROSS_MIN, Math.round(wide / METERS_PER_ACROSS_SEGMENT))),
7710
+ bankOvershoot: BANK_OVERSHOOT,
7711
+ bedBlur: Math.min(narrow / 2 * COLUMN_BLUR_PER_HALF_WIDTH, COLUMN_BLUR_MAX),
7712
+ bedAt: this.bedAt
7713
+ });
7714
+ const geometry = new BufferGeometry();
7715
+ geometry.setAttribute("position", new BufferAttribute(data.positions, 3));
7716
+ geometry.setAttribute("normal", new BufferAttribute(data.normals, 3));
7717
+ geometry.setAttribute("aFlowUv", new BufferAttribute(data.uvs, 2));
7718
+ geometry.setAttribute("aFlow", new BufferAttribute(data.flow, 2));
7719
+ geometry.setAttribute("aRiver", new BufferAttribute(data.river, 4));
7720
+ geometry.setIndex(new BufferAttribute(data.indices, 1));
7721
+ geometry.computeBoundingSphere();
7722
+ mesh.geometry.dispose();
7723
+ mesh.geometry = geometry;
7724
+ }
7725
+ buildMaterial() {
7726
+ return new ShaderMaterial({
7727
+ vertexShader: RIVER_VERT,
7728
+ fragmentShader: RIVER_FRAG,
7729
+ fog: true,
7730
+ uniforms: {
7731
+ fogColor: { value: new Color("#ffffff") },
7732
+ fogNear: { value: 1 },
7733
+ fogFar: { value: 1e3 },
7734
+ fogDensity: { value: 25e-5 },
7735
+ uTime: { value: 0 },
7736
+ uShallowColor: { value: new Color(DEFAULT_COLORS.shallow) },
7737
+ uDeepColor: { value: new Color(DEFAULT_COLORS.deep) },
7738
+ uFoamColor: { value: new Color(FOAM_COLOR) },
7739
+ uSkyColor: { value: new Color(DEFAULT_COLORS.sky) },
7740
+ uHorizonColor: { value: new Color(DEFAULT_COLORS.horizon) },
7741
+ uBankColor: { value: new Color(DEFAULT_COLORS.bank) },
7742
+ uSunDirection: { value: new Vector3(.5, .8, .3) },
7743
+ uSunColor: { value: new Color(this.sunColor) },
7744
+ uSunIntensity: { value: this.sunIntensity },
7745
+ uMaxDepth: { value: this.depth },
7746
+ uAbsorption: { value: this.absorption },
7747
+ uOpacity: { value: this.opacity },
7748
+ uFoam: { value: this.foam },
7749
+ uRippleScale: { value: LOOK.rippleScale },
7750
+ uRippleStrength: { value: LOOK.rippleStrength * this.ripples },
7751
+ uFlowRate: { value: LOOK.flowRate },
7752
+ uSparkle: { value: this.ripples },
7753
+ uFresnelScale: { value: LOOK.fresnelScale },
7754
+ uStandingAmp: { value: LOOK.standingAmp },
7755
+ uGlideAmp: { value: LOOK.glideAmp }
7756
+ },
7757
+ transparent: true,
7758
+ depthTest: true,
7759
+ depthWrite: false,
7760
+ polygonOffset: true,
7761
+ polygonOffsetFactor: -2,
7762
+ polygonOffsetUnits: -4,
7763
+ side: DoubleSide
7764
+ });
7765
+ }
7766
+ syncUniforms(mesh) {
7767
+ const u = mesh.material.uniforms;
7768
+ const set = (key, value) => {
7769
+ const slot = u[key];
7770
+ if (slot) slot.value = value;
7771
+ };
7772
+ const tint = (key, hex) => {
7773
+ (u[key]?.value)?.set(hex);
7774
+ };
7775
+ const colors = this.colors;
7776
+ set("uTime", this.time);
7777
+ tint("uShallowColor", colors.shallow ?? DEFAULT_COLORS.shallow);
7778
+ tint("uDeepColor", colors.deep ?? DEFAULT_COLORS.deep);
7779
+ tint("uSkyColor", colors.sky ?? DEFAULT_COLORS.sky);
7780
+ tint("uHorizonColor", colors.horizon ?? DEFAULT_COLORS.horizon);
7781
+ tint("uBankColor", colors.bank ?? DEFAULT_COLORS.bank);
7782
+ tint("uSunColor", this.sunColor);
7783
+ (u.uSunDirection?.value)?.set(this.sunDirection[0] ?? .5, this.sunDirection[1] ?? .8, this.sunDirection[2] ?? .3);
7784
+ set("uSunIntensity", this.sunIntensity);
7785
+ set("uMaxDepth", this.depth);
7786
+ set("uAbsorption", this.absorption);
7787
+ set("uOpacity", this.opacity);
7788
+ set("uFoam", this.foam);
7789
+ set("uRippleStrength", LOOK.rippleStrength * this.ripples);
7790
+ set("uSparkle", this.ripples);
7791
+ }
7792
+ /**
7793
+ * Drag every body floating in the channel toward the water's own velocity.
7794
+ * Drag rather than thrust: a raft accelerates until it matches the current
7795
+ * and then coasts, and a swimmer can still fight across a slow ford.
7796
+ */
7797
+ sweepBodies(dt) {
7798
+ let top = this;
7799
+ while (top.parent) top = top.parent;
7800
+ const bodies = this._bodies;
7801
+ bodies.length = 0;
7802
+ collectBodies$1(top, bodies);
7803
+ if (bodies.length === 0) return;
7804
+ const [wx, wy, wz] = this.world;
7805
+ for (const body of bodies) {
7806
+ const p = worldXZY(body);
7807
+ const hit = projectToRiver(this.rings, p[0] - wx, p[2] - wz);
7808
+ if (!hit?.inside) continue;
7809
+ const surfaceY = hit.surfaceY + wy;
7810
+ const feet = p[1] - colliderFootDrop(body.collider);
7811
+ if (feet > surfaceY + CURRENT_SURFACE_SLACK) continue;
7812
+ if (feet < surfaceY - this.depth - CURRENT_UNDER_SLACK) continue;
7813
+ const vel = bodyVelocity$1(body);
7814
+ if (!vel) continue;
7815
+ const gain = Math.min(1, this.flowForce * CURRENT_DRAG * dt);
7816
+ const dvx = (hit.dirX * hit.speed - (vel[0] ?? 0)) * gain;
7817
+ const dvz = (hit.dirZ * hit.speed - (vel[2] ?? 0)) * gain;
7818
+ if (body instanceof RigidBody3D) {
7819
+ const mass = body.mass || 1;
7820
+ body.applyImpulse([
7821
+ dvx * mass,
7822
+ 0,
7823
+ dvz * mass
7824
+ ]);
7825
+ } else {
7826
+ vel[0] = (vel[0] ?? 0) + dvx;
7827
+ vel[2] = (vel[2] ?? 0) + dvz;
7828
+ }
7829
+ }
7830
+ }
7831
+ };
7832
+ /** Bodies a current can carry: kinematic characters and dynamic rigid bodies. */
7833
+ function collectBodies$1(node, out) {
7834
+ if (node instanceof CharacterBody3D || node instanceof RigidBody3D) out.push(node);
7835
+ for (const child of node.children) collectBodies$1(child, out);
7836
+ }
7837
+ /** A body's velocity vector — kinematic `velocity` or dynamic `linearVelocity`. */
7838
+ function bodyVelocity$1(body) {
7839
+ return body.velocity ?? body.linearVelocity;
7840
+ }
7841
+ //#endregion
6675
7842
  //#region src/3d/nodes/trail-3d.ts
6676
7843
  const MAX_POINTS = 128;
6677
7844
  /**
@@ -9948,121 +11115,6 @@ function sphereInCameraFrustum(camera, center, radius) {
9948
11115
  return scratchFrustum.intersectsSphere(scratchSphere);
9949
11116
  }
9950
11117
  //#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
11118
  //#region src/3d/water/lake.ts
10067
11119
  /**
10068
11120
  * The Water3D 'simple' quality lake shader — the CHEAP good-looking path.
@@ -10786,6 +11838,16 @@ varying vec3 vWorldPosition;
10786
11838
  varying vec4 vScreenPosition;
10787
11839
 
10788
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;
10789
11851
 
10790
11852
  // Noise function (for foam patterns)
10791
11853
  float hash(vec2 p) {
@@ -11160,6 +12222,27 @@ void main() {
11160
12222
  vec4 reflectionColor = textureCube(uEnvironmentMap, reflectedDirection);
11161
12223
  reflectionColor.rgb = min(reflectionColor.rgb, vec3(2.5));
11162
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
+
11163
12246
  // Calculate fresnel effect
11164
12247
  // incanto: the source dotted the camera→surface direction against the
11165
12248
  // up-facing normal — NEGATIVE at every above-water angle, so the clamp
@@ -11836,6 +12919,10 @@ const WAVE_WORLD_REF = 128;
11836
12919
  const AMPLITUDE_PER_WAVE_HEIGHT = REFERENCE_AMPLITUDE / REFERENCE_WAVE_HEIGHT;
11837
12920
  /** Cube-map reflection resolution (source: reflectionQuality = 256). */
11838
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;
11839
12926
  /** Scene pre-pass (depth for absorption/foam + color grab for refraction):
11840
12927
  * half the drawing buffer, clamped — ONE extra scene render per frame. */
11841
12928
  const SCENE_PASS_MAX = 1024;
@@ -11921,6 +13008,7 @@ var Water3D = class Water3D extends Node3D {
11921
13008
  colors: { default: {} },
11922
13009
  reflection: { default: true },
11923
13010
  reflectionInterval: { default: 1e3 },
13011
+ mirror: { default: false },
11924
13012
  foam: { default: true },
11925
13013
  interaction: { default: true },
11926
13014
  splash: { default: true },
@@ -11963,6 +13051,14 @@ var Water3D = class Water3D extends Node3D {
11963
13051
  colors = {};
11964
13052
  /** CubeCamera reflections (fancy only; needs a live renderer). */
11965
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;
11966
13062
  /** ms between reflection (and foam depth) re-renders. */
11967
13063
  reflectionInterval = 1e3;
11968
13064
  /** Depth-texture shoreline foam — rides the always-on fancy scene pass. */
@@ -12122,6 +13218,9 @@ var Water3D = class Water3D extends Node3D {
12122
13218
  lastReflectionAt = 0;
12123
13219
  /** Per-frame scene pre-pass: depth (absorption/foam) + color grab (refraction). */
12124
13220
  scenePassTarget = null;
13221
+ mirrorTarget = null;
13222
+ mirrorCamera = new PerspectiveCamera();
13223
+ mirrorMatrix = new Matrix4();
12125
13224
  _createObject3D() {
12126
13225
  return new Mesh();
12127
13226
  }
@@ -12154,6 +13253,7 @@ var Water3D = class Water3D extends Node3D {
12154
13253
  const u = material.uniforms;
12155
13254
  if (u.uUseSceneDepth) u.uUseSceneDepth.value = 0;
12156
13255
  if (u.uUseRefraction) u.uUseRefraction.value = 0;
13256
+ if (u.uUseMirror) u.uUseMirror.value = 0;
12157
13257
  }
12158
13258
  return;
12159
13259
  }
@@ -12189,6 +13289,57 @@ var Water3D = class Water3D extends Node3D {
12189
13289
  u.uUseEnvironmentMap.value = 1;
12190
13290
  }
12191
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;
12192
13343
  ctx.gl.getDrawingBufferSize(drawingBufferScratch);
12193
13344
  const passW = Math.max(1, Math.min(Math.round(drawingBufferScratch.x / 2), SCENE_PASS_MAX));
12194
13345
  const passH = Math.max(1, Math.min(Math.round(drawingBufferScratch.y / 2), SCENE_PASS_MAX));
@@ -12277,6 +13428,8 @@ var Water3D = class Water3D extends Node3D {
12277
13428
  this.scenePassTarget?.depthTexture?.dispose();
12278
13429
  this.scenePassTarget?.dispose();
12279
13430
  this.scenePassTarget = null;
13431
+ this.mirrorTarget?.dispose();
13432
+ this.mirrorTarget = null;
12280
13433
  super.free();
12281
13434
  }
12282
13435
  /** Swap geometry+material only when a surface-shaping prop changed. */
@@ -12310,6 +13463,10 @@ var Water3D = class Water3D extends Node3D {
12310
13463
  uOpacity: { value: this.rp("opacity") },
12311
13464
  uEnvironmentMap: { value: null },
12312
13465
  uUseEnvironmentMap: { value: 0 },
13466
+ uMirrorMap: { value: null },
13467
+ uMirrorMatrix: { value: new Matrix4() },
13468
+ uUseMirror: { value: 0 },
13469
+ uMirrorDistort: { value: MIRROR_DISTORT },
12313
13470
  uWavesAmplitude: { value: this.rp("waveHeight") * AMPLITUDE_PER_WAVE_HEIGHT },
12314
13471
  uWavesSpeed: { value: FANCY_WAVE.speed },
12315
13472
  uWavesFrequency: { value: FANCY_WAVE.frequency },
@@ -12664,6 +13821,13 @@ const underwaterCamScratch = new Vector3();
12664
13821
  const UNDERWATER_MARGIN = 3;
12665
13822
  /** Keep-below clip plane for the scene pre-pass (constant set per render):
12666
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];
12667
13831
  const grabClipPlane = new Plane(new Vector3(0, -1, 0), 0);
12668
13832
  const grabClipPlanes = [grabClipPlane];
12669
13833
  /** Keep-above twin for the depth-completion pass: normal (0,1,0) keeps
@@ -12695,6 +13859,7 @@ function registerNodes3D() {
12695
13859
  registerNode(BoneAttachment3D);
12696
13860
  registerNode(BoneLookAt3D);
12697
13861
  registerNode(InstancedMesh3D);
13862
+ registerNode(River3D);
12698
13863
  registerNode(Terrain3D);
12699
13864
  registerNode(Water3D);
12700
13865
  registerNode(Foliage3D);
@@ -12710,4 +13875,4 @@ function registerNodes3D() {
12710
13875
  registerNode(CharacterBody3D);
12711
13876
  }
12712
13877
  //#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 };
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 };