incanto 0.26.0 → 0.27.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.
@@ -898,8 +898,15 @@ function themePatchTransfer(theme) {
898
898
  }
899
899
  //#endregion
900
900
  //#region src/3d/nodes/terrain-3d.ts
901
- /** Hard cap on grid segments per side (129×129 vertices ≈ the CPU budget). */
902
- const MAX_RESOLUTION = 128;
901
+ /**
902
+ * Hard cap on grid segments per side. 128 is the default and the right answer
903
+ * for most maps; 256 (66k vertices) exists for terrain that has to hold
904
+ * features finer than a cell — a 2 m creek bed on a 260 m map needs ~1 m
905
+ * cells or the trench smooths away. The cost is CPU at build time (noise
906
+ * octaves + carving per vertex), paid once per shaping change, so raise it
907
+ * deliberately rather than by default.
908
+ */
909
+ const MAX_RESOLUTION = 256;
903
910
  /**
904
911
  * Procedural heightfield terrain with biome texture splatting — seeded
905
912
  * simplex octaves displace a PlaneGeometry once on the CPU, per-vertex
@@ -982,6 +989,14 @@ var Terrain3D = class extends Node3D {
982
989
  * bed to sit in, and the collider/heightAt/drape all see it. */
983
990
  channels = [];
984
991
  /**
992
+ * Trenches other NODES cut in this terrain — a `River3D` carving its own bed.
993
+ * Keyed by the node that owns each, so a moving or re-pathed river replaces
994
+ * its cut instead of stacking a new one. Runtime state, never serialized:
995
+ * the scene file says "there is a river here", not "here is the ditch it
996
+ * dug", and re-loading the scene digs it again.
997
+ */
998
+ externalChannels = /* @__PURE__ */ new Map();
999
+ /**
985
1000
  * WET SAND band: `{ y, band? }` darkens + glosses the splat in a noisy,
986
1001
  * breathing band just above height `y` (a waterline) — the trace of the
987
1002
  * last swash runup. `band` is the max height above `y` that reads wet
@@ -1061,6 +1076,20 @@ var Terrain3D = class extends Node3D {
1061
1076
  }
1062
1077
  return this._heightmap().heightAt(x - ox, z - oz) + oy;
1063
1078
  }
1079
+ /**
1080
+ * @internal Cut (spec) or release (null) a trench another node owns. A
1081
+ * `River3D` calls this to dig its own bed; the heightmap, the collider,
1082
+ * `heightAt`, the drape and the mesh all pick it up on their next rebuild.
1083
+ */
1084
+ _setExternalChannel(owner, specs) {
1085
+ if (specs === null || specs.length === 0) this.externalChannels.delete(owner);
1086
+ else this.externalChannels.set(owner, specs);
1087
+ }
1088
+ /** Authored trenches plus the ones other nodes cut. */
1089
+ allChannels() {
1090
+ if (this.externalChannels.size === 0) return this.channels;
1091
+ return [...this.channels, ...[...this.externalChannels.values()].flat()];
1092
+ }
1064
1093
  /** @internal The pure heightfield (lazily built — physics pulls this). */
1065
1094
  _heightmap() {
1066
1095
  const segs = this.segments();
@@ -1074,7 +1103,7 @@ var Terrain3D = class extends Node3D {
1074
1103
  this.flatThreshold,
1075
1104
  this.effectiveIslandEdge(),
1076
1105
  this.basins,
1077
- this.channels
1106
+ this.allChannels()
1078
1107
  ]);
1079
1108
  if (!this.heightmap || key !== this.heightmapKey) {
1080
1109
  this.heightmap = buildHeightmap({
@@ -1089,7 +1118,7 @@ var Terrain3D = class extends Node3D {
1089
1118
  flatThreshold: this.flatThreshold,
1090
1119
  islandEdge: this.effectiveIslandEdge(),
1091
1120
  basins: this.basins,
1092
- channels: this.channels
1121
+ channels: this.allChannels()
1093
1122
  });
1094
1123
  this.heightmapKey = key;
1095
1124
  }
@@ -1124,7 +1153,8 @@ var Terrain3D = class extends Node3D {
1124
1153
  this.islandEdge,
1125
1154
  this.layers,
1126
1155
  this.textureBase,
1127
- this.basins
1156
+ this.basins,
1157
+ this.allChannels()
1128
1158
  ]);
1129
1159
  if (key === this.builtKey) return;
1130
1160
  this.builtKey = key;
@@ -6430,6 +6460,22 @@ const PROFILE_SMOOTH = 3;
6430
6460
  const SLOPE_GAIN = 4;
6431
6461
  const SPEED_MIN = .05;
6432
6462
  const SPEED_MAX = 12;
6463
+ /** Taps per side when reading the cross-section (rim height, wetted edge). */
6464
+ const SECTION_TAPS = 14;
6465
+ /** How far out a station looks for the bank that holds it, as a multiple of
6466
+ * its half-width and of its depth. Wider than the water on purpose: a river
6467
+ * running down the middle of a broad channel is held by walls it never
6468
+ * touches, and a scan that stopped at the waterline would call that a spill. */
6469
+ const SCAN_WIDTHS = 3;
6470
+ const SCAN_DEPTHS = 5;
6471
+ /** Thinnest water a reach keeps when the ground refuses to hold any (m). */
6472
+ const MIN_FILM = .04;
6473
+ /** Half-window (stations) the wetted widths are box-smoothed over. */
6474
+ const WIDTH_SMOOTH = 2;
6475
+ /** How far a station looks sideways for the bank that holds its water. */
6476
+ function scanReach(halfWidth, depth) {
6477
+ return Math.max(halfWidth * SCAN_WIDTHS, halfWidth + depth * SCAN_DEPTHS);
6478
+ }
6433
6479
  /**
6434
6480
  * Smooth the control path and walk it at even arc-length steps. The step is
6435
6481
  * nudged so the last sample lands exactly on the mouth — an odd short segment
@@ -6549,26 +6595,102 @@ function buildRiverRings(opts) {
6549
6595
  const s = smoothed[i];
6550
6596
  profile[i] = i === 0 ? s : Math.min(s, profile[i - 1]);
6551
6597
  }
6598
+ const fit = opts.fit === true;
6599
+ const levels = new Array(samples.length);
6600
+ const rims = new Array(samples.length);
6601
+ for (let i = 0; i < samples.length; i++) {
6602
+ const s = samples[i];
6603
+ const wanted = profile[i] + opts.depth;
6604
+ if (!fit) {
6605
+ rims[i] = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
6606
+ levels[i] = wanted;
6607
+ continue;
6608
+ }
6609
+ const reach = scanReach(halfWidths[i], opts.depth);
6610
+ const px = -s.tz;
6611
+ const pz = s.tx;
6612
+ let rimL = Number.NEGATIVE_INFINITY;
6613
+ let rimR = Number.NEGATIVE_INFINITY;
6614
+ for (let k = 1; k <= SECTION_TAPS; k++) {
6615
+ const d = k / SECTION_TAPS * reach;
6616
+ rimL = Math.max(rimL, opts.bedAt(s.x + px * d, s.z + pz * d));
6617
+ rimR = Math.max(rimR, opts.bedAt(s.x - px * d, s.z - pz * d));
6618
+ }
6619
+ rims[i] = [rimL, rimR];
6620
+ levels[i] = Math.min(wanted, rimL, rimR);
6621
+ }
6622
+ for (let i = 0; i < levels.length; i++) {
6623
+ let v = levels[i];
6624
+ if (fit) v = Math.max(v, bed[i] + MIN_FILM);
6625
+ if (i > 0) v = Math.min(v, levels[i - 1]);
6626
+ const rim = rims[i];
6627
+ v = Math.min(v, Math.max(Math.min(rim[0], rim[1]), bed[i] + MIN_FILM));
6628
+ levels[i] = v;
6629
+ }
6630
+ const wetted = samples.map((s, i) => {
6631
+ const half = halfWidths[i];
6632
+ if (!fit) return [half, half];
6633
+ const reach = scanReach(half, opts.depth);
6634
+ const level = levels[i];
6635
+ const px = -s.tz;
6636
+ const pz = s.tx;
6637
+ const edge = (side) => {
6638
+ for (let k = 1; k <= SECTION_TAPS; k++) {
6639
+ const d = k / SECTION_TAPS * reach;
6640
+ if (opts.bedAt(s.x + px * d * side, s.z + pz * d * side) >= level) return d;
6641
+ }
6642
+ return -half;
6643
+ };
6644
+ return [edge(1), edge(-1)];
6645
+ });
6646
+ const smoothWetted = wetted.map((_, i) => {
6647
+ let l = 0;
6648
+ let r = 0;
6649
+ let n = 0;
6650
+ for (let k = -2; k <= WIDTH_SMOOTH; k++) {
6651
+ const w = wetted[Math.min(Math.max(i + k, 0), wetted.length - 1)];
6652
+ l += Math.abs(w[0]);
6653
+ r += Math.abs(w[1]);
6654
+ n++;
6655
+ }
6656
+ const raw = wetted[i];
6657
+ return [Math.min(l / n, Math.abs(raw[0])), Math.min(r / n, Math.abs(raw[1]))];
6658
+ });
6552
6659
  const meanHalf = halfWidths.reduce((a, b) => a + b, 0) / halfWidths.length || 1;
6553
6660
  return samples.map((s, i) => {
6554
- const surfaceY = profile[i] + opts.depth;
6661
+ const surfaceY = levels[i];
6555
6662
  const prev = Math.max(0, i - 1);
6556
6663
  const next = Math.min(samples.length - 1, i + 1);
6557
6664
  const run = samples[next].along - samples[prev].along;
6558
- const drop = (profile[prev] + opts.depth - (profile[next] + opts.depth)) / (run || 1);
6665
+ const drop = (levels[prev] - levels[next]) / (run || 1);
6559
6666
  const slope = Math.max(0, drop);
6560
6667
  const halfWidth = halfWidths[i];
6668
+ const wet = smoothWetted[i];
6561
6669
  const speed = Math.min(Math.max(opts.flowSpeed * Math.sqrt(meanHalf / halfWidth) * (1 + SLOPE_GAIN * slope), SPEED_MIN), SPEED_MAX);
6670
+ const rawWet = wetted[i];
6562
6671
  return {
6563
6672
  ...s,
6564
6673
  halfWidth,
6674
+ halfLeft: Math.max(wet[0], .05),
6675
+ halfRight: Math.max(wet[1], .05),
6676
+ spillLeft: rawWet[0] < 0,
6677
+ spillRight: rawWet[1] < 0,
6565
6678
  surfaceY,
6566
6679
  slope,
6567
6680
  speed
6568
6681
  };
6569
6682
  });
6570
6683
  }
6571
- /** Ribbon mesh for the stations — flat across, draped along, attribute-rich. */
6684
+ /**
6685
+ * Ribbon mesh for the stations: level across the channel, draped along it, and
6686
+ * attribute-rich.
6687
+ *
6688
+ * Level across ONLY while the ground holds it. Where a bank has fallen away —
6689
+ * the outside of a bend, a reach traversing a hillside, the lip above a fall —
6690
+ * a level ribbon would hang in the air, so those vertices drop to follow the
6691
+ * ground down instead. Water pouring over a lip is a sheet on the rock; it is
6692
+ * never a slab of blue floating over the grass.
6693
+ */
6572
6694
  function buildRiverGeometry(rings, opts) {
6573
6695
  const across = Math.max(1, Math.round(opts.acrossSegments));
6574
6696
  const perRing = across + 1;
@@ -6582,6 +6704,7 @@ function buildRiverGeometry(rings, opts) {
6582
6704
  const indices = new Uint32Array(quads * 6);
6583
6705
  for (let r = 0; r < rings.length; r++) {
6584
6706
  const ring = rings[r];
6707
+ const columnCap = Math.max(ring.surfaceY - sampleBed(opts, ring.x, ring.z), MIN_FILM);
6585
6708
  const px = -ring.tz;
6586
6709
  const pz = ring.tx;
6587
6710
  const nLen = Math.hypot(ring.slope, 1);
@@ -6590,12 +6713,15 @@ function buildRiverGeometry(rings, opts) {
6590
6713
  const nz = ring.tz * ring.slope / nLen;
6591
6714
  for (let c = 0; c <= across; c++) {
6592
6715
  const t = c / across * 2 - 1;
6593
- const offset = t * ring.halfWidth * opts.bankOvershoot;
6716
+ const offset = t * (t < 0 ? ring.halfRight : ring.halfLeft) * opts.bankOvershoot;
6594
6717
  const x = ring.x + px * offset;
6595
6718
  const z = ring.z + pz * offset;
6596
6719
  const i = r * perRing + c;
6720
+ const bed = sampleBed(opts, x, z);
6721
+ const cap = (t < 0 ? ring.spillRight : ring.spillLeft) ? columnCap + (MIN_FILM - columnCap) * Math.abs(t) : columnCap;
6722
+ const y = Math.min(ring.surfaceY, bed + cap);
6597
6723
  positions[i * 3] = x;
6598
- positions[i * 3 + 1] = ring.surfaceY;
6724
+ positions[i * 3 + 1] = y;
6599
6725
  positions[i * 3 + 2] = z;
6600
6726
  normals[i * 3] = nx;
6601
6727
  normals[i * 3 + 1] = ny;
@@ -6605,7 +6731,7 @@ function buildRiverGeometry(rings, opts) {
6605
6731
  flow[i * 2] = ring.tx;
6606
6732
  flow[i * 2 + 1] = ring.tz;
6607
6733
  river[i * 4] = ring.speed;
6608
- river[i * 4 + 1] = Math.max(0, ring.surfaceY - sampleBed(opts, x, z));
6734
+ river[i * 4 + 1] = Math.max(0, y - bed);
6609
6735
  river[i * 4 + 2] = ring.slope;
6610
6736
  river[i * 4 + 3] = t * opts.bankOvershoot;
6611
6737
  }
@@ -6689,8 +6815,10 @@ function hitFrom(a, b, t, x, z, capped) {
6689
6815
  const tl = Math.hypot(tx, tz) || 1;
6690
6816
  tx /= tl;
6691
6817
  tz /= tl;
6692
- const halfWidth = a.halfWidth + (b.halfWidth - a.halfWidth) * t;
6693
- const across = ((x - cx) * -tz + (z - cz) * tx) / (halfWidth || 1);
6818
+ const signed = (x - cx) * -tz + (z - cz) * tx;
6819
+ const lerp = (u, v) => u + (v - u) * t;
6820
+ const halfWidth = signed < 0 ? lerp(a.halfRight, b.halfRight) : lerp(a.halfLeft, b.halfLeft);
6821
+ const across = signed / (halfWidth || 1);
6694
6822
  return {
6695
6823
  inside: !capped && Math.abs(across) <= 1,
6696
6824
  across,
@@ -6726,7 +6854,7 @@ function detectFalls(rings, opts = {}) {
6726
6854
  topY: lip.surfaceY,
6727
6855
  baseY: base.surfaceY,
6728
6856
  drop,
6729
- halfWidth: base.halfWidth,
6857
+ halfWidth: (base.halfLeft + base.halfRight) / 2,
6730
6858
  dirX: base.tx,
6731
6859
  dirZ: base.tz,
6732
6860
  speed: base.speed
@@ -6930,6 +7058,15 @@ uniform float uRippleStrength;
6930
7058
  uniform float uFlowRate;
6931
7059
  uniform float uSparkle;
6932
7060
  uniform float uFresnelScale;
7061
+ /**
7062
+ * Rocks standing in the current: xz = centre, z = radius, w = how much of the
7063
+ * water they break (0 = drowned and quiet, 1 = the crest stands proud).
7064
+ * The water does not flow AROUND them in any simulated sense — it is told
7065
+ * where they are and paints what a current does when it meets one: a bow
7066
+ * cushion upstream, a broken wake downstream, and a bent surface between.
7067
+ */
7068
+ uniform int uRockCount;
7069
+ uniform vec4 uRocks[8];
6933
7070
 
6934
7071
  varying vec3 vWorldPosition;
6935
7072
  varying vec3 vSurfaceNormal;
@@ -6970,6 +7107,53 @@ void main() {
6970
7107
  float across = vRiver.w;
6971
7108
 
6972
7109
  vec2 dir = normalize(vFlow + vec2(1e-5, 0.0));
7110
+
7111
+ // ── rocks in the current ────────────────────────────────────────────────
7112
+ // Three things happen where a stream meets a boulder, and all three are
7113
+ // read off the same geometry: the flow BENDS around it (a doublet, the
7114
+ // textbook potential flow past a cylinder), it piles into a bow cushion on
7115
+ // the upstream face, and it tears into a broken wake behind. Everything is
7116
+ // in flow space, so a rock in a bend behaves like a rock in a straight.
7117
+ vec2 bend = vec2(0.0);
7118
+ float bow = 0.0;
7119
+ float wake = 0.0;
7120
+ float shed = 0.0;
7121
+ for (int i = 0; i < 8; i++) {
7122
+ if (i >= uRockCount) break;
7123
+ vec4 rock = uRocks[i];
7124
+ float R = max(rock.z, 0.02);
7125
+ vec2 rel = vWorldPosition.xz - rock.xy;
7126
+ float d = length(rel);
7127
+ if (d > R * 6.0) continue;
7128
+ float breaks = rock.w;
7129
+ // flow-space: +s downstream of the rock, t across it
7130
+ float s = dot(rel, dir);
7131
+ float t = dot(rel, vec2(-dir.y, dir.x));
7132
+ // the doublet: strength falls as 1/r², and inside the rock it is capped
7133
+ // so the field never explodes at the centre
7134
+ float rr = max(d, R);
7135
+ // the BEND is not whitewater: even a drowned stone turns the sheen
7136
+ // around itself, so it never fades out with how proud the rock stands
7137
+ bend += (rel / rr) * (R * R / (rr * rr)) * max(breaks, 0.55);
7138
+ // BOW: a cushion of piled water on the upstream face, brightest dead
7139
+ // ahead of the stone and gone by its shoulders
7140
+ float upstream = smoothstep(0.0, -R * 1.1, s);
7141
+ bow += upstream * (1.0 - smoothstep(R * 0.9, R * 2.0, d)) *
7142
+ (1.0 - smoothstep(R * 0.7, R * 1.6, abs(t))) * breaks;
7143
+ // WAKE: a V opening downstream, fading over a few radii
7144
+ float len = R * 5.0;
7145
+ float along = clamp(s / len, 0.0, 1.0);
7146
+ float halfWake = R * (0.5 + 1.3 * along);
7147
+ wake += step(0.0, s) * (1.0 - along) * (1.0 - along) *
7148
+ (1.0 - smoothstep(halfWake * 0.45, halfWake, abs(t))) * breaks;
7149
+ // the SHOULDERS shed water fastest — that is where the white tears off
7150
+ shed += (1.0 - smoothstep(R * 0.8, R * 1.7, d)) *
7151
+ smoothstep(R * 0.35, R * 0.95, abs(t)) * step(-R * 0.6, s) * breaks;
7152
+ }
7153
+ // a bent surface, not a bent texture: rotating the frame the ripple normal
7154
+ // is built in is what makes the sheen sweep around a stone
7155
+ dir = normalize(dir + clamp(bend, vec2(-1.2), vec2(1.2)));
7156
+
6973
7157
  vec3 dir3 = vec3(dir.x, 0.0, dir.y);
6974
7158
  vec3 perp3 = vec3(-dir.y, 0.0, dir.x);
6975
7159
 
@@ -7036,6 +7220,17 @@ void main() {
7036
7220
  float foam = smoothstep(0.74 - 0.45 * drive, 1.02 - 0.24 * drive, streak) * min(drive, 1.0);
7037
7221
  // crest froth: the tops of standing waves in a real rapid always carry white
7038
7222
  foam = max(foam, smoothstep(0.68, 0.96, ripple.x) * smoothstep(0.4, 1.0, rapids) * uFoam * 0.5);
7223
+
7224
+ // rock whitewater: the cushion is a smooth pile (white but soft-edged), the
7225
+ // wake and the shoulders are TORN, so they ride the advected noise and boil
7226
+ // instead of sitting there as a painted decal
7227
+ float boil = 0.5 + 0.5 * sin(uTime * 3.1 + vFlowUv.x * 2.7);
7228
+ float rockFoam = clamp(bow * 1.6, 0.0, 1.0) * (0.62 + 0.38 * boil);
7229
+ rockFoam = max(rockFoam, clamp(wake * 1.5, 0.0, 1.0) * smoothstep(0.24, 0.72, streak) * 1.3);
7230
+ rockFoam = max(rockFoam, clamp(shed * 1.5, 0.0, 1.0) * smoothstep(0.32, 0.8, streak));
7231
+ // a boulder in a millpond barely whitens; the same boulder in a chute tears
7232
+ foam = max(foam, clamp(rockFoam, 0.0, 1.0) * smoothstep(0.15, 0.9, speed) * uFoam);
7233
+
7039
7234
  // froth can never be more present than the water carrying it
7040
7235
  foam = clamp(foam, 0.0, 1.0) * smoothstep(0.02, 0.14, column);
7041
7236
 
@@ -7132,6 +7327,38 @@ const METERS_PER_ACROSS_SEGMENT = 1.5;
7132
7327
  * flow over it, wide enough that terrain grain does not fizz the whole river. */
7133
7328
  const COLUMN_BLUR_PER_HALF_WIDTH = .35;
7134
7329
  const COLUMN_BLUR_MAX = 1.2;
7330
+ /**
7331
+ * Self-carved bed. The cut is sized off the WATER COLUMN, not off the width:
7332
+ * a channel's banks are as steep as its banks are, and a taper scaled to width
7333
+ * turns a wide river into a shallow saucer the water floods far past its edge.
7334
+ *
7335
+ * `depth` is the trench in water columns; `bank` the smoothstep blend beyond
7336
+ * the flat bed, also in columns. The waterline crosses that blend at
7337
+ * {@link CARVE_WATERLINE_T} of its width (smoothstep(t) = 1 − 1/depth), which
7338
+ * is what lets the floor be narrowed so the WATER ends up the authored width.
7339
+ */
7340
+ const CARVE_DEPTH = 2.2;
7341
+ const CARVE_BANK = 2;
7342
+ const CARVE_WATERLINE_T = .52;
7343
+ /** Never cut the floor away entirely, however steep the bank works out. */
7344
+ const CARVE_FLOOR_MIN = .3;
7345
+ /** A trench thinner than this many terrain cells cannot survive the grid it is
7346
+ * sampled on — it smooths away to nothing and the creek is back on raw ground.
7347
+ * Widening it is the honest failure: better a bed too wide than no bed. */
7348
+ const CARVE_MIN_CELLS = 1.5;
7349
+ /** Deepest the bed may be cut, in water columns — the budget for digging
7350
+ * THROUGH a rise so the water keeps flowing, before it would read as a canyon. */
7351
+ const CARVE_MAX_DEPTH = 7;
7352
+ /** The fall the carved floor keeps even where the ground does not (m per m). */
7353
+ const CARVE_MIN_GRADE = .004;
7354
+ /** Rocks the shader can carry at once (the nearest ones win). */
7355
+ const ROCK_SLOTS = 8;
7356
+ /** How often the tree is re-scanned for things standing in the water (s). */
7357
+ const ROCK_RESCAN = .5;
7358
+ /** A rock stops mattering past this many of its own radii from the camera —
7359
+ * never sooner than ROCK_FADE_MIN, or a pebble would blink out at arm's reach. */
7360
+ const ROCK_FADE_RADII = 42;
7361
+ const ROCK_FADE_MIN = 26;
7135
7362
  /** How hard the current drags a body toward the water's own velocity (1/s at
7136
7363
  * `flowForce: 1`). Drag, not thrust: nothing ever outruns the river. Tuned
7137
7364
  * against CharacterController3D, whose stand-still damping fights it — at this
@@ -7150,6 +7377,8 @@ const DEFAULT_COLORS = {
7150
7377
  bank: "#465447"
7151
7378
  };
7152
7379
  const FOAM_COLOR = "#e9f2ef";
7380
+ /** Per-frame scratch (no allocation in the render path). */
7381
+ const rockCamScratch = new Vector3();
7153
7382
  /** Fixed look constants — the props stay intent-level, these carry the craft. */
7154
7383
  const LOOK = {
7155
7384
  /** Pattern units per meter of flow space. */
@@ -7205,6 +7434,7 @@ var River3D = class River3D extends Node3D {
7205
7434
  sunColor: { default: "#fff6e0" },
7206
7435
  sunIntensity: { default: 1 },
7207
7436
  terrain: { default: "" },
7437
+ carve: { default: true },
7208
7438
  flowForce: { default: 1 },
7209
7439
  spray: { default: 1 }
7210
7440
  };
@@ -7240,6 +7470,18 @@ var River3D = class River3D extends Node3D {
7240
7470
  sunIntensity = 1;
7241
7471
  /** Drape target path; empty = auto-find the first Terrain3D in the tree. */
7242
7472
  terrain = "";
7473
+ /**
7474
+ * Cut the bed. Real water arrives on a hillside and erodes itself a channel;
7475
+ * a ribbon laid on raw ground has nothing holding it and reads as a strip of
7476
+ * blue hovering over the grass. With this on, the river trenches its own
7477
+ * course into the terrain it drapes on — banks the player can walk into, a
7478
+ * collider and `heightAt` that agree with it, and splat that paints the cut.
7479
+ *
7480
+ * Off means you carve it yourself with `Terrain3D.channels` (or the reach is
7481
+ * genuinely a flat flood plain). Either way the surface is FITTED to
7482
+ * whatever ground it finds: it can never stand above its own banks.
7483
+ */
7484
+ carve = true;
7243
7485
  /** How hard the current sweeps bodies downstream (0 = visual only). */
7244
7486
  flowForce = 1;
7245
7487
  /**
@@ -7255,6 +7497,11 @@ var River3D = class River3D extends Node3D {
7255
7497
  sprayKey = "";
7256
7498
  courseKey = "";
7257
7499
  bedAt = null;
7500
+ /** Whether the last course was fitted to real ground (see buildRiverRings). */
7501
+ fitted = false;
7502
+ /** Everything standing in the current, rescanned on a slow timer. */
7503
+ rocks = [];
7504
+ rockScanAt = -1;
7258
7505
  world = [
7259
7506
  0,
7260
7507
  0,
@@ -7295,6 +7542,12 @@ var River3D = class River3D extends Node3D {
7295
7542
  _createObject3D() {
7296
7543
  return new Mesh(new BufferGeometry(), this.buildMaterial());
7297
7544
  }
7545
+ onReady() {
7546
+ this.ensureCourse();
7547
+ }
7548
+ onExitTree() {
7549
+ findDrapeTerrain(this, this.terrain)?._setExternalChannel(this, null);
7550
+ }
7298
7551
  update(dt) {
7299
7552
  this.time += dt;
7300
7553
  this.ensureCourse();
@@ -7307,6 +7560,16 @@ var River3D = class River3D extends Node3D {
7307
7560
  this.rebuildIfNeeded(mesh);
7308
7561
  this.syncUniforms(mesh);
7309
7562
  }
7563
+ /** Rocks are picked per FRAME (nearest to the eye), scanned on a timer. */
7564
+ _onRender3D(ctx) {
7565
+ if (this.rings.length === 0) return;
7566
+ if (this.rockScanAt < 0 || this.time - this.rockScanAt > ROCK_RESCAN) {
7567
+ this.rockScanAt = this.time;
7568
+ this.scanRocks();
7569
+ }
7570
+ const u = this._ensureObject3D().material.uniforms;
7571
+ this.syncRocks(u, ctx.camera.getWorldPosition(rockCamScratch));
7572
+ }
7310
7573
  /**
7311
7574
  * Where a WORLD point sits in the channel — the query gameplay scripts run
7312
7575
  * (steer a raft, drown a torch, decide a ford is too fast to cross). `null`
@@ -7359,6 +7622,63 @@ var River3D = class River3D extends Node3D {
7359
7622
  widthProfile() {
7360
7623
  return this.widths.length > 0 ? this.widths : [this.width];
7361
7624
  }
7625
+ /**
7626
+ * Cut (or release) this river's trench in the terrain it drapes on.
7627
+ *
7628
+ * Runs at ready as well as on every course rebuild: the heightfield collider
7629
+ * is built before the first render sync, so a bed cut only at draw time
7630
+ * would leave the player walking on ground that is no longer there.
7631
+ */
7632
+ applyCarve(terrain) {
7633
+ if (!terrain) return;
7634
+ if (!this.carve || this.path.length < 2) {
7635
+ terrain._setExternalChannel(this, null);
7636
+ return;
7637
+ }
7638
+ terrain._setExternalChannel(this, null);
7639
+ const [wx, , wz] = worldXZY(this);
7640
+ const [tx, , tz] = worldXZY(terrain);
7641
+ const widths = this.widthProfile();
7642
+ const hm = terrain._heightmap();
7643
+ const cell = (terrain.size[0] ?? 1) / Math.max(1, hm.segsX);
7644
+ const line = resamplePath(this.path.map((p) => [(p[0] ?? 0) + wx - tx, (p[1] ?? 0) + wz - tz]), Math.max(cell, this.depth));
7645
+ if (line.length < 2) {
7646
+ terrain._setExternalChannel(this, null);
7647
+ return;
7648
+ }
7649
+ const total = line[line.length - 1].along || 1;
7650
+ const ground = line.map((s) => terrain._heightmap().heightAt(s.x, s.z));
7651
+ const baseCut = this.depth * CARVE_DEPTH;
7652
+ const maxCut = this.depth * CARVE_MAX_DEPTH;
7653
+ const floor = new Array(line.length);
7654
+ for (let i = 0; i < line.length; i++) {
7655
+ const wanted = ground[i] - baseCut;
7656
+ if (i === 0) {
7657
+ floor[i] = wanted;
7658
+ continue;
7659
+ }
7660
+ const run = line[i].along - line[i - 1].along;
7661
+ const carried = floor[i - 1] - run * CARVE_MIN_GRADE;
7662
+ floor[i] = Math.max(Math.min(wanted, carried), ground[i] - maxCut);
7663
+ }
7664
+ const specs = [];
7665
+ for (let i = 1; i < line.length; i++) {
7666
+ const a = line[i - 1];
7667
+ const b = line[i];
7668
+ const cut = Math.max((ground[i - 1] + ground[i]) / 2 - (floor[i - 1] + floor[i]) / 2, 0);
7669
+ if (cut <= .001) continue;
7670
+ const half = widthAlong(widths, a.along / total) / 2;
7671
+ const bank = Math.max(this.depth * CARVE_BANK, cell * CARVE_MIN_CELLS);
7672
+ const floorHalf = Math.max(half - bank * CARVE_WATERLINE_T, half * CARVE_FLOOR_MIN, cell * .5);
7673
+ specs.push({
7674
+ path: [[a.x, a.z], [b.x, b.z]],
7675
+ width: floorHalf * 2,
7676
+ depth: cut,
7677
+ taper: bank
7678
+ });
7679
+ }
7680
+ terrain._setExternalChannel(this, specs);
7681
+ }
7362
7682
  /** Resolve the drape terrain; an explicit path that misses is a hard error. */
7363
7683
  resolveTerrain() {
7364
7684
  const terrain = findDrapeTerrain(this, this.terrain);
@@ -7378,6 +7698,7 @@ var River3D = class River3D extends Node3D {
7378
7698
  this.widths,
7379
7699
  this.depth,
7380
7700
  this.flowSpeed,
7701
+ this.carve,
7381
7702
  terrain ? terrain.name : "",
7382
7703
  world
7383
7704
  ]);
@@ -7385,6 +7706,7 @@ var River3D = class River3D extends Node3D {
7385
7706
  this.courseKey = key;
7386
7707
  this.world = world;
7387
7708
  this.bedAt = null;
7709
+ this.applyCarve(terrain);
7388
7710
  if (this.path.length < 2) {
7389
7711
  this.rings = [];
7390
7712
  this.falls = [];
@@ -7392,6 +7714,7 @@ var River3D = class River3D extends Node3D {
7392
7714
  }
7393
7715
  const [wx, wy, wz] = world;
7394
7716
  this.bedAt = terrain ? (lx, lz) => terrain.heightAt(lx + wx, lz + wz) - wy : () => -this.depth;
7717
+ this.fitted = terrain !== null;
7395
7718
  const widths = this.widthProfile();
7396
7719
  const narrow = Math.min(...widths);
7397
7720
  this.rings = buildRiverRings({
@@ -7400,7 +7723,8 @@ var River3D = class River3D extends Node3D {
7400
7723
  depth: this.depth,
7401
7724
  flowSpeed: this.flowSpeed,
7402
7725
  step: Math.min(Math.max(narrow * STEP_PER_WIDTH, STEP_MIN), STEP_MAX),
7403
- bedAt: this.bedAt
7726
+ bedAt: this.bedAt,
7727
+ fit: this.fitted
7404
7728
  });
7405
7729
  this.falls = detectFalls(this.rings);
7406
7730
  return true;
@@ -7419,7 +7743,7 @@ var River3D = class River3D extends Node3D {
7419
7743
  const wide = Math.max(...widths);
7420
7744
  const data = buildRiverGeometry(this.rings, {
7421
7745
  acrossSegments: Math.min(ACROSS_MAX, Math.max(ACROSS_MIN, Math.round(wide / METERS_PER_ACROSS_SEGMENT))),
7422
- bankOvershoot: BANK_OVERSHOOT,
7746
+ bankOvershoot: this.fitted ? 1 : BANK_OVERSHOOT,
7423
7747
  bedBlur: Math.min(narrow / 2 * COLUMN_BLUR_PER_HALF_WIDTH, COLUMN_BLUR_MAX),
7424
7748
  bedAt: this.bedAt
7425
7749
  });
@@ -7462,6 +7786,8 @@ var River3D = class River3D extends Node3D {
7462
7786
  uRippleStrength: { value: LOOK.rippleStrength * this.ripples },
7463
7787
  uFlowRate: { value: LOOK.flowRate },
7464
7788
  uSparkle: { value: this.ripples },
7789
+ uRockCount: { value: 0 },
7790
+ uRocks: { value: Array.from({ length: ROCK_SLOTS }, () => new Vector4()) },
7465
7791
  uFresnelScale: { value: LOOK.fresnelScale },
7466
7792
  uStandingAmp: { value: LOOK.standingAmp },
7467
7793
  uGlideAmp: { value: LOOK.glideAmp }
@@ -7472,6 +7798,88 @@ var River3D = class River3D extends Node3D {
7472
7798
  side: DoubleSide
7473
7799
  });
7474
7800
  }
7801
+ /**
7802
+ * Find everything standing in the current.
7803
+ *
7804
+ * A boulder in a creek is not a physics problem — it is a thing the water
7805
+ * has to be TOLD about, or the stream runs through it as if it were painted
7806
+ * on. Any mesh whose footprint lands inside the wetted channel and whose top
7807
+ * reaches near the surface counts, `InstancedMesh3D` rows included (a rock
7808
+ * scatter is one node with fifty stones in it).
7809
+ *
7810
+ * Scanned on a slow timer rather than per frame: rocks do not move often,
7811
+ * and a river with a hundred of them still writes only the nearest few.
7812
+ */
7813
+ scanRocks() {
7814
+ const found = [];
7815
+ const [wx, wy, wz] = this.world;
7816
+ /** Keep a candidate if it actually stands IN the water column. */
7817
+ const consider = (x, z, radius, bottom, top) => {
7818
+ if (radius <= .02) return;
7819
+ const hit = projectToRiver(this.rings, x - wx, z - wz);
7820
+ if (!hit || !hit.inside) return;
7821
+ const surface = hit.surfaceY + wy;
7822
+ if (bottom > surface) return;
7823
+ const bed = surface - this.depth;
7824
+ if (top < bed) return;
7825
+ const breaks = Math.min(Math.max((top - bed) / Math.max(this.depth, .05), .2), 1);
7826
+ found.push({
7827
+ x,
7828
+ z,
7829
+ radius,
7830
+ breaks
7831
+ });
7832
+ };
7833
+ let root = this;
7834
+ while (root.parent) root = root.parent;
7835
+ const walk = (node) => {
7836
+ if (node instanceof InstancedMesh3D) {
7837
+ const [sx, sy, sz] = node.size;
7838
+ const [ox, oy, oz] = worldXZY(node);
7839
+ for (const row of node.transforms) {
7840
+ const scale = row[4] ?? 1;
7841
+ const x = ox + (row[0] ?? 0);
7842
+ const y = oy + (row[1] ?? 0);
7843
+ const z = oz + (row[2] ?? 0);
7844
+ const half = (sy ?? 1) * scale / 2;
7845
+ consider(x, z, Math.max(sx, sz) * scale / 2, y - half, y + half);
7846
+ }
7847
+ } else if (node instanceof MeshInstance3D) {
7848
+ const [sx, sy, sz] = node.size;
7849
+ const [x, y, z] = worldXZY(node);
7850
+ const half = (sy ?? 1) / 2;
7851
+ consider(x, z, Math.max(sx, sz) / 2, y - half, y + half);
7852
+ }
7853
+ for (const child of node.children) walk(child);
7854
+ };
7855
+ walk(root);
7856
+ this.rocks = found;
7857
+ }
7858
+ /** Write the nearest rocks into the shader, faded in by distance. */
7859
+ syncRocks(u, camera) {
7860
+ const slots = u.uRocks?.value;
7861
+ const count = u.uRockCount;
7862
+ if (!slots || !count) return;
7863
+ if (this.rocks.length === 0) {
7864
+ count.value = 0;
7865
+ return;
7866
+ }
7867
+ const near = [...this.rocks].map((r) => ({
7868
+ r,
7869
+ d: (r.x - camera.x) ** 2 + (r.z - camera.z) ** 2
7870
+ })).sort((a, b) => a.d - b.d).slice(0, ROCK_SLOTS);
7871
+ let n = 0;
7872
+ for (const { r, d } of near) {
7873
+ const far = Math.max(r.radius * ROCK_FADE_RADII, ROCK_FADE_MIN);
7874
+ const t = Math.min(Math.sqrt(d) / far, 1);
7875
+ const k = Math.min(Math.max((t - .6) / .4, 0), 1);
7876
+ const fade = 1 - k * k * (3 - 2 * k);
7877
+ if (fade <= .01) continue;
7878
+ slots[n]?.set(r.x, r.z, r.radius, r.breaks * fade);
7879
+ n++;
7880
+ }
7881
+ count.value = n;
7882
+ }
7475
7883
  syncUniforms(mesh) {
7476
7884
  const u = mesh.material.uniforms;
7477
7885
  const set = (key, value) => {
package/dist/test.js CHANGED
@@ -6,7 +6,7 @@ import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
6
6
  import { i as getNodeSchema, s as mergeStaticProps } from "./registry-BVJ2HbCn.js";
7
7
  import { n as registerGameplayBehaviors } from "./gameplay-Ddk13pQ6.js";
8
8
  import { t as registerNodes2D } from "./register-CvpSUU3O.js";
9
- import { t as registerNodes3D } from "./register-CeWtdiCw.js";
9
+ import { t as registerNodes3D } from "./register-DcHXS1MA.js";
10
10
  import { t as registerNodesNet } from "./register-BFFE1Mh1.js";
11
11
  //#region src/test/index.ts
12
12
  /**
@@ -127,7 +127,7 @@ async function runScript(json, opts) {
127
127
  const { enablePhysics2D } = await import("./physics-2d-DiVFFlH3.js").then((n) => n.r);
128
128
  await enablePhysics2D(engine);
129
129
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
130
- const { enablePhysics3D } = await import("./physics-3d-Cd7Jl8w1.js").then((n) => n.r);
130
+ const { enablePhysics3D } = await import("./physics-3d-4mGzOZ2J.js").then((n) => n.r);
131
131
  await enablePhysics3D(engine);
132
132
  }
133
133
  const failures = [];
@@ -232,7 +232,7 @@ async function createPlaySession(json, opts = {}) {
232
232
  const { enablePhysics2D } = await import("./physics-2d-DiVFFlH3.js").then((n) => n.r);
233
233
  await enablePhysics2D(engine);
234
234
  } else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
235
- const { enablePhysics3D } = await import("./physics-3d-Cd7Jl8w1.js").then((n) => n.r);
235
+ const { enablePhysics3D } = await import("./physics-3d-4mGzOZ2J.js").then((n) => n.r);
236
236
  await enablePhysics3D(engine);
237
237
  }
238
238
  const stepMs = 1e3 / (opts.fixedHz ?? 60);